mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 18:28:38 +00:00
1604 lines
60 KiB
JavaScript
Executable File
1604 lines
60 KiB
JavaScript
Executable File
#!/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, WORKSPACES_ROOT, validateWorkspacePath } from '@/shared/utils.js';
|
|
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
|
|
import { createWebSocketServer } from '@/modules/websocket/index.js';
|
|
|
|
import { getConnectableHost } from '../shared/networkHosts.js';
|
|
|
|
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
|
import {
|
|
deleteSessionById,
|
|
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 projectModuleRoutes from './modules/projects/projects.routes.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 } from './modules/database/index.js';
|
|
import { configureWebPush } from './services/vapid-keys.js';
|
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
import { IS_PLATFORM } from './constants/config.js';
|
|
import { c } from './utils/colors.js';
|
|
|
|
const __dirname = getModuleDir(import.meta.url);
|
|
// The server source runs from /server, while the compiled output runs from /dist-server/server.
|
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
|
const APP_ROOT = findAppRoot(__dirname);
|
|
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
|
|
|
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
|
|
|
const 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, projectModuleRoutes);
|
|
|
|
// Git API Routes (protected)
|
|
app.use('/api/git', authenticateToken, gitRoutes);
|
|
|
|
// Cursor API Routes (protected)
|
|
app.use('/api/cursor', authenticateToken, cursorRoutes);
|
|
|
|
// TaskMaster API Routes (protected)
|
|
app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
|
|
|
|
// MCP utilities
|
|
app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
|
|
|
|
// Commands API Routes (protected)
|
|
app.use('/api/commands', authenticateToken, commandsRoutes);
|
|
|
|
// Settings API Routes (protected)
|
|
app.use('/api/settings', authenticateToken, settingsRoutes);
|
|
|
|
// User API Routes (protected)
|
|
app.use('/api/user', authenticateToken, userRoutes);
|
|
|
|
// 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
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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
|
|
// 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();
|
|
|
|
// 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();
|