diff --git a/server/index.js b/server/index.js index 5095655..81cfab4 100755 --- a/server/index.js +++ b/server/index.js @@ -71,4 +71,1433 @@ import { initializeDatabase, projectsDb } from './modules/database/index.js'; import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; -import { c } from './utils/colors.js'; \ No newline at end of file +import { c } from './utils/colors.js'; + +const __dirname = getModuleDir(import.meta.url); +// The server source runs from /server, while the compiled output runs from /dist-server/server. +// Resolving the app root once keeps every repo-level lookup below aligned across both layouts. +const APP_ROOT = findAppRoot(__dirname); +const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; + +console.log('SERVER_PORT from env:', process.env.SERVER_PORT); + +const app = express(); +const server = http.createServer(app); + +// Single WebSocket server that handles chat, shell, and plugin proxy paths. +const wss = createWebSocketServer(server, { + verifyClient: { + isPlatform: IS_PLATFORM, + authenticateWebSocket, + }, + chat: { + queryClaudeSDK, + spawnCursor, + queryCodex, + spawnGemini, + abortClaudeSDKSession, + abortCursorSession, + abortCodexSession, + abortGeminiSession, + resolveToolApproval, + isClaudeSDKSessionActive, + isCursorSessionActive, + isCodexSessionActive, + isGeminiSessionActive, + reconnectSessionWriter, + getPendingApprovalsForSession, + getActiveClaudeSDKSessions, + getActiveCursorSessions, + getActiveCodexSessions, + getActiveGeminiSessions, + }, + shell: { + getSessionById: (sessionId) => sessionManager.getSession(sessionId), + stripAnsiSequences, + normalizeDetectedUrl, + extractUrlsFromText, + shouldAutoOpenUrlFromOutput, + }, + getPluginPort, +}); + +// Make WebSocket server available to routes +app.locals.wss = wss; + +app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] })); +app.use(express.json({ + limit: '50mb', + type: (req) => { + // Skip multipart/form-data requests (for file uploads like images) + const contentType = req.headers['content-type'] || ''; + if (contentType.includes('multipart/form-data')) { + return false; + } + return contentType.includes('json'); + } +})); +app.use(express.urlencoded({ limit: '50mb', extended: true })); + +// Public health check endpoint (no authentication required) +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + installMode + }); +}); + +// Optional API key validation (if configured) +app.use('/api', validateApiKey); + +// Authentication routes (public) +app.use('/api/auth', authRoutes); + +// Projects API Routes (protected) +app.use('/api/projects', authenticateToken, projectModuleRoutes); + +// Git API Routes (protected) +app.use('/api/git', authenticateToken, gitRoutes); + +// Cursor API Routes (protected) +app.use('/api/cursor', authenticateToken, cursorRoutes); + +// TaskMaster API Routes (protected) +app.use('/api/taskmaster', authenticateToken, taskmasterRoutes); + +// MCP utilities +app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes); + +// Commands API Routes (protected) +app.use('/api/commands', authenticateToken, commandsRoutes); + +// Settings API Routes (protected) +app.use('/api/settings', authenticateToken, settingsRoutes); + +// User API Routes (protected) +app.use('/api/user', authenticateToken, userRoutes); + +// Gemini API Routes (protected) +app.use('/api/gemini', authenticateToken, geminiRoutes); + +// Plugins API Routes (protected) +app.use('/api/plugins', authenticateToken, pluginsRoutes); +app.use('/api/models', authenticateToken, modelsRoutes); + +// Unified provider MCP routes (protected) +app.use('/api/providers', authenticateToken, providerRoutes); + +// Agent API Routes (uses API key authentication) +app.use('/api/agent', agentRoutes); + +// Serve public files (like api-docs.html) +app.use(express.static(path.join(APP_ROOT, 'public'))); + +// Static files served after API routes +// Add cache control: HTML files should not be cached, but assets can be cached +app.use(express.static(path.join(APP_ROOT, 'dist'), { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.html')) { + // Prevent HTML caching to avoid service worker issues after builds + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) { + // Cache static assets for 1 year (they have hashed names) + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + } +})); + +// API Routes (protected) +// /api/config endpoint removed - no longer needed +// Frontend now uses window.location for WebSocket URLs + +// System update endpoint +app.post('/api/system/update', authenticateToken, async (req, res) => { + try { + // Get the project root directory (parent of server directory) + const projectRoot = APP_ROOT; + + console.log('Starting system update from directory:', projectRoot); + + // Platform deployments use their own update workflow from the project root. + const updateCommand = IS_PLATFORM + // In platform, husky and dev dependencies are not needed + ? 'npm run update:platform' + : installMode === 'git' + ? 'git checkout main && git pull && npm install' + : 'npm install -g @cloudcli-ai/cloudcli@latest'; + + const updateCwd = IS_PLATFORM || installMode === 'git' + ? projectRoot + : os.homedir(); + + const child = spawn('sh', ['-c', updateCommand], { + cwd: updateCwd, + env: process.env + }); + + let output = ''; + let errorOutput = ''; + + child.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + console.log('Update output:', text); + }); + + child.stderr.on('data', (data) => { + const text = data.toString(); + errorOutput += text; + console.error('Update error:', text); + }); + + child.on('close', (code) => { + if (code === 0) { + res.json({ + success: true, + output: output || 'Update completed successfully', + message: 'Update completed. Please restart the server to apply changes.' + }); + } else { + res.status(500).json({ + success: false, + error: 'Update command failed', + output: output, + errorOutput: errorOutput + }); + } + }); + + child.on('error', (error) => { + console.error('Update process error:', error); + res.status(500).json({ + success: false, + error: error.message + }); + }); + + } catch (error) { + console.error('System update error:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +const expandWorkspacePath = (inputPath) => { + if (!inputPath) return inputPath; + if (inputPath === '~') { + return WORKSPACES_ROOT; + } + if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { + return path.join(WORKSPACES_ROOT, inputPath.slice(2)); + } + return inputPath; +}; + +// Browse filesystem endpoint for project suggestions - uses existing getFileTree +app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { + try { + const { path: dirPath } = req.query; + + console.log('[API] Browse filesystem request for path:', dirPath); + console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT); + // Default to home directory if no path provided + const defaultRoot = WORKSPACES_ROOT; + let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot; + + // Resolve and normalize the path + targetPath = path.resolve(targetPath); + + // Security check - ensure path is within allowed workspace root + const validation = await validateWorkspacePath(targetPath); + if (!validation.valid) { + return res.status(403).json({ error: validation.error }); + } + const resolvedPath = validation.resolvedPath || targetPath; + + // Security check - ensure path is accessible + try { + await fs.promises.access(resolvedPath); + const stats = await fs.promises.stat(resolvedPath); + + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Path is not a directory' }); + } + } catch (err) { + return res.status(404).json({ error: 'Directory not accessible' }); + } + + // Use existing getFileTree function with shallow depth (only direct children) + const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false + + // Filter only directories and format for suggestions + const directories = fileTree + .filter(item => item.type === 'directory') + .map(item => ({ + path: item.path, + name: item.name, + type: 'directory' + })) + .sort((a, b) => { + const aHidden = a.name.startsWith('.'); + const bHidden = b.name.startsWith('.'); + if (aHidden && !bHidden) return 1; + if (!aHidden && bHidden) return -1; + return a.name.localeCompare(b.name); + }); + + // Add common directories if browsing home directory + const suggestions = []; + let resolvedWorkspaceRoot = defaultRoot; + try { + resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot); + } catch (error) { + // Use default root as-is if realpath fails + } + if (resolvedPath === resolvedWorkspaceRoot) { + const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace']; + const existingCommon = directories.filter(dir => commonDirs.includes(dir.name)); + const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name)); + + suggestions.push(...existingCommon, ...otherDirs); + } else { + suggestions.push(...directories); + } + + res.json({ + path: resolvedPath, + suggestions: suggestions + }); + + } catch (error) { + console.error('Error browsing filesystem:', error); + res.status(500).json({ error: 'Failed to browse filesystem' }); + } +}); + +app.post('/api/create-folder', authenticateToken, async (req, res) => { + try { + const { path: folderPath } = req.body; + if (!folderPath) { + return res.status(400).json({ error: 'Path is required' }); + } + const expandedPath = expandWorkspacePath(folderPath); + const resolvedInput = path.resolve(expandedPath); + const validation = await validateWorkspacePath(resolvedInput); + if (!validation.valid) { + return res.status(403).json({ error: validation.error }); + } + const targetPath = validation.resolvedPath || resolvedInput; + const parentDir = path.dirname(targetPath); + try { + await fs.promises.access(parentDir); + } catch (err) { + return res.status(404).json({ error: 'Parent directory does not exist' }); + } + try { + await fs.promises.access(targetPath); + return res.status(409).json({ error: 'Folder already exists' }); + } catch (err) { + // Folder doesn't exist, which is what we want + } + try { + await fs.promises.mkdir(targetPath, { recursive: false }); + res.json({ success: true, path: targetPath }); + } catch (mkdirError) { + if (mkdirError.code === 'EEXIST') { + return res.status(409).json({ error: 'Folder already exists' }); + } + throw mkdirError; + } + } catch (error) { + console.error('Error creating folder:', error); + res.status(500).json({ error: 'Failed to create folder' }); + } +}); + +// Read file content endpoint +app.get('/api/projects/:projectId/file', authenticateToken, async (req, res) => { + try { + const { projectId } = req.params; + const { filePath } = req.query; + + + // Security: ensure the requested path is inside the project root + if (!filePath) { + return res.status(400).json({ error: 'Invalid file path' }); + } + + // Resolve the absolute project root via the DB-backed helper; the + // caller passes the DB-assigned `projectId`, not a folder name. + const projectRoot = await projectsDb.getProjectPathById(projectId); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Handle both absolute and relative paths + const resolved = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(projectRoot, filePath); + const normalizedRoot = path.resolve(projectRoot) + path.sep; + if (!resolved.startsWith(normalizedRoot)) { + return res.status(403).json({ error: 'Path must be under project root' }); + } + + const content = await fsPromises.readFile(resolved, 'utf8'); + res.json({ content, path: resolved }); + } catch (error) { + console.error('Error reading file:', error); + if (error.code === 'ENOENT') { + res.status(404).json({ error: 'File not found' }); + } else if (error.code === 'EACCES') { + res.status(403).json({ error: 'Permission denied' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +// Serve raw file bytes for previews and downloads. +app.get('/api/projects/:projectId/files/content', authenticateToken, async (req, res) => { + try { + const { projectId } = req.params; + const { path: filePath } = req.query; + + + // Security: ensure the requested path is inside the project root + if (!filePath) { + return res.status(400).json({ error: 'Invalid file path' }); + } + + // Projects are now addressed by DB `projectId`, resolved to their path here. + const projectRoot = await projectsDb.getProjectPathById(projectId); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Match the text reader endpoint so callers can pass either project-relative + // or absolute paths without changing how the bytes are served. + const resolved = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(projectRoot, filePath); + const normalizedRoot = path.resolve(projectRoot) + path.sep; + if (!resolved.startsWith(normalizedRoot)) { + return res.status(403).json({ error: 'Path must be under project root' }); + } + + // Check if file exists + try { + await fsPromises.access(resolved); + } catch (error) { + return res.status(404).json({ error: 'File not found' }); + } + + // Get file extension and set appropriate content type + const mimeType = mime.lookup(resolved) || 'application/octet-stream'; + res.setHeader('Content-Type', mimeType); + + // Stream the file + const fileStream = fs.createReadStream(resolved); + fileStream.pipe(res); + + fileStream.on('error', (error) => { + console.error('Error streaming file:', error); + if (!res.headersSent) { + res.status(500).json({ error: 'Error reading file' }); + } + }); + + } catch (error) { + console.error('Error serving binary file:', error); + if (!res.headersSent) { + res.status(500).json({ error: error.message }); + } + } +}); + +// Save file content endpoint +app.put('/api/projects/:projectId/file', authenticateToken, async (req, res) => { + try { + const { projectId } = req.params; + const { filePath, content } = req.body; + + + // Security: ensure the requested path is inside the project root + if (!filePath) { + return res.status(400).json({ error: 'Invalid file path' }); + } + + if (content === undefined) { + return res.status(400).json({ error: 'Content is required' }); + } + + // Projects are now addressed by DB `projectId`, resolved to their path here. + const projectRoot = await projectsDb.getProjectPathById(projectId); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Handle both absolute and relative paths + const resolved = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(projectRoot, filePath); + const normalizedRoot = path.resolve(projectRoot) + path.sep; + if (!resolved.startsWith(normalizedRoot)) { + return res.status(403).json({ error: 'Path must be under project root' }); + } + + // Write the new content + await fsPromises.writeFile(resolved, content, 'utf8'); + + res.json({ + success: true, + path: resolved, + message: 'File saved successfully' + }); + } catch (error) { + console.error('Error saving file:', error); + if (error.code === 'ENOENT') { + res.status(404).json({ error: 'File or directory not found' }); + } else if (error.code === 'EACCES') { + res.status(403).json({ error: 'Permission denied' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +app.get('/api/projects/:projectId/files', authenticateToken, async (req, res) => { + try { + + // Using fsPromises from import + + // Resolve the project's absolute path through the DB (projectId is the + // primary key of the `projects` table after the identifier migration). + const actualPath = await projectsDb.getProjectPathById(req.params.projectId); + if (!actualPath) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Check if path exists + try { + await fsPromises.access(actualPath); + } catch (e) { + return res.status(404).json({ error: `Project path not found: ${actualPath}` }); + } + + const files = await getFileTree(actualPath, 10, 0, true); + res.json(files); + } catch (error) { + console.error('[ERROR] File tree error:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// ============================================================================ +// FILE OPERATIONS API ENDPOINTS +// ============================================================================ + +/** + * Validate that a path is within the project root + * @param {string} projectRoot - The project root path + * @param {string} targetPath - The path to validate + * @returns {{ valid: boolean, resolved?: string, error?: string }} + */ +function validatePathInProject(projectRoot, targetPath) { + const resolved = path.isAbsolute(targetPath) + ? path.resolve(targetPath) + : path.resolve(projectRoot, targetPath); + const normalizedRoot = path.resolve(projectRoot) + path.sep; + if (!resolved.startsWith(normalizedRoot)) { + return { valid: false, error: 'Path must be under project root' }; + } + return { valid: true, resolved }; +} + +/** + * Validate filename - check for invalid characters + * @param {string} name - The filename to validate + * @returns {{ valid: boolean, error?: string }} + */ +function validateFilename(name) { + if (!name || !name.trim()) { + return { valid: false, error: 'Filename cannot be empty' }; + } + // Check for invalid characters (Windows + Unix) + const invalidChars = /[<>:"/\\|?*\x00-\x1f]/; + if (invalidChars.test(name)) { + return { valid: false, error: 'Filename contains invalid characters' }; + } + // Check for reserved names (Windows) + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; + if (reserved.test(name)) { + return { valid: false, error: 'Filename is a reserved name' }; + } + // Check for dots only + if (/^\.+$/.test(name)) { + return { valid: false, error: 'Filename cannot be only dots' }; + } + return { valid: true }; +} + +// POST /api/projects/:projectId/files/create - Create new file or directory +app.post('/api/projects/:projectId/files/create', authenticateToken, async (req, res) => { + try { + const { projectId } = req.params; + const { path: parentPath, type, name } = req.body; + + // Validate input + if (!name || !type) { + return res.status(400).json({ error: 'Name and type are required' }); + } + + if (!['file', 'directory'].includes(type)) { + return res.status(400).json({ error: 'Type must be "file" or "directory"' }); + } + + const nameValidation = validateFilename(name); + if (!nameValidation.valid) { + return res.status(400).json({ error: nameValidation.error }); + } + + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await projectsDb.getProjectPathById(projectId); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Build and validate target path + const targetDir = parentPath || ''; + const targetPath = targetDir ? path.join(targetDir, name) : name; + const validation = validatePathInProject(projectRoot, targetPath); + if (!validation.valid) { + return res.status(403).json({ error: validation.error }); + } + + const resolvedPath = validation.resolved; + + // Check if already exists + try { + await fsPromises.access(resolvedPath); + return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` }); + } catch { + // Doesn't exist, which is what we want + } + + // Create file or directory + if (type === 'directory') { + await fsPromises.mkdir(resolvedPath, { recursive: false }); + } else { + // Ensure parent directory exists + const parentDir = path.dirname(resolvedPath); + try { + await fsPromises.access(parentDir); + } catch { + await fsPromises.mkdir(parentDir, { recursive: true }); + } + await fsPromises.writeFile(resolvedPath, '', 'utf8'); + } + + res.json({ + success: true, + path: resolvedPath, + name, + type, + message: `${type === 'file' ? 'File' : 'Directory'} created successfully` + }); + } catch (error) { + console.error('Error creating file/directory:', error); + if (error.code === 'EACCES') { + res.status(403).json({ error: 'Permission denied' }); + } else if (error.code === 'ENOENT') { + res.status(404).json({ error: 'Parent directory not found' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +// PUT /api/projects/:projectId/files/rename - Rename file or directory +app.put('/api/projects/:projectId/files/rename', authenticateToken, async (req, res) => { + try { + const { projectId } = req.params; + const { oldPath, newName } = req.body; + + // Validate input + if (!oldPath || !newName) { + return res.status(400).json({ error: 'oldPath and newName are required' }); + } + + const nameValidation = validateFilename(newName); + if (!nameValidation.valid) { + return res.status(400).json({ error: nameValidation.error }); + } + + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await projectsDb.getProjectPathById(projectId); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Validate old path + const oldValidation = validatePathInProject(projectRoot, oldPath); + if (!oldValidation.valid) { + return res.status(403).json({ error: oldValidation.error }); + } + + const resolvedOldPath = oldValidation.resolved; + + // Check if old path exists + try { + await fsPromises.access(resolvedOldPath); + } catch { + return res.status(404).json({ error: 'File or directory not found' }); + } + + // Build and validate new path + const parentDir = path.dirname(resolvedOldPath); + const resolvedNewPath = path.join(parentDir, newName); + const newValidation = validatePathInProject(projectRoot, resolvedNewPath); + if (!newValidation.valid) { + return res.status(403).json({ error: newValidation.error }); + } + + // Check if new path already exists + try { + await fsPromises.access(resolvedNewPath); + return res.status(409).json({ error: 'A file or directory with this name already exists' }); + } catch { + // Doesn't exist, which is what we want + } + + // Rename + await fsPromises.rename(resolvedOldPath, resolvedNewPath); + + res.json({ + success: true, + oldPath: resolvedOldPath, + newPath: resolvedNewPath, + newName, + message: 'Renamed successfully' + }); + } catch (error) { + console.error('Error renaming file/directory:', error); + if (error.code === 'EACCES') { + res.status(403).json({ error: 'Permission denied' }); + } else if (error.code === 'ENOENT') { + res.status(404).json({ error: 'File or directory not found' }); + } else if (error.code === 'EXDEV') { + res.status(400).json({ error: 'Cannot move across different filesystems' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +// DELETE /api/projects/:projectId/files - Delete file or directory +app.delete('/api/projects/:projectId/files', authenticateToken, async (req, res) => { + try { + const { projectId } = req.params; + const { path: targetPath, type } = req.body; + + // Validate input + if (!targetPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await projectsDb.getProjectPathById(projectId); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Validate path + const validation = validatePathInProject(projectRoot, targetPath); + if (!validation.valid) { + return res.status(403).json({ error: validation.error }); + } + + const resolvedPath = validation.resolved; + + // Check if path exists and get stats + let stats; + try { + stats = await fsPromises.stat(resolvedPath); + } catch { + return res.status(404).json({ error: 'File or directory not found' }); + } + + // Prevent deleting the project root itself + if (resolvedPath === path.resolve(projectRoot)) { + return res.status(403).json({ error: 'Cannot delete project root directory' }); + } + + // Delete based on type + if (stats.isDirectory()) { + await fsPromises.rm(resolvedPath, { recursive: true, force: true }); + } else { + await fsPromises.unlink(resolvedPath); + } + + res.json({ + success: true, + path: resolvedPath, + type: stats.isDirectory() ? 'directory' : 'file', + message: 'Deleted successfully' + }); + } catch (error) { + console.error('Error deleting file/directory:', error); + if (error.code === 'EACCES') { + res.status(403).json({ error: 'Permission denied' }); + } else if (error.code === 'ENOENT') { + res.status(404).json({ error: 'File or directory not found' }); + } else if (error.code === 'ENOTEMPTY') { + res.status(400).json({ error: 'Directory is not empty' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +// POST /api/projects/:projectId/files/upload - Upload files +// Dynamic import of multer for file uploads +const uploadFilesHandler = async (req, res) => { + // Dynamic import of multer + const multer = (await import('multer')).default; + + const uploadMiddleware = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, os.tmpdir()); + }, + filename: (req, file, cb) => { + // Use a unique temp name, but preserve original name in file.originalname + // Note: file.originalname may contain path separators for folder uploads + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + // For temp file, just use a safe unique name without the path + cb(null, `upload-${uniqueSuffix}`); + } + }), + limits: { + fileSize: 50 * 1024 * 1024, // 50MB limit + files: 20 // Max 20 files at once + } + }); + + // Use multer middleware + uploadMiddleware.array('files', 20)(req, res, async (err) => { + if (err) { + console.error('Multer error:', err); + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); + } + if (err.code === 'LIMIT_FILE_COUNT') { + return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' }); + } + return res.status(500).json({ error: err.message }); + } + + try { + const { projectId } = req.params; + const { targetPath, relativePaths } = req.body; + + // Parse relative paths if provided (for folder uploads) + let filePaths = []; + if (relativePaths) { + try { + filePaths = JSON.parse(relativePaths); + } catch (e) { + console.log('[DEBUG] Failed to parse relativePaths:', relativePaths); + } + } + + console.log('[DEBUG] File upload request:', { + projectId, + targetPath: JSON.stringify(targetPath), + targetPathType: typeof targetPath, + filesCount: req.files?.length, + relativePaths: filePaths + }); + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files provided' }); + } + + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await projectsDb.getProjectPathById(projectId); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + console.log('[DEBUG] Project root:', projectRoot); + + // Validate and resolve target path + // If targetPath is empty or '.', use project root directly + const targetDir = targetPath || ''; + let resolvedTargetDir; + + console.log('[DEBUG] Target dir:', JSON.stringify(targetDir)); + + if (!targetDir || targetDir === '.' || targetDir === './') { + // Empty path means upload to project root + resolvedTargetDir = path.resolve(projectRoot); + console.log('[DEBUG] Using project root as target:', resolvedTargetDir); + } else { + const validation = validatePathInProject(projectRoot, targetDir); + if (!validation.valid) { + console.log('[DEBUG] Path validation failed:', validation.error); + return res.status(403).json({ error: validation.error }); + } + resolvedTargetDir = validation.resolved; + console.log('[DEBUG] Resolved target dir:', resolvedTargetDir); + } + + // Ensure target directory exists + try { + await fsPromises.access(resolvedTargetDir); + } catch { + await fsPromises.mkdir(resolvedTargetDir, { recursive: true }); + } + + // Move uploaded files from temp to target directory + const uploadedFiles = []; + console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path }))); + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + // Use relative path if provided (for folder uploads), otherwise use originalname + const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname; + console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')'); + const destPath = path.join(resolvedTargetDir, fileName); + + // Validate destination path + const destValidation = validatePathInProject(projectRoot, destPath); + if (!destValidation.valid) { + console.log('[DEBUG] Destination validation failed for:', destPath); + // Clean up temp file + await fsPromises.unlink(file.path).catch(() => {}); + continue; + } + + // Ensure parent directory exists (for nested files from folder upload) + const parentDir = path.dirname(destPath); + try { + await fsPromises.access(parentDir); + } catch { + await fsPromises.mkdir(parentDir, { recursive: true }); + } + + // Move file (copy + unlink to handle cross-device scenarios) + await fsPromises.copyFile(file.path, destPath); + await fsPromises.unlink(file.path); + + uploadedFiles.push({ + name: fileName, + path: destPath, + size: file.size, + mimeType: file.mimetype + }); + } + + res.json({ + success: true, + files: uploadedFiles, + targetPath: resolvedTargetDir, + message: `Uploaded ${uploadedFiles.length} file(s) successfully` + }); + } catch (error) { + console.error('Error uploading files:', error); + // Clean up any remaining temp files + if (req.files) { + for (const file of req.files) { + await fsPromises.unlink(file.path).catch(() => {}); + } + } + if (error.code === 'EACCES') { + res.status(403).json({ error: 'Permission denied' }); + } else { + res.status(500).json({ error: error.message }); + } + } + }); +}; + +app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler); + +// Image upload endpoint. Accepts the DB-assigned `projectId` (not a folder name) +// but the current implementation doesn't need to touch the project directory, +// so we just leave the param rename for consistency with the rest of the API. +app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req, res) => { + try { + const multer = (await import('multer')).default; + const path = (await import('path')).default; + const fs = (await import('fs')).promises; + const os = (await import('os')).default; + + // Configure multer for image uploads + const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id)); + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, uniqueSuffix + '-' + sanitizedName); + } + }); + + const fileFilter = (req, file, cb) => { + const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; + if (allowedMimes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.')); + } + }; + + const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB + files: 5 + } + }); + + // Handle multipart form data + upload.array('images', 5)(req, res, async (err) => { + if (err) { + return res.status(400).json({ error: err.message }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No image files provided' }); + } + + try { + // Process uploaded images + const processedImages = await Promise.all( + req.files.map(async (file) => { + // Read file and convert to base64 + const buffer = await fs.readFile(file.path); + const base64 = buffer.toString('base64'); + const mimeType = file.mimetype; + + // Clean up temp file immediately + await fs.unlink(file.path); + + return { + name: file.originalname, + data: `data:${mimeType};base64,${base64}`, + size: file.size, + mimeType: mimeType + }; + }) + ); + + res.json({ images: processedImages }); + } catch (error) { + console.error('Error processing images:', error); + // Clean up any remaining files + await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { }))); + res.status(500).json({ error: 'Failed to process images' }); + } + }); + } catch (error) { + console.error('Error in image upload endpoint:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get token usage for a specific session. `projectId` is the DB primary key; +// the Claude branch below resolves it to an absolute path via the DB. +app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { + try { + const { projectId, sessionId } = req.params; + const { provider = 'claude' } = req.query; + const homeDir = os.homedir(); + + // Allow only safe characters in sessionId + const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safeSessionId || safeSessionId !== String(sessionId)) { + return res.status(400).json({ error: 'Invalid sessionId' }); + } + + // Handle Cursor sessions - they use SQLite and don't have token usage info + if (provider === 'cursor') { + return res.json({ + used: 0, + total: 0, + breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, + unsupported: true, + message: 'Token usage tracking not available for Cursor sessions' + }); + } + + // Handle Gemini sessions - they are raw logs in our current setup + if (provider === 'gemini') { + return res.json({ + used: 0, + total: 0, + breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, + unsupported: true, + message: 'Token usage tracking not available for Gemini sessions' + }); + } + + // Handle Codex sessions + if (provider === 'codex') { + const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); + + // Find the session file by searching for the session ID + const findSessionFile = async (dir) => { + try { + const entries = await fsPromises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = await findSessionFile(fullPath); + if (found) return found; + } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { + return fullPath; + } + } + } catch (error) { + // Skip directories we can't read + } + return null; + }; + + const sessionFilePath = await findSessionFile(codexSessionsDir); + + if (!sessionFilePath) { + return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId }); + } + + // Read and parse the Codex JSONL file + let fileContent; + try { + fileContent = await fsPromises.readFile(sessionFilePath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Session file not found', path: sessionFilePath }); + } + throw error; + } + const lines = fileContent.trim().split('\n'); + let totalTokens = 0; + let contextWindow = 200000; // Default for Codex/OpenAI + + // Find the latest token_count event with info (scan from end) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + + // Codex stores token info in event_msg with type: "token_count" + if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { + const tokenInfo = entry.payload.info; + if (tokenInfo.total_token_usage) { + totalTokens = tokenInfo.total_token_usage.total_tokens || 0; + } + if (tokenInfo.model_context_window) { + contextWindow = tokenInfo.model_context_window; + } + break; // Stop after finding the latest token count + } + } catch (parseError) { + // Skip lines that can't be parsed + continue; + } + } + + return res.json({ + used: totalTokens, + total: contextWindow + }); + } + + // Handle Claude sessions (default) + // Resolve the project path through the DB using the caller-supplied + // `projectId`. Legacy code here called extractProjectDirectory with a + // folder-encoded project name; the migration centralizes that lookup + // in the projects table. + const projectPath = await projectsDb.getProjectPathById(projectId); + if (!projectPath) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Construct the JSONL file path + // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl + // The encoding replaces any non-alphanumeric character (except -) with - + const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); + const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); + + const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); + + // Constrain to projectDir + const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + return res.status(400).json({ error: 'Invalid path' }); + } + + // Read and parse the JSONL file + let fileContent; + try { + fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); + } + throw error; // Re-throw other errors to be caught by outer try-catch + } + const lines = fileContent.trim().split('\n'); + + const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); + const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; + let inputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + + // Find the latest assistant message with usage data (scan from end) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + + // Only count assistant messages which have usage data + if (entry.type === 'assistant' && entry.message?.usage) { + const usage = entry.message.usage; + + // Use token counts from latest assistant message only + inputTokens = usage.input_tokens || 0; + cacheCreationTokens = usage.cache_creation_input_tokens || 0; + cacheReadTokens = usage.cache_read_input_tokens || 0; + + break; // Stop after finding the latest assistant message + } + } catch (parseError) { + // Skip lines that can't be parsed + continue; + } + } + + // Calculate total context usage (excluding output_tokens, as per ccusage) + const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens; + + res.json({ + used: totalUsed, + total: contextWindow, + breakdown: { + input: inputTokens, + cacheCreation: cacheCreationTokens, + cacheRead: cacheReadTokens + } + }); + } catch (error) { + console.error('Error reading session token usage:', error); + res.status(500).json({ error: 'Failed to read session token usage' }); + } +}); + +// Serve React app for all other routes (excluding static files) +app.get('*', (req, res) => { + // Skip requests for static assets (files with extensions) + if (path.extname(req.path)) { + return res.status(404).send('Not found'); + } + + // Only serve index.html for HTML routes, not for static assets + // Static assets should already be handled by express.static middleware above + const indexPath = path.join(APP_ROOT, 'dist', 'index.html'); + + // Check if dist/index.html exists (production build available) + if (fs.existsSync(indexPath)) { + // Set no-cache headers for HTML to prevent service worker issues + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.sendFile(indexPath); + } else { + // In development, redirect to Vite dev server only if dist doesn't exist + const redirectHost = getConnectableHost(req.hostname); + res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`); + } +}); + +// global error middleware must be last +app.use((err, req, res, next) => { + if (err instanceof AppError) { + return res.status(err.statusCode).json({ + success: false, + error: { + code: err.code, + message: err.message, + details: err.details, + }, + }); + } + + console.error(err); + + return res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_ERROR', + message: 'Internal server error', + }, + }); +}); + +// Helper function to convert permissions to rwx format +function permToRwx(perm) { + const r = perm & 4 ? 'r' : '-'; + const w = perm & 2 ? 'w' : '-'; + const x = perm & 1 ? 'x' : '-'; + return r + w + x; +} + +async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) { + // Using fsPromises from import + const items = []; + + try { + const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + // Debug: log all entries including hidden files + + + // Skip heavy build directories and VCS directories + if (entry.name === 'node_modules' || + entry.name === 'dist' || + entry.name === 'build' || + entry.name === '.git' || + entry.name === '.svn' || + entry.name === '.hg') continue; + + const itemPath = path.join(dirPath, entry.name); + const item = { + name: entry.name, + path: itemPath, + type: entry.isDirectory() ? 'directory' : 'file' + }; + + // Get file stats for additional metadata + try { + const stats = await fsPromises.stat(itemPath); + item.size = stats.size; + item.modified = stats.mtime.toISOString(); + + // Convert permissions to rwx format + const mode = stats.mode; + const ownerPerm = (mode >> 6) & 7; + const groupPerm = (mode >> 3) & 7; + const otherPerm = mode & 7; + item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString(); + item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm); + } catch (statError) { + // If stat fails, provide default values + item.size = 0; + item.modified = null; + item.permissions = '000'; + item.permissionsRwx = '---------'; + } + + if (entry.isDirectory() && currentDepth < maxDepth) { + // Recursively get subdirectories but limit depth + try { + // Check if we can access the directory before trying to read it + await fsPromises.access(item.path, fs.constants.R_OK); + item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden); + } catch (e) { + // Silently skip directories we can't access (permission denied, etc.) + item.children = []; + } + } + + items.push(item); + } + } catch (error) { + // Only log non-permission errors to avoid spam + if (error.code !== 'EACCES' && error.code !== 'EPERM') { + console.error('Error reading directory:', error); + } + } + + return items.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +} + +const SERVER_PORT = process.env.SERVER_PORT || 3001; +const HOST = process.env.HOST || '0.0.0.0'; +const DISPLAY_HOST = getConnectableHost(HOST); +const VITE_PORT = process.env.VITE_PORT || 5173; + +// Initialize database and start server +async function startServer() { + try { + // Initialize authentication database + await initializeDatabase(); + + // Configure Web Push (VAPID keys) + configureWebPush(); + + // Check if running in production mode (dist folder exists) + const distIndexPath = path.join(APP_ROOT, 'dist', 'index.html'); + const isProduction = fs.existsSync(distIndexPath); + + // Log Claude implementation mode + console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`); + console.log(''); + + if (isProduction) { + console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`); + } + + console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`); + + server.listen(SERVER_PORT, HOST, async () => { + const appInstallPath = APP_ROOT; + + console.log(''); + console.log(c.dim('═'.repeat(63))); + console.log(` ${c.bright('CloudCLI Server - Ready')}`); + console.log(c.dim('═'.repeat(63))); + console.log(''); + console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`); + console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`); + console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`); + console.log(''); + + // Start watching the projects folder for changes + await initializeSessionsWatcher(); + + // Start server-side plugin processes for enabled plugins + startEnabledPluginServers().catch(err => { + console.error('[Plugins] Error during startup:', err.message); + }); + }); + + await closeSessionsWatcher(); + // 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); + } +} + +startServer();