Merge branch 'main' into feat/notifications

This commit is contained in:
Simos Mikelatos
2026-03-12 10:43:26 +01:00
committed by GitHub
109 changed files with 6143 additions and 1230 deletions

View File

@@ -1,84 +1,124 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID
const WORKSPACE_TRUST_PATTERNS = [
/workspace trust required/i,
/do you trust the contents of this directory/i,
/working with untrusted contents/i,
/pass --trust,\s*--yolo,\s*or -f/i
];
function isWorkspaceTrustPrompt(text = '') {
if (!text || typeof text !== 'string') {
return false;
}
return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
}
async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let messageBuffer = ''; // Buffer for accumulating assistant messages
let hasRetriedWithTrust = false;
let settled = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedShellCommands: [],
skipPermissions: false
};
// Build Cursor CLI command
const args = [];
const baseArgs = [];
// Build flags allowing both resume and prompt together (reply in existing session)
// Treat presence of sessionId as intention to resume, regardless of resume flag
if (sessionId) {
args.push('--resume=' + sessionId);
baseArgs.push('--resume=' + sessionId);
}
if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions)
args.push('-p', command);
baseArgs.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) {
args.push('--model', model);
baseArgs.push('--model', model);
}
// Request streaming JSON when we are providing a prompt
args.push('--output-format', 'stream-json');
baseArgs.push('--output-format', 'stream-json');
}
// Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) {
args.push('-f');
console.log('⚠️ Using -f flag (skip permissions)');
baseArgs.push('-f');
console.log('Using -f flag (skip permissions)');
}
// Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd();
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
activeCursorProcesses.set(processKey, cursorProcess);
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('📤 Cursor CLI stdout:', rawOutput);
const lines = rawOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
const settleOnce = (callback) => {
if (settled) {
return;
}
settled = true;
callback();
};
const runCursorProcess = (args, runReason = 'initial') => {
const isTrustRetry = runReason === 'trust-retry';
let runSawWorkspaceTrustPrompt = false;
let stdoutLineBuffer = '';
if (isTrustRetry) {
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
}
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
activeCursorProcesses.set(processKey, cursorProcess);
const shouldSuppressForTrustRetry = (text) => {
if (hasRetriedWithTrust || args.includes('--trust')) {
return false;
}
if (!isWorkspaceTrustPrompt(text)) {
return false;
}
runSawWorkspaceTrustPrompt = true;
return true;
};
const processCursorOutputLine = (line) => {
if (!line || !line.trim()) {
return;
}
try {
const response = JSON.parse(line);
console.log('📄 Parsed JSON response:', response);
console.log('Parsed JSON response:', response);
// Handle different message types
switch (response.type) {
case 'system':
@@ -86,14 +126,14 @@ async function spawnCursor(command, options = {}, ws) {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('📝 Captured session ID:', capturedSessionId);
console.log('Captured session ID:', capturedSessionId);
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeCursorProcesses.delete(processKey);
activeCursorProcesses.set(capturedSessionId, cursorProcess);
}
// Set session ID on writer (for API endpoint compatibility)
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
@@ -110,7 +150,7 @@ async function spawnCursor(command, options = {}, ws) {
});
}
}
// Send system info to frontend
ws.send({
type: 'cursor-system',
@@ -119,7 +159,7 @@ async function spawnCursor(command, options = {}, ws) {
});
}
break;
case 'user':
// Forward user message
ws.send({
@@ -128,13 +168,12 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
break;
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const textContent = response.message.content[0].text;
messageBuffer += textContent;
// Send as Claude-compatible format for frontend
ws.send({
type: 'claude-response',
@@ -149,23 +188,14 @@ async function spawnCursor(command, options = {}, ws) {
});
}
break;
case 'result':
// Session complete
console.log('Cursor session result:', response);
// Send final message if we have buffered content
if (messageBuffer) {
ws.send({
type: 'claude-response',
data: {
type: 'content_block_stop'
},
sessionId: capturedSessionId || sessionId || null
});
}
// Send completion event
// Do not emit an extra content_block_stop here.
// The UI already finalizes the streaming message in cursor-result handling,
// and emitting both can produce duplicate assistant messages.
ws.send({
type: 'cursor-result',
sessionId: capturedSessionId || sessionId,
@@ -173,7 +203,7 @@ async function spawnCursor(command, options = {}, ws) {
success: response.subtype === 'success'
});
break;
default:
// Forward any other message types
ws.send({
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
});
}
} catch (parseError) {
console.log('📄 Non-JSON response:', line);
console.log('Non-JSON response:', line);
if (shouldSuppressForTrustRetry(line)) {
return;
}
// If not JSON, send as raw text
ws.send({
type: 'cursor-output',
@@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
}
}
});
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString());
ws.send({
type: 'cursor-error',
error: data.toString(),
sessionId: capturedSessionId || sessionId || null
});
});
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
};
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
});
if (code === 0) {
resolve();
} else {
reject(new Error(`Cursor CLI exited with code ${code}`));
}
});
// Handle process errors
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('Cursor CLI stdout:', rawOutput);
ws.send({
type: 'cursor-error',
error: error.message,
sessionId: capturedSessionId || sessionId || null
// Stream chunks can split JSON objects across packets; keep trailing partial line.
stdoutLineBuffer += rawOutput;
const completeLines = stdoutLineBuffer.split(/\r?\n/);
stdoutLineBuffer = completeLines.pop() || '';
completeLines.forEach((line) => {
processCursorOutputLine(line.trim());
});
});
reject(error);
});
// Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end();
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
const stderrText = data.toString();
console.error('Cursor CLI stderr:', stderrText);
if (shouldSuppressForTrustRetry(stderrText)) {
return;
}
ws.send({
type: 'cursor-error',
error: stderrText,
sessionId: capturedSessionId || sessionId || null
});
});
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Flush any final unterminated stdout line before completion handling.
if (stdoutLineBuffer.trim()) {
processCursorOutputLine(stdoutLineBuffer.trim());
stdoutLineBuffer = '';
}
if (
runSawWorkspaceTrustPrompt &&
code !== 0 &&
!hasRetriedWithTrust &&
!args.includes('--trust')
) {
hasRetriedWithTrust = true;
runCursorProcess([...args, '--trust'], 'trust-retry');
return;
}
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
});
if (code === 0) {
settleOnce(() => resolve());
} else {
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
}
});
// Handle process errors
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send({
type: 'cursor-error',
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
settleOnce(() => reject(error));
});
// Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end();
};
runCursorProcess(baseArgs, 'initial');
});
}
function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
console.log(`Aborting Cursor session: ${sessionId}`);
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;

View File

@@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
// Create database connection
const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called.
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
console.log('');
@@ -120,6 +129,12 @@ const runMigrations = () => {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create app_config table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
@@ -554,6 +569,33 @@ function applyCustomSessionNames(sessions, provider) {
}
}
// App config database operations
const appConfigDb = {
get: (key) => {
try {
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
return row?.value || null;
} catch (err) {
return null;
}
},
set: (key, value) => {
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
},
getOrCreateJwtSecret: () => {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
}
return secret;
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -583,5 +625,6 @@ export {
pushSubscriptionsDb,
sessionNamesDb,
applyCustomSessionNames,
appConfigDb,
githubTokensDb // Backward compatibility
};

View File

@@ -90,3 +90,10 @@ CREATE TABLE IF NOT EXISTS session_names (
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
-- App configuration table (auto-generated secrets, settings, etc.)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -64,6 +64,8 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -325,7 +327,7 @@ const wss = new WebSocketServer({
// Make WebSocket server available to routes
app.locals.wss = wss;
app.use(cors());
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(express.json({
limit: '50mb',
type: (req) => {
@@ -390,6 +392,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
// Gemini API Routes (protected)
app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -1696,50 +1701,49 @@ function handleShellConnection(ws) {
}));
try {
// Prepare the shell command adapted to the platform and provider
// Validate projectPath — resolve to absolute and verify it exists
const resolvedProjectPath = path.resolve(projectPath);
try {
const stats = fs.statSync(resolvedProjectPath);
if (!stats.isDirectory()) {
throw new Error('Not a directory');
}
} catch (pathErr) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
return;
}
// Validate sessionId — only allow safe characters
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
return;
}
// Build shell command — use cwd for project path (never interpolate into shell string)
let shellCommand;
if (isPlainShell) {
// Plain shell mode - just run the initial command in the project directory
if (os.platform() === 'win32') {
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
} else {
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
}
// Plain shell mode - run the initial command in the project directory
shellCommand = initialCommand;
} else if (provider === 'cursor') {
// Use cursor-agent command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
}
if (hasSession && sessionId) {
shellCommand = `cursor-agent --resume="${sessionId}"`;
} else {
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `cd "${projectPath}" && cursor-agent`;
}
shellCommand = 'cursor-agent';
}
} else if (provider === 'codex') {
// Use codex command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
// PowerShell syntax for fallback
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
shellCommand = `codex resume "${sessionId}" || codex`;
}
} else {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
} else {
shellCommand = `cd "${projectPath}" && codex`;
}
shellCommand = 'codex';
}
} else if (provider === 'gemini') {
// Use gemini command
const command = initialCommand || 'gemini';
let resumeId = sessionId;
if (hasSession && sessionId) {
@@ -1750,41 +1754,32 @@ function handleShellConnection(ws) {
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId;
// Validate the looked-up CLI session ID too
if (!safeSessionIdPattern.test(resumeId)) {
resumeId = null;
}
}
} catch (err) {
console.error('Failed to get Gemini CLI session ID:', err);
}
}
if (os.platform() === 'win32') {
if (hasSession && resumeId) {
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
if (hasSession && resumeId) {
shellCommand = `${command} --resume "${resumeId}"`;
} else {
if (hasSession && resumeId) {
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
shellCommand = command;
}
} else {
// Use claude command (default) or initialCommand if provided
// Claude (default provider)
const command = initialCommand || 'claude';
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
shellCommand = `claude --resume "${sessionId}" || claude`;
}
} else {
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
shellCommand = command;
}
}
@@ -1803,7 +1798,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
cwd: os.homedir(),
cwd: resolvedProjectPath,
env: {
...process.env,
TERM: 'xterm-256color',
@@ -2537,7 +2532,20 @@ async function startServer() {
// Start watching the projects folder for changes
await setupProjectsWatcher();
// Start server-side plugin processes for enabled plugins
startEnabledPluginServers().catch(err => {
console.error('[Plugins] Error during startup:', err.message);
});
});
// Clean up plugin processes on shutdown
const shutdownPlugins = async () => {
await stopAllPlugins();
process.exit(0);
};
process.on('SIGTERM', () => void shutdownPlugins());
process.on('SIGINT', () => void shutdownPlugins());
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);

View File

@@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken';
import { userDb } from '../database/db.js';
import { userDb, appConfigDb } from '../database/db.js';
import { IS_PLATFORM } from '../constants/config.js';
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
// Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
// Optional API key middleware
const validateApiKey = (req, res, next) => {
@@ -58,6 +58,16 @@ const authenticateToken = async (req, res, next) => {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
// Auto-refresh: if token is past halfway through its lifetime, issue a new one
if (decoded.exp && decoded.iat) {
const now = Math.floor(Date.now() / 1000);
const halfLife = (decoded.exp - decoded.iat) / 2;
if (now > decoded.iat + halfLife) {
const newToken = generateToken(user);
res.setHeader('X-Refreshed-Token', newToken);
}
}
req.user = user;
next();
} catch (error) {
@@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
}
};
// Generate JWT token (never expires)
// Generate JWT token
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
{
userId: user.id,
username: user.username
},
JWT_SECRET
// No expiration - token lasts forever
JWT_SECRET,
{ expiresIn: '7d' }
);
};
@@ -101,10 +111,12 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
return {
...decoded,
id: decoded.userId
};
// Verify user actually exists in database (matches REST authenticateToken behavior)
const user = userDb.getUserById(decoded.userId);
if (!user) {
return null;
}
return { userId: user.id, username: user.username };
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;

View File

@@ -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;

View File

@@ -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
View 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;

View File

@@ -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) {

View File

@@ -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);

View File

@@ -1,9 +1,9 @@
import matter from 'gray-matter';
import { promises as fs } from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { parse as parseShellCommand } from 'shell-quote';
import { parseFrontmatter } from './frontmatter.js';
const execFileAsync = promisify(execFile);
@@ -32,7 +32,7 @@ const BASH_COMMAND_ALLOWLIST = [
*/
export function parseCommand(content) {
try {
const parsed = matter(content);
const parsed = parseFrontmatter(content);
return {
data: parsed.data || {},
content: parsed.content || '',

View File

@@ -0,0 +1,18 @@
import matter from 'gray-matter';
const disabledFrontmatterEngine = () => ({});
const frontmatterOptions = {
language: 'yaml',
// Disable JS/JSON frontmatter parsing to avoid executable project content.
// Mirrors Gatsby's mitigation for gray-matter.
engines: {
js: disabledFrontmatterEngine,
javascript: disabledFrontmatterEngine,
json: disabledFrontmatterEngine
}
};
export function parseFrontmatter(content) {
return matter(content, frontmatterOptions);
}

View File

@@ -1,7 +1,17 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { spawn } from 'child_process';
const execAsync = promisify(exec);
function spawnAsync(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { shell: false });
let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('error', (error) => { reject(error); });
child.on('close', (code) => {
if (code === 0) { resolve({ stdout }); return; }
reject(new Error(`Command failed with code ${code}`));
});
});
}
/**
* Read git configuration from system's global git config
@@ -10,8 +20,8 @@ const execAsync = promisify(exec);
export async function getSystemGitConfig() {
try {
const [nameResult, emailResult] = await Promise.all([
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),
spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))
]);
return {

View File

@@ -0,0 +1,408 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { spawn } from 'child_process';
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
/** Strip embedded credentials from a repo URL before exposing it to the client. */
function sanitizeRepoUrl(raw) {
try {
const u = new URL(raw);
u.username = '';
u.password = '';
return u.toString().replace(/\/$/, '');
} catch {
// Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
return raw.replace(/\/\/[^@/]+@/, '//');
}
}
const ALLOWED_TYPES = ['react', 'module'];
const ALLOWED_SLOTS = ['tab'];
export function getPluginsDir() {
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
return PLUGINS_DIR;
}
export function getPluginsConfig() {
try {
if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
}
} catch {
// Corrupted config, start fresh
}
return {};
}
export function savePluginsConfig(config) {
const dir = path.dirname(PLUGINS_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
}
export function validateManifest(manifest) {
if (!manifest || typeof manifest !== 'object') {
return { valid: false, error: 'Manifest must be a JSON object' };
}
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (!manifest[field] || typeof manifest[field] !== 'string') {
return { valid: false, error: `Missing or invalid required field: ${field}` };
}
}
// Sanitize name — only allow alphanumeric, hyphens, underscores
if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
}
if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
}
if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
}
// Validate entry is a relative path without traversal
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
return { valid: false, error: 'Entry must be a relative path without ".."' };
}
if (manifest.server !== undefined && manifest.server !== null) {
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
}
}
if (manifest.permissions !== undefined) {
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
return { valid: false, error: 'Permissions must be an array of strings' };
}
}
return { valid: true };
}
export function scanPlugins() {
const pluginsDir = getPluginsDir();
const config = getPluginsConfig();
const plugins = [];
let entries;
try {
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
} catch {
return plugins;
}
const seenNames = new Set();
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip transient temp directories from in-progress installs
if (entry.name.startsWith('.tmp-')) continue;
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
if (!fs.existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const validation = validateManifest(manifest);
if (!validation.valid) {
console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
continue;
}
// Skip duplicate manifest names
if (seenNames.has(manifest.name)) {
console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
continue;
}
seenNames.add(manifest.name);
// Try to read git remote URL
let repoUrl = null;
try {
const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
if (fs.existsSync(gitConfigPath)) {
const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
const match = gitConfig.match(/url\s*=\s*(.+)/);
if (match) {
repoUrl = match[1].trim().replace(/\.git$/, '');
// Convert SSH URLs to HTTPS
if (repoUrl.startsWith('git@')) {
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
}
// Strip embedded credentials (e.g. https://user:pass@host/...)
repoUrl = sanitizeRepoUrl(repoUrl);
}
}
} catch { /* ignore */ }
plugins.push({
name: manifest.name,
displayName: manifest.displayName,
version: manifest.version || '0.0.0',
description: manifest.description || '',
author: manifest.author || '',
icon: manifest.icon || 'Puzzle',
type: manifest.type || 'module',
slot: manifest.slot || 'tab',
entry: manifest.entry,
server: manifest.server || null,
permissions: manifest.permissions || [],
enabled: config[manifest.name]?.enabled !== false, // enabled by default
dirName: entry.name,
repoUrl,
});
} catch (err) {
console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
}
}
return plugins;
}
export function getPluginDir(name) {
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === name);
if (!plugin) return null;
return path.join(getPluginsDir(), plugin.dirName);
}
export function resolvePluginAssetPath(name, assetPath) {
const pluginDir = getPluginDir(name);
if (!pluginDir) return null;
const resolved = path.resolve(pluginDir, assetPath);
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
if (!fs.existsSync(resolved)) return null;
const realResolved = fs.realpathSync(resolved);
const realPluginDir = fs.realpathSync(pluginDir);
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
return null;
}
return realResolved;
}
export function installPluginFromGit(url) {
return new Promise((resolve, reject) => {
if (typeof url !== 'string' || !url.trim()) {
return reject(new Error('Invalid URL: must be a non-empty string'));
}
if (url.startsWith('-')) {
return reject(new Error('Invalid URL: must not start with "-"'));
}
// Extract repo name from URL for directory name
const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
const repoName = urlClean.split('/').pop();
if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
return reject(new Error('Could not determine a valid directory name from the URL'));
}
const pluginsDir = getPluginsDir();
const targetDir = path.resolve(pluginsDir, repoName);
// Ensure the resolved target directory stays within the plugins directory
if (!targetDir.startsWith(pluginsDir + path.sep)) {
return reject(new Error('Invalid plugin directory path'));
}
if (fs.existsSync(targetDir)) {
return reject(new Error(`Plugin directory "${repoName}" already exists`));
}
// Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
const cleanupTemp = () => {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
};
const finalize = (manifest) => {
try {
fs.renameSync(tempDir, targetDir);
} catch (err) {
cleanupTemp();
return reject(new Error(`Failed to move plugin into place: ${err.message}`));
}
resolve(manifest);
};
const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
gitProcess.on('close', (code) => {
if (code !== 0) {
cleanupTemp();
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
}
// Validate manifest exists
const manifestPath = path.join(tempDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
cleanupTemp();
return reject(new Error('Cloned repository does not contain a manifest.json'));
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
cleanupTemp();
return reject(new Error('manifest.json is not valid JSON'));
}
const validation = validateManifest(manifest);
if (!validation.valid) {
cleanupTemp();
return reject(new Error(`Invalid manifest: ${validation.error}`));
}
// Reject if another installed plugin already uses this name
const existing = scanPlugins().find(p => p.name === manifest.name);
if (existing) {
cleanupTemp();
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
}
// Run npm install if package.json exists.
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
const packageJsonPath = path.join(tempDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
cwd: tempDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
cleanupTemp();
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
}
finalize(manifest);
});
npmProcess.on('error', (err) => {
cleanupTemp();
reject(err);
});
} else {
finalize(manifest);
}
});
gitProcess.on('error', (err) => {
cleanupTemp();
reject(new Error(`Failed to spawn git: ${err.message}`));
});
});
}
export function updatePluginFromGit(name) {
return new Promise((resolve, reject) => {
const pluginDir = getPluginDir(name);
if (!pluginDir) {
return reject(new Error(`Plugin "${name}" not found`));
}
// Only fast-forward to avoid silent divergence
const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
cwd: pluginDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
gitProcess.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
}
// Re-validate manifest after update
const manifestPath = path.join(pluginDir, 'manifest.json');
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return reject(new Error('manifest.json is not valid JSON after update'));
}
const validation = validateManifest(manifest);
if (!validation.valid) {
return reject(new Error(`Invalid manifest after update: ${validation.error}`));
}
// Re-run npm install if package.json exists
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
cwd: pluginDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
}
resolve(manifest);
});
npmProcess.on('error', (err) => reject(err));
} else {
resolve(manifest);
}
});
gitProcess.on('error', (err) => {
reject(new Error(`Failed to spawn git: ${err.message}`));
});
});
}
export async function uninstallPlugin(name) {
const pluginDir = getPluginDir(name);
if (!pluginDir) {
throw new Error(`Plugin "${name}" not found`);
}
// On Windows, file handles may be released slightly after process exit.
// Retry a few times with a short delay before giving up.
const MAX_RETRIES = 5;
const RETRY_DELAY_MS = 500;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
fs.rmSync(pluginDir, { recursive: true, force: true });
break;
} catch (err) {
if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
} else {
throw err;
}
}
}
// Remove from config
const config = getPluginsConfig();
delete config[name];
savePluginsConfig(config);
}

View File

@@ -0,0 +1,184 @@
import { spawn } from 'child_process';
import path from 'path';
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
// Map<pluginName, { process, port }>
const runningPlugins = new Map();
// Map<pluginName, Promise<port>> — in-flight start operations
const startingPlugins = new Map();
/**
* Start a plugin's server subprocess.
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
* to stdout within 10 seconds.
*/
export function startPluginServer(name, pluginDir, serverEntry) {
if (runningPlugins.has(name)) {
return Promise.resolve(runningPlugins.get(name).port);
}
// Coalesce concurrent starts for the same plugin
if (startingPlugins.has(name)) {
return startingPlugins.get(name);
}
const startPromise = new Promise((resolve, reject) => {
const serverPath = path.join(pluginDir, serverEntry);
// Restricted env — only essentials, no host secrets
const pluginProcess = spawn('node', [serverPath], {
cwd: pluginDir,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let resolved = false;
let stdout = '';
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
pluginProcess.kill();
reject(new Error('Plugin server did not report ready within 10 seconds'));
}
}, 10000);
pluginProcess.stdout.on('data', (data) => {
if (resolved) return;
stdout += data.toString();
// Look for the JSON ready line
const lines = stdout.split('\n');
for (const line of lines) {
try {
const msg = JSON.parse(line.trim());
if (msg.ready && typeof msg.port === 'number') {
clearTimeout(timeout);
resolved = true;
runningPlugins.set(name, { process: pluginProcess, port: msg.port });
pluginProcess.on('exit', () => {
runningPlugins.delete(name);
});
console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
resolve(msg.port);
}
} catch {
// Not JSON yet, keep buffering
}
}
});
pluginProcess.stderr.on('data', (data) => {
console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
});
pluginProcess.on('error', (err) => {
clearTimeout(timeout);
if (!resolved) {
resolved = true;
reject(new Error(`Failed to start plugin server: ${err.message}`));
}
});
pluginProcess.on('exit', (code) => {
clearTimeout(timeout);
runningPlugins.delete(name);
if (!resolved) {
resolved = true;
reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
}
});
}).finally(() => {
startingPlugins.delete(name);
});
startingPlugins.set(name, startPromise);
return startPromise;
}
/**
* Stop a plugin's server subprocess.
* Returns a Promise that resolves when the process has fully exited.
*/
export function stopPluginServer(name) {
const entry = runningPlugins.get(name);
if (!entry) return Promise.resolve();
return new Promise((resolve) => {
const cleanup = () => {
clearTimeout(forceKillTimer);
runningPlugins.delete(name);
resolve();
};
entry.process.once('exit', cleanup);
entry.process.kill('SIGTERM');
// Force kill after 5 seconds if still running
const forceKillTimer = setTimeout(() => {
if (runningPlugins.has(name)) {
entry.process.kill('SIGKILL');
cleanup();
}
}, 5000);
console.log(`[Plugins] Server stopped for "${name}"`);
});
}
/**
* Get the port a running plugin server is listening on.
*/
export function getPluginPort(name) {
return runningPlugins.get(name)?.port ?? null;
}
/**
* Check if a plugin's server is running.
*/
export function isPluginRunning(name) {
return runningPlugins.has(name);
}
/**
* Stop all running plugin servers (called on host shutdown).
*/
export function stopAllPlugins() {
const stops = [];
for (const [name] of runningPlugins) {
stops.push(stopPluginServer(name));
}
return Promise.all(stops);
}
/**
* Start servers for all enabled plugins that have a server entry.
* Called once on host server boot.
*/
export async function startEnabledPluginServers() {
const plugins = scanPlugins();
const config = getPluginsConfig();
for (const plugin of plugins) {
if (!plugin.server) continue;
if (config[plugin.name]?.enabled === false) continue;
const pluginDir = getPluginDir(plugin.name);
if (!pluginDir) continue;
try {
await startPluginServer(plugin.name, pluginDir, plugin.server);
} catch (err) {
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
}
}
}