| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697 |
- 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'), '***');
- }
- // Default root used by the folder browser when no path is provided.
- export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
- // System-critical paths that should never be used as workspace directories
- export const FORBIDDEN_PATHS = [
- // Unix
- '/',
- '/etc',
- '/bin',
- '/sbin',
- '/usr',
- '/dev',
- '/proc',
- '/sys',
- '/var',
- '/boot',
- '/root',
- '/lib',
- '/lib64',
- '/opt',
- '/run',
- // Windows
- '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)) {
- // Exception: allow user-accessible temporary folders under /var.
- 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 {
- // Resolve to absolute path
- let absolutePath = path.resolve(requestedPath);
- // Reject system-critical directories and descendants.
- if (isForbiddenWorkspacePath(absolutePath)) {
- return {
- valid: false,
- error: 'Cannot create workspace in system-critical directories'
- };
- }
- // Try to resolve the real path (following symlinks)
- let realPath;
- try {
- // Check if path exists to resolve real path
- await fs.access(absolutePath);
- realPath = await fs.realpath(absolutePath);
- } catch (error) {
- if (error.code === 'ENOENT') {
- // Path doesn't exist yet - check parent directory
- let parentPath = path.dirname(absolutePath);
- try {
- const parentRealPath = await fs.realpath(parentPath);
- // Reconstruct the full path with real parent
- realPath = path.join(parentRealPath, path.basename(absolutePath));
- } catch (parentError) {
- if (parentError.code === 'ENOENT') {
- // Parent doesn't exist either - use the absolute path as-is
- // We'll validate it's within allowed root
- realPath = absolutePath;
- } else {
- throw parentError;
- }
- }
- } else {
- throw error;
- }
- }
- // Apply the same checks after symlink/canonical path resolution.
- if (isForbiddenWorkspacePath(realPath)) {
- return {
- valid: false,
- error: 'Resolved path points to a system-critical directory'
- };
- }
- // Additional symlink check for existing paths
- try {
- await fs.access(absolutePath);
- const stats = await fs.lstat(absolutePath);
- if (stats.isSymbolicLink()) {
- // Verify symlink target is not a forbidden system path.
- 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;
- }
- // Path doesn't exist - that's fine for new workspace creation
- }
- 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;
- // Validate required fields
- 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"' });
- }
- // Validate path safety before any operations
- const validation = await validateWorkspacePath(workspacePath);
- if (!validation.valid) {
- return res.status(400).json({
- error: 'Invalid workspace path',
- details: validation.error
- });
- }
- const absolutePath = validation.resolvedPath;
- // Handle existing workspace
- if (workspaceType === 'existing') {
- // Check if the path exists
- 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;
- }
- // Add the existing workspace to the project list
- const project = await addProjectManually(absolutePath);
- return res.json({
- success: true,
- project,
- message: 'Existing workspace added successfully'
- });
- }
- // Handle new workspace creation
- if (workspaceType === 'new') {
- // Create the directory if it doesn't exist
- await fs.mkdir(absolutePath, { recursive: true });
- // If GitHub URL is provided, clone the repository
- if (githubUrl) {
- let githubToken = null;
- // Get GitHub token if needed
- if (githubTokenId) {
- // Fetch token from database
- const token = await getGithubTokenById(githubTokenId, req.user.id);
- if (!token) {
- // Clean up created directory
- 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;
- }
- // Extract repo name from URL for the clone destination
- const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
- const repoName = normalizedUrl.split('/').pop() || 'repository';
- const clonePath = path.join(absolutePath, repoName);
- // Check if clone destination already exists to prevent data loss
- 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) {
- // Directory doesn't exist, which is what we want
- }
- // Clone the repository into a subfolder
- try {
- await cloneGitHubRepository(githubUrl, clonePath, githubToken);
- } catch (error) {
- // Only clean up if clone created partial data (check if dir exists and is empty or partial)
- try {
- const stats = await fs.stat(clonePath);
- if (stats.isDirectory()) {
- await fs.rm(clonePath, { recursive: true, force: true });
- }
- } catch (cleanupError) {
- // Directory doesn't exist or cleanup failed - ignore
- }
- throw new Error(`Failed to clone repository: ${error.message}`);
- }
- // Add the cloned repo path to the project list
- const project = await addProjectManually(clonePath);
- return res.json({
- success: true,
- project,
- message: 'New workspace created and repository cloned successfully'
- });
- }
- // Add the new workspace to the project list (no clone)
- 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');
- // Return in the expected format (github_token field for compatibility)
- 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);
- // Check if clone destination already exists to prevent data loss
- 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) {
- // Directory doesn't exist, which is what we want
- }
- let cloneUrl = githubUrl;
- if (githubToken) {
- try {
- const url = new URL(githubUrl);
- url.username = githubToken;
- url.password = '';
- cloneUrl = url.toString();
- } catch (error) {
- // SSH URL or invalid - use as-is
- }
- }
- 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) {
- // SSH URL - use as-is
- }
- }
- 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;
|