diff --git a/server/src/modules/files/files.routes.js b/server/src/modules/files/files.routes.js new file mode 100644 index 00000000..adb52dce --- /dev/null +++ b/server/src/modules/files/files.routes.js @@ -0,0 +1,934 @@ +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import mime from 'mime-types'; +import fetch from 'node-fetch'; +import { promises as fsPromises } from 'fs'; +import { extractProjectDirectory } from '../../../projects.js'; +import { authenticateToken } from '../auth/auth.middleware.js'; + +const router = express.Router(); + +/** + * 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 }; +} + +// 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); + }); +} + +// Read file content endpoint +router.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { + try { + const { projectName } = 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' }); + } + + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + 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 binary file content endpoint (for images, etc.) +router.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => { + try { + const { projectName } = 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' }); + } + + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + const resolved = path.resolve(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 +router.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => { + try { + const { projectName } = 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' }); + } + + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + 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 }); + } + } +}); + +router.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => { + try { + + // Using fsPromises from import + + // Use extractProjectDirectory to get the actual project path + let actualPath; + try { + actualPath = await extractProjectDirectory(req.params.projectName); + } catch (error) { + console.error('Error extracting project directory:', error); + // Fallback to simple dash replacement + actualPath = req.params.projectName.replace(/-/g, '/'); + } + + // 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 +// ============================================================================ + +// POST /api/projects/:projectName/files/create - Create new file or directory +router.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => { + try { + const { projectName } = 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 }); + } + + // Get project root + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + 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/:projectName/files/rename - Rename file or directory +router.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => { + try { + const { projectName } = 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 }); + } + + // Get project root + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + 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/:projectName/files - Delete file or directory +router.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => { + try { + const { projectName } = req.params; + const { path: targetPath, type } = req.body; + + // Validate input + if (!targetPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + // Get project root + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + 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/:projectName/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 { projectName } = 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:', { + projectName, + 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' }); + } + + // Get project root + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + 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 }); + } + } + }); +}; + +router.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler); + +// Audio transcription endpoint +router.post('/api/transcribe', authenticateToken, async (req, res) => { + try { + const multer = (await import('multer')).default; + const upload = multer({ storage: multer.memoryStorage() }); + + // Handle multipart form data + upload.single('audio')(req, res, async (err) => { + if (err) { + return res.status(400).json({ error: 'Failed to process audio file' }); + } + + if (!req.file) { + return res.status(400).json({ error: 'No audio file provided' }); + } + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' }); + } + + try { + // Create form data for OpenAI + const FormData = (await import('form-data')).default; + const formData = new FormData(); + formData.append('file', req.file.buffer, { + filename: req.file.originalname, + contentType: req.file.mimetype + }); + formData.append('model', 'whisper-1'); + formData.append('response_format', 'json'); + formData.append('language', 'en'); + + // Make request to OpenAI + const response = await fetch('https://api.openai.com/v1/audio/transcriptions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + ...formData.getHeaders() + }, + body: formData + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`); + } + + const data = await response.json(); + let transcribedText = data.text || ''; + + // Check if enhancement mode is enabled + const mode = req.body.mode || 'default'; + + // If no transcribed text, return empty + if (!transcribedText) { + return res.json({ text: '' }); + } + + // If default mode, return transcribed text without enhancement + if (mode === 'default') { + return res.json({ text: transcribedText }); + } + + // Handle different enhancement modes + try { + const OpenAI = (await import('openai')).default; + const openai = new OpenAI({ apiKey }); + + let prompt, systemMessage, temperature = 0.7, maxTokens = 800; + + switch (mode) { + case 'prompt': + systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.'; + prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt. + +Your enhanced prompt should: +1. Be specific and unambiguous +2. Include relevant context and constraints +3. Specify the desired output format +4. Use clear, actionable language +5. Include examples where helpful +6. Consider edge cases and potential ambiguities + +Transform this rough instruction into a well-crafted prompt: +"${transcribedText}" + +Enhanced prompt:`; + break; + + case 'vibe': + case 'instructions': + case 'architect': + systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.'; + temperature = 0.5; // Lower temperature for more controlled output + prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute. + +IMPORTANT RULES: +- Format as clear, step-by-step instructions +- Add reasonable implementation details based on common patterns +- Only include details directly related to what was asked +- Do NOT add features or functionality not mentioned +- Keep the original intent and scope intact +- Use clear, actionable language an agent can follow + +Transform this idea into agent-friendly instructions: +"${transcribedText}" + +Agent instructions:`; + break; + + default: + // No enhancement needed + break; + } + + // Only make GPT call if we have a prompt + if (prompt) { + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemMessage }, + { role: 'user', content: prompt } + ], + temperature: temperature, + max_tokens: maxTokens + }); + + transcribedText = completion.choices[0].message.content || transcribedText; + } + + } catch (gptError) { + console.error('GPT processing error:', gptError); + // Fall back to original transcription if GPT fails + } + + res.json({ text: transcribedText }); + + } catch (error) { + console.error('Transcription error:', error); + res.status(500).json({ error: error.message }); + } + }); + } catch (error) { + console.error('Endpoint error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Image upload endpoint +router.post('/api/projects/:projectName/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' }); + } +}); + +export default router; diff --git a/server/src/modules/health/health.routes.js b/server/src/modules/health/health.routes.js new file mode 100644 index 00000000..890190bd --- /dev/null +++ b/server/src/modules/health/health.routes.js @@ -0,0 +1,21 @@ +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const router = express.Router(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const installMode = fs.existsSync(path.join(__dirname, '../../../../.git')) ? 'git' : 'npm'; + +// Public health check endpoint (no authentication required) +router.get('/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + installMode + }); +}); + +export default router; diff --git a/server/src/modules/projects/projects.inline.routes.js b/server/src/modules/projects/projects.inline.routes.js new file mode 100644 index 00000000..4e1f770d --- /dev/null +++ b/server/src/modules/projects/projects.inline.routes.js @@ -0,0 +1,372 @@ +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import { promises as fsPromises } from 'fs'; +import { + getProjects, + getSessions, + renameProject, + deleteSession, + deleteProject, + addProjectManually, + searchConversations +} from '../../../projects.js'; +import { applyCustomSessionNames, sessionNamesDb } from '../../../database/db.js'; +import { authenticateToken } from '../auth/auth.middleware.js'; +import { WORKSPACES_ROOT, validateWorkspacePath } from './projects.routes.js'; + +const router = express.Router(); + +// Broadcast progress to all connected WebSocket clients +function broadcastProgress(req, progress) { + const connectedClients = req.app.locals.connectedClients; + if (!connectedClients) return; + + const message = JSON.stringify({ + type: 'loading_progress', + ...progress + }); + connectedClients.forEach(client => { + if (client.readyState === 1) { + client.send(message); + } + }); +} + +router.get('/api/projects', authenticateToken, async (req, res) => { + try { + const projects = await getProjects((progress) => broadcastProgress(req, progress)); + res.json(projects); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => { + try { + const { limit = 5, offset = 0 } = req.query; + const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset)); + applyCustomSessionNames(result.sessions, 'claude'); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Rename project endpoint +router.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => { + try { + const { displayName } = req.body; + await renameProject(req.params.projectName, displayName); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Delete session endpoint +router.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => { + try { + const { projectName, sessionId } = req.params; + console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); + await deleteSession(projectName, sessionId); + sessionNamesDb.deleteName(sessionId, 'claude'); + console.log(`[API] Session ${sessionId} deleted successfully`); + res.json({ success: true }); + } catch (error) { + console.error(`[API] Error deleting session ${req.params.sessionId}:`, error); + res.status(500).json({ error: error.message }); + } +}); + +// Delete project endpoint (force=true to delete with sessions) +router.delete('/api/projects/:projectName', authenticateToken, async (req, res) => { + try { + const { projectName } = req.params; + const force = req.query.force === 'true'; + await deleteProject(projectName, force); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Create project endpoint +router.post('/api/projects/create', authenticateToken, async (req, res) => { + try { + const { path: projectPath } = req.body; + + if (!projectPath || !projectPath.trim()) { + return res.status(400).json({ error: 'Project path is required' }); + } + + const project = await addProjectManually(projectPath.trim()); + res.json({ success: true, project }); + } catch (error) { + console.error('Error creating project:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Search conversations content (SSE streaming) +router.get('/api/search/conversations', authenticateToken, async (req, res) => { + const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''; + const parsedLimit = Number.parseInt(String(req.query.limit), 10); + const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100)); + + if (query.length < 2) { + return res.status(400).json({ error: 'Query must be at least 2 characters' }); + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + let closed = false; + const abortController = new AbortController(); + req.on('close', () => { closed = true; abortController.abort(); }); + + try { + await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => { + if (closed) return; + if (projectResult) { + res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`); + } else { + res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`); + } + }, abortController.signal); + if (!closed) { + res.write(`event: done\ndata: {}\n\n`); + } + } catch (error) { + console.error('Error searching conversations:', error); + if (!closed) { + res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`); + } + } finally { + if (!closed) { + res.end(); + } + } +}); + +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 +router.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' }); + } +}); + +router.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' }); + } +}); + +// 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); + }); +} + +export default router; diff --git a/server/src/modules/sessions/sessions.inline.routes.js b/server/src/modules/sessions/sessions.inline.routes.js new file mode 100644 index 00000000..3e225050 --- /dev/null +++ b/server/src/modules/sessions/sessions.inline.routes.js @@ -0,0 +1,226 @@ +import express from 'express'; +import path from 'path'; +import os from 'os'; +import { promises as fsPromises } from 'fs'; +import { sessionNamesDb } from '../../../database/db.js'; +import { extractProjectDirectory } from '../../../projects.js'; +import { authenticateToken } from '../auth/auth.middleware.js'; + +const router = express.Router(); +const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini']; + +// Rename session endpoint +router.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => { + try { + const { sessionId } = req.params; + const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safeSessionId || safeSessionId !== String(sessionId)) { + return res.status(400).json({ error: 'Invalid sessionId' }); + } + const { summary, provider } = req.body; + if (!summary || typeof summary !== 'string' || summary.trim() === '') { + return res.status(400).json({ error: 'Summary is required' }); + } + if (summary.trim().length > 500) { + return res.status(400).json({ error: 'Summary must not exceed 500 characters' }); + } + if (!provider || !VALID_PROVIDERS.includes(provider)) { + return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` }); + } + sessionNamesDb.setName(safeSessionId, provider, summary.trim()); + res.json({ success: true }); + } catch (error) { + console.error(`[API] Error renaming session ${req.params.sessionId}:`, error); + res.status(500).json({ error: error.message }); + } +}); + +// Get token usage for a specific session +router.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { + try { + const { projectName, 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) + // Extract actual project path + let projectPath; + try { + projectPath = await extractProjectDirectory(projectName); + } catch (error) { + console.error('Error extracting project directory:', error); + return res.status(500).json({ error: 'Failed to determine project path' }); + } + + // 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' }); + } +}); + +export default router; diff --git a/server/src/modules/system/system.routes.js b/server/src/modules/system/system.routes.js new file mode 100644 index 00000000..15b5654a --- /dev/null +++ b/server/src/modules/system/system.routes.js @@ -0,0 +1,82 @@ +import express from 'express'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { authenticateToken } from '../auth/auth.middleware.js'; + +const router = express.Router(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const installMode = fs.existsSync(path.join(__dirname, '../../../../.git')) ? 'git' : 'npm'; + +// System update endpoint +router.post('/api/system/update', authenticateToken, async (req, res) => { + try { + // Get the project root directory (parent of server directory) + const projectRoot = path.join(__dirname, '../../../../'); + + console.log('Starting system update from directory:', projectRoot); + + // Run the update command based on install mode + const updateCommand = installMode === 'git' + ? 'git checkout main && git pull && npm install' + : 'npm install -g @siteboon/claude-code-ui@latest'; + + const child = spawn('sh', ['-c', updateCommand], { + cwd: installMode === 'git' ? projectRoot : os.homedir(), + 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 + }); + } +}); + +export default router; diff --git a/server/src/runner.ts b/server/src/runner.ts index 3404638f..430f3f69 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -40,6 +40,8 @@ async function importRoute(relativePath: string): Promise { } const [ + healthRoutes, + systemRoutes, gitRoutes, mcpRoutes, cursorRoutes, @@ -58,7 +60,12 @@ const [ geminiRoutes, pluginsRoutes, messagesRoutes, + projectsInlineRoutes, + filesRoutes, + sessionsInlineRoutes, ] = await Promise.all([ + importRoute('./modules/health/health.routes.js'), + importRoute('./modules/system/system.routes.js'), importRoute('./modules/git/git.routes.js'), importRoute('./modules/mcp/mcp.routes.js'), importRoute('./modules/cursor/cursor.routes.js'), @@ -77,6 +84,9 @@ const [ importRoute('./modules/gemini/gemini.routes.js'), importRoute('./modules/plugins/plugins.routes.js'), importRoute('./modules/messages/messages.routes.js'), + importRoute('./modules/projects/projects.inline.routes.js'), + importRoute('./modules/files/files.routes.js'), + importRoute('./modules/sessions/sessions.inline.routes.js'), ]); // ---------- MIDDLEWARES ---------------- @@ -106,6 +116,9 @@ app.use((req, res, next) => { }); +// Public health check endpoint (no authentication required) +app.use(healthRoutes); + // Optional API key validation (if configured) app.use('/api', validateApiKey); @@ -163,6 +176,12 @@ app.use('/api/sessions', authenticateToken, messagesRoutes); // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); +// System and inline routes migrated from legacy index.js +app.use(systemRoutes); +app.use(projectsInlineRoutes); +app.use(filesRoutes); +app.use(sessionsInlineRoutes); + // This matches files found in the root public folder (like api-docs.html when we run `/api-docs.html`). // If the file is found, it's automatically sent. If it is not, it passes it to the next route checker. // This will run in production as well as development URLs.