import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import { spawn } from 'child_process';
import os from 'os';
import {
addProjectManually,
extractProjectDirectory,
} from '../projects.js';
import {
getProjectDiscoveryContext,
getProjectDiscoveryPlansOverview,
getProjectDiscoveryPlanReport,
rerunDiscoveryPlan,
getProjectWorkCycles,
applyWorkCycle,
archiveWorkCycle,
} from '../discovery-plans.js';
const router = express.Router();
function sanitizeGitError(message, token) {
if (!message || !token) return message;
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
}
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
export const FORBIDDEN_PATHS = [
'/',
'/etc',
'/bin',
'/sbin',
'/usr',
'/dev',
'/proc',
'/sys',
'/var',
'/boot',
'/root',
'/lib',
'/lib64',
'/opt',
'/run',
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
'C:\\System Volume Information',
'C:\\$Recycle.Bin'
];
function isForbiddenWorkspacePath(inputPath) {
const normalizedPath = path.normalize(path.resolve(inputPath));
if (normalizedPath === '/' || FORBIDDEN_PATHS.includes(normalizedPath)) {
return true;
}
for (const forbidden of FORBIDDEN_PATHS) {
if (normalizedPath === forbidden || normalizedPath.startsWith(forbidden + path.sep)) {
if (
forbidden === '/var' &&
(normalizedPath.startsWith('/var/tmp') || normalizedPath.startsWith('/var/folders'))
) {
continue;
}
return true;
}
}
return false;
}
* Validates that a path is safe for workspace operations
* @param {string} requestedPath - The path to validate
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
*/
export async function validateWorkspacePath(requestedPath) {
try {
let absolutePath = path.resolve(requestedPath);
if (isForbiddenWorkspacePath(absolutePath)) {
return {
valid: false,
error: 'Cannot create workspace in system-critical directories'
};
}
let realPath;
try {
await fs.access(absolutePath);
realPath = await fs.realpath(absolutePath);
} catch (error) {
if (error.code === 'ENOENT') {
let parentPath = path.dirname(absolutePath);
try {
const parentRealPath = await fs.realpath(parentPath);
realPath = path.join(parentRealPath, path.basename(absolutePath));
} catch (parentError) {
if (parentError.code === 'ENOENT') {
realPath = absolutePath;
} else {
throw parentError;
}
}
} else {
throw error;
}
}
if (isForbiddenWorkspacePath(realPath)) {
return {
valid: false,
error: 'Resolved path points to a system-critical directory'
};
}
try {
await fs.access(absolutePath);
const stats = await fs.lstat(absolutePath);
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(absolutePath);
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
const realTarget = await fs.realpath(resolvedTarget);
if (isForbiddenWorkspacePath(realTarget)) {
return {
valid: false,
error: 'Symlink target points to a system-critical directory'
};
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
return {
valid: true,
resolvedPath: realPath
};
} catch (error) {
return {
valid: false,
error: `Path validation failed: ${error.message}`
};
}
}
function getTrimmedParam(value) {
return typeof value === 'string' ? value.trim() : '';
}
function getDiscoveryPlanErrorMessage(error, fallback) {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function getDiscoveryPlanErrorStatus(error) {
if (error?.code === 'NOT_FOUND') {
return 404;
}
if (error?.code === 'UNSUPPORTED_STRATEGY') {
return 400;
}
if (error?.code === 'INVALID_STATE' || error?.code === 'MISSING_PLAN_BODY' || error?.code === 'MISSING_WORKSPACE') {
return 409;
}
if (error?.code === 'ALREADY_RUNNING') {
return 409;
}
return 500;
}
export async function handleGetProjectDiscoveryPlans(req, res) {
try {
const projectName = getTrimmedParam(req.params?.projectName);
if (!projectName) {
return res.status(400).json({ error: 'projectName is required' });
}
const overview = await getProjectDiscoveryPlansOverview(projectName);
return res.json(overview);
} catch (error) {
return res.status(500).json({ error: error.message });
}
}
export async function handleGetProjectDiscoveryContext(req, res) {
try {
const projectName = getTrimmedParam(req.params?.projectName);
if (!projectName) {
return res.status(400).json({ error: 'projectName is required' });
}
const context = await getProjectDiscoveryContext(projectName);
return res.json(context);
} catch (error) {
return res.status(500).json({ error: error.message });
}
}
export async function handleExecuteProjectDiscoveryPlan(req, res) {
try {
const projectName = getTrimmedParam(req.params?.projectName);
const planId = getTrimmedParam(req.params?.planId);
if (!projectName) {
return res.status(400).json({ error: 'projectName is required' });
}
if (!planId) {
return res.status(400).json({ error: 'planId is required' });
}
const result = await rerunDiscoveryPlan(projectName, planId);
return res.json(result);
} catch (error) {
return res.status(getDiscoveryPlanErrorStatus(error)).json({
error: getDiscoveryPlanErrorMessage(error, 'Failed to rerun discovery plan')
});
}
}
router.get('/:projectName/discovery-context', handleGetProjectDiscoveryContext);
router.get('/:projectName/discovery-plans', handleGetProjectDiscoveryPlans);
router.post('/:projectName/discovery-plans/:planId/execute', handleExecuteProjectDiscoveryPlan);
router.get('/:projectName/discovery-plans/:planId/report', async (req, res) => {
try {
const projectName = getTrimmedParam(req.params?.projectName);
const planId = getTrimmedParam(req.params?.planId);
if (!projectName) return res.status(400).json({ error: 'projectName is required' });
if (!planId) return res.status(400).json({ error: 'planId is required' });
const result = await getProjectDiscoveryPlanReport(projectName, planId);
return res.json(result);
} catch (error) {
return res.status(getDiscoveryPlanErrorStatus(error)).json({
error: getDiscoveryPlanErrorMessage(error, 'Failed to read discovery plan report')
});
}
});
router.get('/:projectName/work-cycles', async (req, res) => {
try {
const projectName = getTrimmedParam(req.params?.projectName);
if (!projectName) return res.status(400).json({ error: 'projectName is required' });
const result = await getProjectWorkCycles(projectName);
return res.json(result);
} catch (error) {
return res.status(getDiscoveryPlanErrorStatus(error)).json({
error: getDiscoveryPlanErrorMessage(error, 'Failed to get work cycles')
});
}
});
router.post('/:projectName/work-cycles/:cycleId/apply', async (req, res) => {
try {
const projectName = getTrimmedParam(req.params?.projectName);
const cycleId = getTrimmedParam(req.params?.cycleId);
if (!projectName) return res.status(400).json({ error: 'projectName is required' });
if (!cycleId) return res.status(400).json({ error: 'cycleId is required' });
const result = await applyWorkCycle(projectName, cycleId);
return res.json(result);
} catch (error) {
return res.status(getDiscoveryPlanErrorStatus(error)).json({
error: getDiscoveryPlanErrorMessage(error, 'Failed to apply work cycle')
});
}
});
router.post('/:projectName/work-cycles/:cycleId/archive', async (req, res) => {
try {
const projectName = getTrimmedParam(req.params?.projectName);
const cycleId = getTrimmedParam(req.params?.cycleId);
if (!projectName) return res.status(400).json({ error: 'projectName is required' });
if (!cycleId) return res.status(400).json({ error: 'cycleId is required' });
const result = await archiveWorkCycle(projectName, cycleId);
return res.json(result);
} catch (error) {
return res.status(getDiscoveryPlanErrorStatus(error)).json({
error: getDiscoveryPlanErrorMessage(error, 'Failed to archive work cycle')
});
}
});
* Create a new workspace
* POST /api/projects/create-workspace
*
* Body:
* - workspaceType: 'existing' | 'new'
* - path: string (workspace path)
* - githubUrl?: string (optional, for new workspaces)
* - githubTokenId?: number (optional, ID of stored token)
* - newGithubToken?: string (optional, one-time token)
*/
router.post('/create-workspace', async (req, res) => {
try {
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
if (!workspaceType || !workspacePath) {
return res.status(400).json({ error: 'workspaceType and path are required' });
}
if (!['existing', 'new'].includes(workspaceType)) {
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
}
const validation = await validateWorkspacePath(workspacePath);
if (!validation.valid) {
return res.status(400).json({
error: 'Invalid workspace path',
details: validation.error
});
}
const absolutePath = validation.resolvedPath;
if (workspaceType === 'existing') {
try {
await fs.access(absolutePath);
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path exists but is not a directory' });
}
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Workspace path does not exist' });
}
throw error;
}
const project = await addProjectManually(absolutePath);
return res.json({
success: true,
project,
message: 'Existing workspace added successfully'
});
}
if (workspaceType === 'new') {
await fs.mkdir(absolutePath, { recursive: true });
if (githubUrl) {
let githubToken = null;
if (githubTokenId) {
const token = await getGithubTokenById(githubTokenId, req.user.id);
if (!token) {
await fs.rm(absolutePath, { recursive: true, force: true });
return res.status(404).json({ error: 'GitHub token not found' });
}
githubToken = token.github_token;
} else if (newGithubToken) {
githubToken = newGithubToken;
}
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
try {
await fs.access(clonePath);
return res.status(409).json({
error: 'Directory already exists',
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
});
} catch (err) {
}
try {
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
} catch (error) {
try {
const stats = await fs.stat(clonePath);
if (stats.isDirectory()) {
await fs.rm(clonePath, { recursive: true, force: true });
}
} catch (cleanupError) {
}
throw new Error(`Failed to clone repository: ${error.message}`);
}
const project = await addProjectManually(clonePath);
return res.json({
success: true,
project,
message: 'New workspace created and repository cloned successfully'
});
}
const project = await addProjectManually(absolutePath);
return res.json({
success: true,
project,
message: 'New workspace created successfully'
});
}
} catch (error) {
console.error('Error creating workspace:', error);
res.status(500).json({
error: error.message || 'Failed to create workspace',
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
});
* Helper function to get GitHub token from database
*/
async function getGithubTokenById(tokenId, userId) {
const { db } = await import('../database/db.js');
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');
if (credential) {
return {
...credential,
github_token: credential.credential_value
};
}
return null;
}
* Clone repository with progress streaming (SSE)
* GET /api/projects/clone-progress
*/
router.get('/clone-progress', async (req, res) => {
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendEvent = (type, data) => {
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
try {
if (!workspacePath || !githubUrl) {
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
res.end();
return;
}
const validation = await validateWorkspacePath(workspacePath);
if (!validation.valid) {
sendEvent('error', { message: validation.error });
res.end();
return;
}
const absolutePath = validation.resolvedPath;
await fs.mkdir(absolutePath, { recursive: true });
let githubToken = null;
if (githubTokenId) {
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
if (!token) {
await fs.rm(absolutePath, { recursive: true, force: true });
sendEvent('error', { message: 'GitHub token not found' });
res.end();
return;
}
githubToken = token.github_token;
} else if (newGithubToken) {
githubToken = newGithubToken;
}
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
try {
await fs.access(clonePath);
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
res.end();
return;
} catch (err) {
}
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
}
}
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0'
}
});
let lastError = '';
gitProcess.stdout.on('data', (data) => {
const message = data.toString().trim();
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.stderr.on('data', (data) => {
const message = data.toString().trim();
lastError = message;
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.on('close', async (code) => {
if (code === 0) {
try {
const project = await addProjectManually(clonePath);
sendEvent('complete', { project, message: 'Repository cloned successfully' });
} catch (error) {
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
}
} else {
const sanitizedError = sanitizeGitError(lastError, githubToken);
let errorMessage = 'Git clone failed';
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
errorMessage = 'Authentication failed. Please check your credentials.';
} else if (lastError.includes('Repository not found')) {
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
} else if (lastError.includes('already exists')) {
errorMessage = 'Directory already exists';
} else if (sanitizedError) {
errorMessage = sanitizedError;
}
try {
await fs.rm(clonePath, { recursive: true, force: true });
} catch (cleanupError) {
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
}
sendEvent('error', { message: errorMessage });
}
res.end();
});
gitProcess.on('error', (error) => {
if (error.code === 'ENOENT') {
sendEvent('error', { message: 'Git is not installed or not in PATH' });
} else {
sendEvent('error', { message: error.message });
}
res.end();
});
req.on('close', () => {
gitProcess.kill();
});
} catch (error) {
sendEvent('error', { message: error.message });
res.end();
}
});
* Helper function to clone a GitHub repository
*/
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
return new Promise((resolve, reject) => {
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
}
}
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0'
}
});
let stdout = '';
let stderr = '';
gitProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
gitProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
gitProcess.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
let errorMessage = 'Git clone failed';
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
errorMessage = 'Authentication failed. Please check your GitHub token.';
} else if (stderr.includes('Repository not found')) {
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
} else if (stderr.includes('already exists')) {
errorMessage = 'Directory already exists';
} else if (stderr) {
errorMessage = stderr;
}
reject(new Error(errorMessage));
}
});
gitProcess.on('error', (error) => {
if (error.code === 'ENOENT') {
reject(new Error('Git is not installed or not in PATH'));
} else {
reject(error);
}
});
});
}
export default router;