Files
claudecodeui/server/index.js
Haile 374e9de719 feat: add opencode support (#762)
* feat: add opencode support

* fix: stabilize opencode session startup

* fix: /models

* fix: improveUI for commands

* fix: format commands.js

* feat: load models through provider adapters

Provider model selection had outgrown a single hardcoded service.

The old service mixed shared caching with provider catalogs and CLI lookup details.

That made stale model lists more likely as providers changed on separate schedules.

Move model discovery behind each provider so lookup lives next to the integration.

The shared service now focuses on provider resolution, caching, persistence, and dedupe.

Return cache metadata and add bypassCache because model availability changes outside the app.

The UI and /models command can show freshness and let users force a provider refresh.

Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.

* feat(models): resolve active session models through provider adapters

The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.

Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:

- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
  provide a reliable active-session lookup

Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.

Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.

As part of this, centralize two supporting concerns:

- add a shared helper for building the default current-model result from
  a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
  readers and model lookup code enforce the same path-safety rule

Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.

Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
  routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
  provider default when no true active model can be resolved

* feat: support session-scoped model overrides

Model selection was acting like a provider-level preference.

That made resumed sessions drift back to a default or request-time model.

Users expect /models changes made inside a conversation to affect that session.

Store explicit session choices in app-owned ~/.cloudcli state.

This avoids editing provider transcripts or native provider config.

Resolve the effective model before launching each provider runtime.

Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.

Expose a backend active-model change endpoint for existing sessions.

The models modal can now distinguish default changes from session overrides.

It also shows when a selected model will apply on the next response.

For Claude, stop probing active model state by resuming with a dummy prompt.

Read the indexed JSONL transcript from the end instead.

This preserves provider history while honoring /model stdout or model fields.

Add service tests for adapter delegation and resume-model precedence.

The tests keep cache state, override state, and requested fallback separate.

* feat: make command modal more compact

* fix: preserve opencode session creation events

OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.

Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.

Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.

* fix: clarify model refresh and onboarding providers

OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.

The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.

Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.

* fix: format opencode model catalog labels

OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.

Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.

This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.

* feat: add more fallback models for cursor

* docs: move model catalog out of shared

The model catalog is no longer a frontend/backend runtime contract.

Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.

Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.

Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.

Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.

* fix: simplify empty-state model selector

Keep the provider empty state focused on the setup action users need there:

choosing a model.

The refresh control, cache timestamp, and refresh explanation made the dialog feel

like a cache-management surface.

That extra action is out of place in the empty state, where the goal is to start

a chat with the selected provider and model.

Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the

now-unused refresh/cache props from the ChatMessagesPane pass-through.

Refresh behavior remains available in the dedicated command result flow.
2026-05-28 10:50:41 +02:00

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