#!/usr/bin/env node // Load environment variables before other imports execute import './load-env.js'; import fs, { promises as fsPromises } from 'fs'; import path from 'path'; import os from 'os'; import http from 'http'; import { spawn } from 'child_process'; import express from 'express'; import cors from 'cors'; import mime from 'mime-types'; import { AppError } from '@/shared/utils.js'; import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js'; import { getProjectsWithSessions } from '@/modules/projects/index.js'; import { createWebSocketServer } from '@/modules/websocket/index.js'; import { getConnectableHost } from '../shared/networkHosts.js'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; import { getSessionsById, renameProjectById, deleteSessionById, deleteProjectById, getProjectTaskMasterById, getProjectPathById, searchConversations, } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter, } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions, } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions, } from './openai-codex.js'; import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions, } from './gemini-cli.js'; import sessionManager from './sessionManager.js'; import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput, } from './utils/url-detection.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import cursorRoutes from './routes/cursor.js'; import taskmasterRoutes from './routes/taskmaster.js'; import mcpUtilsRoutes from './routes/mcp-utils.js'; import commandsRoutes from './routes/commands.js'; import settingsRoutes from './routes/settings.js'; import agentRoutes from './routes/agent.js'; import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js'; import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; import messagesRoutes from './routes/messages.js'; import providerRoutes from './modules/providers/provider.routes.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionsDb, applyCustomSessionNames } 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'; 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'; import { c } from './utils/colors.js'; console.log('SERVER_PORT from env:', process.env.SERVER_PORT); const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini']; 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, projectsRoutes); // 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); // Codex API Routes (protected) app.use('/api/codex', authenticateToken, codexRoutes); // Gemini API Routes (protected) app.use('/api/gemini', authenticateToken, geminiRoutes); // Plugins API Routes (protected) app.use('/api/plugins', authenticateToken, pluginsRoutes); // Unified session messages route (protected) app.use('/api/sessions', authenticateToken, messagesRoutes); // 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 }); } }); app.get('/api/projects', authenticateToken, async (req, res) => { try { const projects = await getProjectsWithSessions(); res.json(projects); } catch (error) { res.status(500).json({ error: error.message }); } }); // Project-scoped TaskMaster details; identified by DB-assigned `projectId`. app.get('/api/projects/:projectId/taskmaster', authenticateToken, async (req, res) => { try { const { projectId } = req.params; const taskMasterDetails = await getProjectTaskMasterById(projectId); if (!taskMasterDetails) { return res.status(404).json({ error: 'Project not found' }); } res.json(taskMasterDetails); } catch (error) { res.status(500).json({ error: error.message }); } }); // Sessions for a project; `projectId` is resolved to a path via the DB. app.get('/api/projects/:projectId/sessions', authenticateToken, async (req, res) => { try { const { limit = 5, offset = 0 } = req.query; const result = await getSessionsById(req.params.projectId, parseInt(limit), parseInt(offset)); applyCustomSessionNames(result.sessions, 'claude'); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Rename project endpoint; stores the custom name on the DB row for `projectId`. app.put('/api/projects/:projectId/rename', authenticateToken, async (req, res) => { try { const { displayName } = req.body; await renameProjectById(req.params.projectId, displayName); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete session endpoint; resolves `projectId` to path before touching disk. app.delete('/api/projects/:projectId/sessions/:sessionId', authenticateToken, async (req, res) => { try { const { projectId, sessionId } = req.params; console.log(`[API] Deleting session: ${sessionId} from project: ${projectId}`); await deleteSessionById(projectId, sessionId); sessionsDb.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 }); } }); // Rename session endpoint app.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(', ')}` }); } sessionsDb.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 }); } }); // Delete project endpoint // force=true to allow removal even when sessions exist // deleteData=true to also delete session/memory files on disk (destructive) // `projectId` is resolved to an absolute path through the DB before cleanup. app.delete('/api/projects/:projectId', authenticateToken, async (req, res) => { try { const { projectId } = req.params; const force = req.query.force === 'true'; const deleteData = req.query.deleteData === 'true'; await deleteProjectById(projectId, force, deleteData); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Search conversations content (SSE streaming) app.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 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 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 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 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 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 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 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 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 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 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(); // await getProjectsWithSessions(); // TODO: REMOVE THIS // 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();