mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-29 15:35:31 +08:00
Merge branch 'main' into feat/notifications
This commit is contained in:
@@ -3,8 +3,8 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
import matter from 'gray-matter';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { parseFrontmatter } from '../utils/frontmatter.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -38,7 +38,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
// Parse markdown file for metadata
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, 'utf8');
|
||||
const { data: frontmatter, content: commandContent } = matter(content);
|
||||
const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
|
||||
|
||||
// Calculate relative path from baseDir for command name
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
@@ -475,7 +475,7 @@ router.post('/load', async (req, res) => {
|
||||
|
||||
// Read and parse the command file
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = matter(content);
|
||||
const { data: metadata, content: commandContent } = parseFrontmatter(content);
|
||||
|
||||
res.json({
|
||||
path: commandPath,
|
||||
@@ -560,7 +560,7 @@ router.post('/execute', async (req, res) => {
|
||||
}
|
||||
}
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = matter(content);
|
||||
const { data: metadata, content: commandContent } = parseFrontmatter(content);
|
||||
// Basic argument replacement (will be enhanced in command parser utility)
|
||||
let processedContent = commandContent;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { extractProjectDirectory } from '../projects.js';
|
||||
@@ -8,7 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -47,15 +46,71 @@ function spawnAsync(command, args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Input validation helpers (defense-in-depth)
|
||||
function validateCommitRef(commit) {
|
||||
// Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
|
||||
if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
|
||||
throw new Error('Invalid commit reference');
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
function validateBranchName(branch) {
|
||||
if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
|
||||
throw new Error('Invalid branch name');
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
function validateFilePath(file, projectPath) {
|
||||
if (!file || file.includes('\0')) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
// Prevent path traversal: resolve the file relative to the project root
|
||||
// and ensure the result stays within the project directory
|
||||
if (projectPath) {
|
||||
const resolved = path.resolve(projectPath, file);
|
||||
const normalizedRoot = path.resolve(projectPath) + path.sep;
|
||||
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
|
||||
throw new Error('Invalid file path: path traversal detected');
|
||||
}
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function validateRemoteName(remote) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
|
||||
throw new Error('Invalid remote name');
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
|
||||
function validateProjectPath(projectPath) {
|
||||
if (!projectPath || projectPath.includes('\0')) {
|
||||
throw new Error('Invalid project path');
|
||||
}
|
||||
const resolved = path.resolve(projectPath);
|
||||
// Must be an absolute path after resolution
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
throw new Error('Invalid project path: must be absolute');
|
||||
}
|
||||
// Block obviously dangerous paths
|
||||
if (resolved === '/' || resolved === path.sep) {
|
||||
throw new Error('Invalid project path: root directory not allowed');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
async function getActualProjectPath(projectName) {
|
||||
let projectPath;
|
||||
try {
|
||||
return await extractProjectDirectory(projectName);
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||
// Fallback to the old method
|
||||
return projectName.replace(/-/g, '/');
|
||||
throw new Error(`Unable to resolve project path for "${projectName}"`);
|
||||
}
|
||||
return validateProjectPath(projectPath);
|
||||
}
|
||||
|
||||
// Helper function to strip git diff headers
|
||||
@@ -98,19 +153,140 @@ async function validateGitRepository(projectPath) {
|
||||
|
||||
try {
|
||||
// Allow any directory that is inside a work tree (repo root or nested folder).
|
||||
const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
|
||||
const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
|
||||
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
|
||||
if (!isInsideWorkTree) {
|
||||
throw new Error('Not inside a git work tree');
|
||||
}
|
||||
|
||||
// Ensure git can resolve the repository root for this directory.
|
||||
await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
||||
} catch {
|
||||
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
||||
}
|
||||
}
|
||||
|
||||
function getGitErrorDetails(error) {
|
||||
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
|
||||
}
|
||||
|
||||
function isMissingHeadRevisionError(error) {
|
||||
const errorDetails = getGitErrorDetails(error).toLowerCase();
|
||||
return errorDetails.includes('unknown revision')
|
||||
|| errorDetails.includes('ambiguous argument')
|
||||
|| errorDetails.includes('needed a single revision')
|
||||
|| errorDetails.includes('bad revision');
|
||||
}
|
||||
|
||||
async function getCurrentBranchName(projectPath) {
|
||||
try {
|
||||
// symbolic-ref works even when the repository has no commits.
|
||||
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
|
||||
const branchName = stdout.trim();
|
||||
if (branchName) {
|
||||
return branchName;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to rev-parse for detached HEAD and older git edge cases.
|
||||
}
|
||||
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function repositoryHasCommits(projectPath) {
|
||||
try {
|
||||
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingHeadRevisionError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRepositoryRootPath(projectPath) {
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
function normalizeRepositoryRelativeFilePath(filePath) {
|
||||
return String(filePath)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\/+/, '')
|
||||
.replace(/^\/+/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseStatusFilePaths(statusOutput) {
|
||||
return statusOutput
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
const statusPath = line.substring(3);
|
||||
const renamedFilePath = statusPath.split(' -> ')[1];
|
||||
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
|
||||
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
||||
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
|
||||
const candidates = [normalizedFilePath];
|
||||
|
||||
if (
|
||||
projectRelativePath
|
||||
&& projectRelativePath !== '.'
|
||||
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
|
||||
) {
|
||||
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set(candidates.filter(Boolean)));
|
||||
}
|
||||
|
||||
async function resolveRepositoryFilePath(projectPath, filePath) {
|
||||
validateFilePath(filePath);
|
||||
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
|
||||
|
||||
for (const candidateFilePath of candidateFilePaths) {
|
||||
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
|
||||
if (stdout.trim()) {
|
||||
return {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath: candidateFilePath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
|
||||
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
||||
if (!normalizedFilePath.includes('/')) {
|
||||
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
|
||||
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
|
||||
const suffixMatches = changedFilePaths.filter(
|
||||
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
|
||||
);
|
||||
|
||||
if (suffixMatches.length === 1) {
|
||||
return {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath: suffixMatches[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath: candidateFilePaths[0],
|
||||
};
|
||||
}
|
||||
|
||||
// Get git status for a project
|
||||
router.get('/status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
@@ -125,24 +301,11 @@ router.get('/status', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch - handle case where there are no commits yet
|
||||
let branch = 'main';
|
||||
let hasCommits = true;
|
||||
try {
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
branch = branchOutput.trim();
|
||||
} catch (error) {
|
||||
// No HEAD exists - repository has no commits yet
|
||||
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
|
||||
hasCommits = false;
|
||||
branch = 'main';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
const hasCommits = await repositoryHasCommits(projectPath);
|
||||
|
||||
// Get git status
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
||||
|
||||
const modified = [];
|
||||
const added = [];
|
||||
@@ -200,44 +363,65 @@ router.get('/diff', async (req, res) => {
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check if file is untracked or deleted
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
let diff;
|
||||
if (isUntracked) {
|
||||
// For untracked files, show the entire file content as additions
|
||||
const filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// For directories, show a simple message
|
||||
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
|
||||
diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
|
||||
} else {
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||
diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||
lines.map(line => `+${line}`).join('\n');
|
||||
}
|
||||
} else if (isDeleted) {
|
||||
// For deleted files, show the entire file content from HEAD as deletions
|
||||
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
const { stdout: fileContent } = await spawnAsync(
|
||||
'git',
|
||||
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
||||
diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
||||
lines.map(line => `-${line}`).join('\n');
|
||||
} else {
|
||||
// Get diff for tracked files
|
||||
// First check for unstaged changes (working tree vs index)
|
||||
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
|
||||
const { stdout: unstagedDiff } = await spawnAsync(
|
||||
'git',
|
||||
['diff', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
|
||||
if (unstagedDiff) {
|
||||
// Show unstaged changes if they exist
|
||||
diff = stripDiffHeaders(unstagedDiff);
|
||||
} else {
|
||||
// If no unstaged changes, check for staged changes (index vs HEAD)
|
||||
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
|
||||
const { stdout: stagedDiff } = await spawnAsync(
|
||||
'git',
|
||||
['diff', '--cached', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
diff = stripDiffHeaders(stagedDiff) || '';
|
||||
}
|
||||
}
|
||||
@@ -263,8 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check file status
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
@@ -273,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
|
||||
if (isDeleted) {
|
||||
// For deleted files, get content from HEAD
|
||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
const { stdout: headContent } = await spawnAsync(
|
||||
'git',
|
||||
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
oldContent = headContent;
|
||||
currentContent = headContent; // Show the deleted content in editor
|
||||
} else {
|
||||
// Get current file content
|
||||
const filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
@@ -291,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
if (!isUntracked) {
|
||||
// Get the old content from HEAD for tracked files
|
||||
try {
|
||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
const { stdout: headContent } = await spawnAsync(
|
||||
'git',
|
||||
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
oldContent = headContent;
|
||||
} catch (error) {
|
||||
// File might be newly added to git (staged but not committed)
|
||||
@@ -328,17 +529,17 @@ router.post('/initial-commit', async (req, res) => {
|
||||
|
||||
// Check if there are already commits
|
||||
try {
|
||||
await execAsync('git rev-parse HEAD', { cwd: projectPath });
|
||||
await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
|
||||
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
||||
} catch (error) {
|
||||
// No HEAD - this is good, we can create initial commit
|
||||
}
|
||||
|
||||
// Add all files
|
||||
await execAsync('git add .', { cwd: projectPath });
|
||||
await spawnAsync('git', ['add', '.'], { cwd: projectPath });
|
||||
|
||||
// Create initial commit
|
||||
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
||||
} catch (error) {
|
||||
@@ -369,14 +570,16 @@ router.post('/commit', async (req, res) => {
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
|
||||
// Stage selected files
|
||||
for (const file of files) {
|
||||
await execAsync(`git add "${file}"`, { cwd: projectPath });
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
}
|
||||
|
||||
|
||||
// Commit with message
|
||||
const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -385,6 +588,53 @@ router.post('/commit', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Revert latest local commit (keeps changes staged)
|
||||
router.post('/revert-local-commit', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
try {
|
||||
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No local commit to revert',
|
||||
details: 'This repository has no commit yet.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Soft reset rewinds one commit while preserving all file changes in the index.
|
||||
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
|
||||
} catch (error) {
|
||||
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
|
||||
const isInitialCommit = errorDetails.includes('HEAD~1') &&
|
||||
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
|
||||
|
||||
if (!isInitialCommit) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
|
||||
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: 'Latest local commit reverted successfully. Changes were kept staged.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git revert local commit error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get list of branches
|
||||
router.get('/branches', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
@@ -400,7 +650,7 @@ router.get('/branches', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get all branches
|
||||
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
|
||||
|
||||
// Parse branches
|
||||
const branches = stdout
|
||||
@@ -439,7 +689,8 @@ router.post('/checkout', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Checkout the branch
|
||||
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
|
||||
validateBranchName(branch);
|
||||
const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -460,7 +711,8 @@ router.post('/create-branch', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Create and checkout new branch
|
||||
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
|
||||
validateBranchName(branch);
|
||||
const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -509,8 +761,8 @@ router.get('/commits', async (req, res) => {
|
||||
// Get stats for each commit
|
||||
for (const commit of commits) {
|
||||
try {
|
||||
const { stdout: stats } = await execAsync(
|
||||
`git show --stat --format='' ${commit.hash}`,
|
||||
const { stdout: stats } = await spawnAsync(
|
||||
'git', ['show', '--stat', '--format=', commit.hash],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
|
||||
@@ -536,14 +788,22 @@ router.get('/commit-diff', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
|
||||
// Validate commit reference (defense-in-depth)
|
||||
validateCommitRef(commit);
|
||||
|
||||
// Get diff for the commit
|
||||
const { stdout } = await execAsync(
|
||||
`git show ${commit}`,
|
||||
const { stdout } = await spawnAsync(
|
||||
'git', ['show', commit],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
res.json({ diff: stdout });
|
||||
|
||||
const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
|
||||
const diff = isTruncated
|
||||
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
|
||||
: stdout;
|
||||
|
||||
res.json({ diff, isTruncated });
|
||||
} catch (error) {
|
||||
console.error('Git commit diff error:', error);
|
||||
res.json({ error: error.message });
|
||||
@@ -565,17 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
|
||||
// Get diff for selected files
|
||||
let diffContext = '';
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git diff HEAD -- "${file}"`,
|
||||
{ cwd: projectPath }
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
const { stdout } = await spawnAsync(
|
||||
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath }
|
||||
);
|
||||
if (stdout) {
|
||||
diffContext += `\n--- ${file} ---\n${stdout}`;
|
||||
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting diff for ${file}:`, error);
|
||||
@@ -587,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
// Try to get content of untracked files
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(projectPath, file);
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||
} else {
|
||||
diffContext += `\n--- ${file} (new directory) ---\n`;
|
||||
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error);
|
||||
@@ -763,44 +1027,51 @@ router.get('/remote-status', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
const hasCommits = await repositoryHasCommits(projectPath);
|
||||
|
||||
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
|
||||
const hasRemote = remotes.length > 0;
|
||||
const fallbackRemoteName = hasRemote
|
||||
? (remotes.includes('origin') ? 'origin' : remotes[0])
|
||||
: null;
|
||||
|
||||
// Repositories initialized with `git init` can have a branch but no commits.
|
||||
// Return a non-error state so the UI can show the initial-commit workflow.
|
||||
if (!hasCommits) {
|
||||
return res.json({
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
remoteName: fallbackRemoteName,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
isUpToDate: false,
|
||||
message: 'Repository has no commits yet'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's a remote tracking branch (smart detection)
|
||||
let trackingBranch;
|
||||
let remoteName;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
trackingBranch = stdout.trim();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
// No upstream branch configured - but check if we have remotes
|
||||
let hasRemote = false;
|
||||
let remoteName = null;
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length > 0) {
|
||||
hasRemote = true;
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
}
|
||||
} catch (remoteError) {
|
||||
// No remotes configured
|
||||
}
|
||||
|
||||
return res.json({
|
||||
return res.json({
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
remoteName,
|
||||
remoteName: fallbackRemoteName,
|
||||
message: 'No remote tracking branch configured'
|
||||
});
|
||||
}
|
||||
|
||||
// Get ahead/behind counts
|
||||
const { stdout: countOutput } = await execAsync(
|
||||
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
|
||||
const { stdout: countOutput } = await spawnAsync(
|
||||
'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
@@ -835,20 +1106,20 @@ router.post('/fetch', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
remoteName = stdout.trim().split('/')[0]; // Extract remote name
|
||||
} catch (error) {
|
||||
// No upstream, try to fetch from origin anyway
|
||||
console.log('No upstream configured, using origin as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
|
||||
|
||||
validateRemoteName(remoteName);
|
||||
const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
|
||||
} catch (error) {
|
||||
console.error('Git fetch error:', error);
|
||||
@@ -876,13 +1147,12 @@ router.post('/pull', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
const tracking = stdout.trim();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
@@ -891,17 +1161,19 @@ router.post('/pull', async (req, res) => {
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Pull completed successfully',
|
||||
validateRemoteName(remoteName);
|
||||
validateBranchName(remoteBranch);
|
||||
const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Pull completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git pull error:', error);
|
||||
|
||||
|
||||
// Enhanced error handling for common pull scenarios
|
||||
let errorMessage = 'Pull failed';
|
||||
let details = error.message;
|
||||
@@ -943,13 +1215,12 @@ router.post('/push', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
const tracking = stdout.trim();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
@@ -958,11 +1229,13 @@ router.post('/push', async (req, res) => {
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Push completed successfully',
|
||||
validateRemoteName(remoteName);
|
||||
validateBranchName(remoteBranch);
|
||||
const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Push completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
@@ -1012,35 +1285,38 @@ router.post('/publish', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate branch name
|
||||
validateBranchName(branch);
|
||||
|
||||
// Get current branch to verify it matches the requested branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const currentBranchName = currentBranch.trim();
|
||||
|
||||
const currentBranchName = await getCurrentBranchName(projectPath);
|
||||
|
||||
if (currentBranchName !== branch) {
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if remote exists
|
||||
let remoteName = 'origin';
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the branch (set upstream and push)
|
||||
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
|
||||
validateRemoteName(remoteName);
|
||||
const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1087,10 +1363,18 @@ router.post('/discard', async (req, res) => {
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check file status to determine correct discard command
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'No changes to discard for this file' });
|
||||
}
|
||||
@@ -1099,7 +1383,7 @@ router.post('/discard', async (req, res) => {
|
||||
|
||||
if (status === '??') {
|
||||
// Untracked file or directory - delete it
|
||||
const filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
@@ -1109,13 +1393,13 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
} else if (status.includes('M') || status.includes('D')) {
|
||||
// Modified or deleted file - restore from HEAD
|
||||
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
||||
await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
} else if (status.includes('A')) {
|
||||
// Added file - unstage it
|
||||
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
|
||||
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Changes discarded for ${file}` });
|
||||
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
|
||||
} catch (error) {
|
||||
console.error('Git discard error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -1133,9 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check if file is actually untracked
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||
@@ -1148,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
}
|
||||
|
||||
// Delete the untracked file or directory
|
||||
const filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Use rm with recursive option for directories
|
||||
await fs.rm(filePath, { recursive: true, force: true });
|
||||
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
|
||||
res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Git delete untracked error:', error);
|
||||
|
||||
303
server/routes/plugins.js
Normal file
303
server/routes/plugins.js
Normal file
@@ -0,0 +1,303 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import mime from 'mime-types';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
scanPlugins,
|
||||
getPluginsConfig,
|
||||
getPluginsDir,
|
||||
savePluginsConfig,
|
||||
getPluginDir,
|
||||
resolvePluginAssetPath,
|
||||
installPluginFromGit,
|
||||
updatePluginFromGit,
|
||||
uninstallPlugin,
|
||||
} from '../utils/plugin-loader.js';
|
||||
import {
|
||||
startPluginServer,
|
||||
stopPluginServer,
|
||||
getPluginPort,
|
||||
isPluginRunning,
|
||||
} from '../utils/plugin-process-manager.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET / — List all installed plugins (includes server running status)
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const plugins = scanPlugins().map(p => ({
|
||||
...p,
|
||||
serverRunning: p.server ? isPluginRunning(p.name) : false,
|
||||
}));
|
||||
res.json({ plugins });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /:name/manifest — Get single plugin manifest
|
||||
router.get('/:name/manifest', (req, res) => {
|
||||
try {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === req.params.name);
|
||||
if (!plugin) {
|
||||
return res.status(404).json({ error: 'Plugin not found' });
|
||||
}
|
||||
res.json(plugin);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /:name/assets/* — Serve plugin static files
|
||||
router.get('/:name/assets/*', (req, res) => {
|
||||
const pluginName = req.params.name;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
const assetPath = req.params[0];
|
||||
|
||||
if (!assetPath) {
|
||||
return res.status(400).json({ error: 'No asset path specified' });
|
||||
}
|
||||
|
||||
const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
|
||||
if (!resolvedPath) {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
const stream = fs.createReadStream(resolvedPath);
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Failed to read asset' });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
|
||||
// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
|
||||
router.put('/:name/enable', async (req, res) => {
|
||||
try {
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: '"enabled" must be a boolean' });
|
||||
}
|
||||
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === req.params.name);
|
||||
if (!plugin) {
|
||||
return res.status(404).json({ error: 'Plugin not found' });
|
||||
}
|
||||
|
||||
const config = getPluginsConfig();
|
||||
config[req.params.name] = { ...config[req.params.name], enabled };
|
||||
savePluginsConfig(config);
|
||||
|
||||
// Start or stop the plugin server as needed
|
||||
if (plugin.server) {
|
||||
if (enabled && !isPluginRunning(plugin.name)) {
|
||||
const pluginDir = getPluginDir(plugin.name);
|
||||
if (pluginDir) {
|
||||
try {
|
||||
await startPluginServer(plugin.name, pluginDir, plugin.server);
|
||||
} catch (err) {
|
||||
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
} else if (!enabled && isPluginRunning(plugin.name)) {
|
||||
await stopPluginServer(plugin.name);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, name: req.params.name, enabled });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to update plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /install — Install plugin from git URL
|
||||
router.post('/install', async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') {
|
||||
return res.status(400).json({ error: '"url" is required and must be a string' });
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
if (!url.startsWith('https://') && !url.startsWith('git@')) {
|
||||
return res.status(400).json({ error: 'URL must start with https:// or git@' });
|
||||
}
|
||||
|
||||
const manifest = await installPluginFromGit(url);
|
||||
|
||||
// Auto-start the server if the plugin has one (enabled by default)
|
||||
if (manifest.server) {
|
||||
const pluginDir = getPluginDir(manifest.name);
|
||||
if (pluginDir) {
|
||||
try {
|
||||
await startPluginServer(manifest.name, pluginDir, manifest.server);
|
||||
} catch (err) {
|
||||
console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, plugin: manifest });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'Failed to install plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:name/update — Pull latest from git (restarts server if running)
|
||||
router.post('/:name/update', async (req, res) => {
|
||||
try {
|
||||
const pluginName = req.params.name;
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
|
||||
const wasRunning = isPluginRunning(pluginName);
|
||||
if (wasRunning) {
|
||||
await stopPluginServer(pluginName);
|
||||
}
|
||||
|
||||
const manifest = await updatePluginFromGit(pluginName);
|
||||
|
||||
// Restart server if it was running before the update
|
||||
if (wasRunning && manifest.server) {
|
||||
const pluginDir = getPluginDir(pluginName);
|
||||
if (pluginDir) {
|
||||
try {
|
||||
await startPluginServer(pluginName, pluginDir, manifest.server);
|
||||
} catch (err) {
|
||||
console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, plugin: manifest });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'Failed to update plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
|
||||
router.all('/:name/rpc/*', async (req, res) => {
|
||||
const pluginName = req.params.name;
|
||||
const rpcPath = req.params[0] || '';
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
|
||||
let port = getPluginPort(pluginName);
|
||||
if (!port) {
|
||||
// Lazily start the plugin server if it exists and is enabled
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
if (!plugin || !plugin.server) {
|
||||
return res.status(503).json({ error: 'Plugin server is not running' });
|
||||
}
|
||||
if (!plugin.enabled) {
|
||||
return res.status(503).json({ error: 'Plugin is disabled' });
|
||||
}
|
||||
const pluginDir = path.join(getPluginsDir(), plugin.dirName);
|
||||
try {
|
||||
port = await startPluginServer(pluginName, pluginDir, plugin.server);
|
||||
} catch (err) {
|
||||
return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Inject configured secrets as headers
|
||||
const config = getPluginsConfig();
|
||||
const pluginConfig = config[pluginName] || {};
|
||||
const secrets = pluginConfig.secrets || {};
|
||||
|
||||
const headers = {
|
||||
'content-type': req.headers['content-type'] || 'application/json',
|
||||
};
|
||||
|
||||
// Add per-plugin secrets as X-Plugin-Secret-* headers
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
|
||||
}
|
||||
|
||||
// Reconstruct query string
|
||||
const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: `/${rpcPath}${qs}`,
|
||||
method: req.method,
|
||||
headers,
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ error: 'Plugin server error', details: err.message });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Forward body (already parsed by express JSON middleware, so re-stringify).
|
||||
// Check content-length to detect whether a body was actually sent, since
|
||||
// req.body can be falsy for valid payloads like 0, false, null, or {}.
|
||||
const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
|
||||
if (hasBody && req.body !== undefined) {
|
||||
const bodyStr = JSON.stringify(req.body);
|
||||
proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
|
||||
proxyReq.write(bodyStr);
|
||||
}
|
||||
|
||||
proxyReq.end();
|
||||
});
|
||||
|
||||
// DELETE /:name — Uninstall plugin (stops server first)
|
||||
router.delete('/:name', async (req, res) => {
|
||||
try {
|
||||
const pluginName = req.params.name;
|
||||
|
||||
// Validate name format to prevent path traversal
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
|
||||
// Stop server and wait for the process to fully exit before deleting files
|
||||
if (isPluginRunning(pluginName)) {
|
||||
await stopPluginServer(pluginName);
|
||||
}
|
||||
|
||||
await uninstallPlugin(pluginName);
|
||||
res.json({ success: true, name: pluginName });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -311,13 +311,11 @@ router.post('/create-workspace', async (req, res) => {
|
||||
* Helper function to get GitHub token from database
|
||||
*/
|
||||
async function getGithubTokenById(tokenId, userId) {
|
||||
const { getDatabase } = await import('../database/db.js');
|
||||
const db = await getDatabase();
|
||||
const { db } = await import('../database/db.js');
|
||||
|
||||
const credential = await db.get(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
||||
[tokenId, userId, 'github_token']
|
||||
);
|
||||
const credential = db.prepare(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'
|
||||
).get(tokenId, userId, 'github_token');
|
||||
|
||||
// Return in the expected format (github_token field for compatibility)
|
||||
if (credential) {
|
||||
|
||||
@@ -2,12 +2,29 @@ import express from 'express';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const router = express.Router();
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { ...options, shell: false });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
child.on('error', (error) => { reject(error); });
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) { resolve({ stdout, stderr }); return; }
|
||||
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
|
||||
error.code = code;
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/git-config', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
@@ -55,8 +72,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
|
||||
userDb.updateGitConfig(userId, gitName, gitEmail);
|
||||
|
||||
try {
|
||||
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
|
||||
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
|
||||
await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
|
||||
await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
|
||||
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
||||
} catch (gitError) {
|
||||
console.error('Error applying git config:', gitError);
|
||||
|
||||
Reference in New Issue
Block a user