| |
| |
| |
| |
| |
| |
| |
|
|
| import express from 'express'; |
| import cors from 'cors'; |
| import morgan from 'morgan'; |
| import cookieParser from 'cookie-parser'; |
| import cookie from 'cookie'; |
| import { WebSocketServer, WebSocket } from 'ws'; |
| import { createServer } from 'http'; |
| import dotenv from 'dotenv'; |
| import path from 'path'; |
| import { fileURLToPath } from 'url'; |
|
|
| const __filename = fileURLToPath(import.meta.url); |
| const __dirname = path.dirname(__filename); |
|
|
| import { createEventEmitter, type EventEmitter } from './lib/events.js'; |
| import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform'; |
| import { createLogger, setLogLevel, LogLevel } from '@automaker/utils'; |
|
|
| const logger = createLogger('Server'); |
|
|
| |
| |
| |
| const LOG_LEVEL_MAP: Record<string, LogLevel> = { |
| error: LogLevel.ERROR, |
| warn: LogLevel.WARN, |
| info: LogLevel.INFO, |
| debug: LogLevel.DEBUG, |
| }; |
| import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js'; |
| import { requireJsonContentType } from './middleware/require-json-content-type.js'; |
| import { createAuthRoutes } from './routes/auth/index.js'; |
| import { createFsRoutes } from './routes/fs/index.js'; |
| import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js'; |
| import { createAgentRoutes } from './routes/agent/index.js'; |
| import { createSessionsRoutes } from './routes/sessions/index.js'; |
| import { createFeaturesRoutes } from './routes/features/index.js'; |
| import { createAutoModeRoutes } from './routes/auto-mode/index.js'; |
| import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js'; |
| import { createWorktreeRoutes } from './routes/worktree/index.js'; |
| import { createGitRoutes } from './routes/git/index.js'; |
| import { createSetupRoutes } from './routes/setup/index.js'; |
| import { createModelsRoutes } from './routes/models/index.js'; |
| import { createRunningAgentsRoutes } from './routes/running-agents/index.js'; |
| import { createWorkspaceRoutes } from './routes/workspace/index.js'; |
| import { createTemplatesRoutes } from './routes/templates/index.js'; |
| import { |
| createTerminalRoutes, |
| validateTerminalToken, |
| isTerminalEnabled, |
| isTerminalPasswordRequired, |
| } from './routes/terminal/index.js'; |
| import { createSettingsRoutes } from './routes/settings/index.js'; |
| import { AgentService } from './services/agent-service.js'; |
| import { FeatureLoader } from './services/feature-loader.js'; |
| import { AutoModeServiceCompat } from './services/auto-mode/index.js'; |
| import { getTerminalService } from './services/terminal-service.js'; |
| import { SettingsService } from './services/settings-service.js'; |
| import { createSpecRegenerationRoutes } from './routes/app-spec/index.js'; |
| import { createClaudeRoutes } from './routes/claude/index.js'; |
| import { ClaudeUsageService } from './services/claude-usage-service.js'; |
| import { createCodexRoutes } from './routes/codex/index.js'; |
| import { CodexUsageService } from './services/codex-usage-service.js'; |
| import { CodexAppServerService } from './services/codex-app-server-service.js'; |
| import { CodexModelCacheService } from './services/codex-model-cache-service.js'; |
| import { createZaiRoutes } from './routes/zai/index.js'; |
| import { ZaiUsageService } from './services/zai-usage-service.js'; |
| import { createGeminiRoutes } from './routes/gemini/index.js'; |
| import { GeminiUsageService } from './services/gemini-usage-service.js'; |
| import { createGitHubRoutes } from './routes/github/index.js'; |
| import { createContextRoutes } from './routes/context/index.js'; |
| import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; |
| import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; |
| import { createMCPRoutes } from './routes/mcp/index.js'; |
| import { MCPTestService } from './services/mcp-test-service.js'; |
| import { createPipelineRoutes } from './routes/pipeline/index.js'; |
| import { pipelineService } from './services/pipeline-service.js'; |
| import { createIdeationRoutes } from './routes/ideation/index.js'; |
| import { IdeationService } from './services/ideation-service.js'; |
| import { getDevServerService } from './services/dev-server-service.js'; |
| import { eventHookService } from './services/event-hook-service.js'; |
| import { createNotificationsRoutes } from './routes/notifications/index.js'; |
| import { getNotificationService } from './services/notification-service.js'; |
| import { createEventHistoryRoutes } from './routes/event-history/index.js'; |
| import { getEventHistoryService } from './services/event-history-service.js'; |
| import { getTestRunnerService } from './services/test-runner-service.js'; |
| import { createProjectsRoutes } from './routes/projects/index.js'; |
|
|
| |
| dotenv.config(); |
|
|
| const PORT = parseInt(process.env.PORT || '3008', 10); |
| const HOST = process.env.HOST || '0.0.0.0'; |
| const HOSTNAME = process.env.HOSTNAME || 'localhost'; |
| const DATA_DIR = process.env.DATA_DIR || './data'; |
| logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR); |
| logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR); |
| logger.info('[SERVER_STARTUP] process.cwd():', process.cwd()); |
| const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; |
|
|
| |
| let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT; |
|
|
| |
| |
| |
| export function setRequestLoggingEnabled(enabled: boolean): void { |
| requestLoggingEnabled = enabled; |
| } |
|
|
| |
| |
| |
| export function isRequestLoggingEnabled(): boolean { |
| return requestLoggingEnabled; |
| } |
|
|
| |
| const BOX_CONTENT_WIDTH = 67; |
|
|
| |
| |
| (async () => { |
| const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; |
| const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; |
|
|
| logger.debug('[CREDENTIAL_CHECK] Starting credential detection...'); |
| logger.debug('[CREDENTIAL_CHECK] Environment variables:', { |
| hasAnthropicKey, |
| hasEnvOAuthToken, |
| }); |
|
|
| if (hasAnthropicKey) { |
| logger.info('β ANTHROPIC_API_KEY detected'); |
| return; |
| } |
|
|
| if (hasEnvOAuthToken) { |
| logger.info('β CLAUDE_CODE_OAUTH_TOKEN detected'); |
| return; |
| } |
|
|
| |
| |
| let cliAuthIndicators: Awaited<ReturnType<typeof getClaudeAuthIndicators>> | null = null; |
|
|
| try { |
| cliAuthIndicators = await getClaudeAuthIndicators(); |
| const indicators = cliAuthIndicators; |
|
|
| |
| const { checks, ...indicatorSummary } = indicators; |
| logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', indicatorSummary); |
|
|
| logger.debug('[CREDENTIAL_CHECK] File check details:', checks); |
|
|
| const hasCliAuth = |
| indicators.hasStatsCacheWithActivity || |
| (indicators.hasSettingsFile && indicators.hasProjectsSessions) || |
| (indicators.hasCredentialsFile && |
| (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey)); |
|
|
| logger.debug('[CREDENTIAL_CHECK] Auth determination:', { |
| hasCliAuth, |
| reason: hasCliAuth |
| ? indicators.hasStatsCacheWithActivity |
| ? 'stats cache with activity' |
| : indicators.hasSettingsFile && indicators.hasProjectsSessions |
| ? 'settings file + project sessions' |
| : indicators.credentials?.hasOAuthToken |
| ? 'credentials file with OAuth token' |
| : 'credentials file with API key' |
| : 'no valid credentials found', |
| }); |
|
|
| if (hasCliAuth) { |
| logger.info('β Claude Code CLI authentication detected'); |
| return; |
| } |
| } catch (error) { |
| |
| logger.warn('Error checking for Claude Code CLI authentication:', error); |
| } |
|
|
| |
| const wHeader = 'β οΈ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH); |
| const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH); |
| const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH); |
| const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd( |
| BOX_CONTENT_WIDTH |
| ); |
| const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH); |
| const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH); |
| const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd( |
| BOX_CONTENT_WIDTH |
| ); |
|
|
| |
| let pathsCheckedInfo = ''; |
| if (cliAuthIndicators) { |
| const pathsChecked: string[] = []; |
|
|
| |
| pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`); |
| pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`); |
| pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`); |
| for (const credFile of cliAuthIndicators.checks.credentialFiles) { |
| pathsChecked.push(`Credentials: ${credFile.path}`); |
| } |
|
|
| if (pathsChecked.length > 0) { |
| pathsCheckedInfo = ` |
| β β |
| β ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}β |
| ${pathsChecked |
| .map((p) => { |
| const maxLen = BOX_CONTENT_WIDTH - 4; |
| const display = p.length > maxLen ? '...' + p.slice(-(maxLen - 3)) : p; |
| return `β ${display.padEnd(maxLen)} β`; |
| }) |
| .join('\n')}`; |
| } |
| } |
|
|
| logger.warn(` |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β ${wHeader}β |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ |
| β β |
| β ${w1}β |
| β β |
| β ${w2}β |
| β ${w3}β |
| β ${w4}β |
| β ${w5}β |
| β ${w6}β${pathsCheckedInfo} |
| β β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| `); |
| })(); |
|
|
| |
| initAllowedPaths(); |
|
|
| |
| const app = express(); |
|
|
| |
| app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); |
| app.get('/api-docs', (_req, res) => res.status(200).json({ |
| message: 'Automaker API Documentation', |
| endpoints: [ |
| { method: 'GET', path: '/health', purpose: 'Health Check' }, |
| { method: 'GET', path: '/api-docs', purpose: 'API Documentation' }, |
| { method: 'POST', path: '/api/auth/login', purpose: 'Login' }, |
| { method: 'GET', path: '/api/projects', purpose: 'List projects' }, |
| { method: 'GET', path: '/api/agent/chat', purpose: 'Chat with agent' }, |
| { method: 'POST', path: '/api/features', purpose: 'Create feature' }, |
| { method: 'GET', path: '/api/settings', purpose: 'Get settings' } |
| ] |
| })); |
|
|
| |
| |
| morgan.token('status-colored', (_req, res) => { |
| const status = res.statusCode; |
| if (status >= 500) return `\x1b[31m${status}\x1b[0m`; |
| if (status >= 400) return `\x1b[33m${status}\x1b[0m`; |
| if (status >= 300) return `\x1b[36m${status}\x1b[0m`; |
| return `\x1b[32m${status}\x1b[0m`; |
| }); |
|
|
| app.use( |
| morgan(':method :url :status-colored', { |
| |
| skip: (req) => |
| !requestLoggingEnabled || |
| req.url === '/api/health' || |
| req.url === '/api/auto-mode/context-exists', |
| }) |
| ); |
| |
| |
| |
|
|
| |
| function isLocalOrigin(origin: string): boolean { |
| try { |
| const url = new URL(origin); |
| const hostname = url.hostname; |
| return ( |
| hostname === 'localhost' || |
| hostname === '127.0.0.1' || |
| hostname === '[::1]' || |
| hostname === '0.0.0.0' || |
| hostname.startsWith('192.168.') || |
| hostname.startsWith('10.') || |
| /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) || |
| hostname.endsWith('.hf.space') || |
| hostname.endsWith('.huggingface.co') |
| ); |
| } catch { |
| return false; |
| } |
| } |
|
|
| app.use( |
| cors({ |
| origin: (origin, callback) => { |
| |
| if (!origin) { |
| callback(null, true); |
| return; |
| } |
|
|
| |
| const allowedOrigins = process.env.CORS_ORIGIN?.split(',') |
| .map((o) => o.trim()) |
| .filter(Boolean); |
| if (allowedOrigins && allowedOrigins.length > 0) { |
| if (allowedOrigins.includes('*')) { |
| callback(null, true); |
| return; |
| } |
| if (allowedOrigins.includes(origin)) { |
| callback(null, origin); |
| return; |
| } |
| |
| } |
|
|
| |
| if (isLocalOrigin(origin)) { |
| callback(null, origin); |
| return; |
| } |
|
|
| |
| callback(new Error('Not allowed by CORS')); |
| }, |
| credentials: true, |
| }) |
| ); |
| app.use(express.json({ limit: '50mb' })); |
| app.use(cookieParser()); |
|
|
| |
| const events: EventEmitter = createEventEmitter(); |
|
|
| |
| |
| const settingsService = new SettingsService(DATA_DIR); |
| const agentService = new AgentService(DATA_DIR, events, settingsService); |
| const featureLoader = new FeatureLoader(); |
|
|
| |
| const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader); |
| const claudeUsageService = new ClaudeUsageService(); |
| const codexAppServerService = new CodexAppServerService(); |
| const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); |
| const codexUsageService = new CodexUsageService(codexAppServerService); |
| const zaiUsageService = new ZaiUsageService(); |
| const geminiUsageService = new GeminiUsageService(); |
| const mcpTestService = new MCPTestService(settingsService); |
| const ideationService = new IdeationService(events, settingsService, featureLoader); |
|
|
| |
| const devServerService = getDevServerService(); |
| devServerService.initialize(DATA_DIR, events).catch((err) => { |
| logger.error('Failed to initialize DevServerService:', err); |
| }); |
|
|
| |
| const notificationService = getNotificationService(); |
| notificationService.setEventEmitter(events); |
|
|
| |
| const eventHistoryService = getEventHistoryService(); |
|
|
| |
| const testRunnerService = getTestRunnerService(); |
| testRunnerService.setEventEmitter(events); |
|
|
| |
| eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader); |
|
|
| |
| (async () => { |
| |
| |
| |
| |
| try { |
| const migrationResult = await settingsService.migrateFromLegacyElectronPath(); |
| if (migrationResult.migrated) { |
| logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`); |
| logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`); |
| } |
| if (migrationResult.errors.length > 0) { |
| logger.warn('Migration errors:', migrationResult.errors); |
| } |
| } catch (err) { |
| logger.warn('Failed to check for legacy settings migration:', err); |
| } |
|
|
| |
| let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null; |
| try { |
| globalSettings = await settingsService.getGlobalSettings(); |
| } catch { |
| logger.warn('Failed to load global settings, using defaults'); |
| } |
|
|
| |
| if (globalSettings) { |
| try { |
| if ( |
| globalSettings.serverLogLevel && |
| LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined |
| ) { |
| setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]); |
| logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`); |
| } |
| |
| const enableRequestLog = globalSettings.enableRequestLogging ?? true; |
| setRequestLoggingEnabled(enableRequestLog); |
| logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); |
| } catch { |
| logger.warn('Failed to apply logging settings, using defaults'); |
| } |
| } |
|
|
| await agentService.initialize(); |
| logger.info('Agent service initialized'); |
|
|
| |
| |
| |
| |
| if (globalSettings) { |
| try { |
| if (globalSettings.projects && globalSettings.projects.length > 0) { |
| let totalReconciled = 0; |
| for (const project of globalSettings.projects) { |
| const count = await autoModeService.reconcileFeatureStates(project.path); |
| totalReconciled += count; |
| } |
| if (totalReconciled > 0) { |
| logger.info( |
| `[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)` |
| ); |
| } else { |
| logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); |
| } |
|
|
| |
| |
| |
| for (const project of globalSettings.projects) { |
| autoModeService.resumeInterruptedFeatures(project.path).catch((err) => { |
| logger.warn( |
| `[STARTUP] Failed to resume interrupted features for ${project.path}:`, |
| err |
| ); |
| }); |
| } |
| logger.info('[STARTUP] Initiated background resume of interrupted features'); |
| } |
| } catch (err) { |
| logger.warn('[STARTUP] Failed to reconcile feature states:', err); |
| } |
| } |
|
|
| |
| void codexModelCacheService.getModels().catch((err) => { |
| logger.error('Failed to bootstrap Codex model cache:', err); |
| }); |
| })(); |
|
|
| |
| const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; |
| setInterval(() => { |
| const cleaned = cleanupStaleValidations(); |
| if (cleaned > 0) { |
| logger.info(`Cleaned up ${cleaned} stale validation entries`); |
| } |
| }, VALIDATION_CLEANUP_INTERVAL_MS); |
|
|
| |
| |
| app.use('/api', requireJsonContentType); |
|
|
| |
| const UI_DIST_PATH = path.resolve(__dirname, '../../../apps/ui/dist'); |
| app.use(express.static(UI_DIST_PATH)); |
|
|
|
|
| |
| app.use('/api/health', createHealthRoutes()); |
| app.use('/api/auth', createAuthRoutes()); |
| app.use('/api/setup', createSetupRoutes()); |
|
|
| |
| app.use('/api', authMiddleware); |
|
|
| |
| app.get('/api/health/detailed', createDetailedHandler()); |
|
|
| app.use('/api/fs', createFsRoutes(events)); |
| app.use('/api/agent', createAgentRoutes(agentService, events)); |
| app.use('/api/sessions', createSessionsRoutes(agentService)); |
| app.use( |
| '/api/features', |
| createFeaturesRoutes(featureLoader, settingsService, events, autoModeService) |
| ); |
| app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); |
| app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); |
| app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader)); |
| app.use('/api/git', createGitRoutes()); |
| app.use('/api/models', createModelsRoutes()); |
| app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); |
| app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService)); |
| app.use('/api/workspace', createWorkspaceRoutes()); |
| app.use('/api/templates', createTemplatesRoutes()); |
| app.use('/api/terminal', createTerminalRoutes()); |
| app.use('/api/settings', createSettingsRoutes(settingsService)); |
| app.use('/api/claude', createClaudeRoutes(claudeUsageService)); |
| app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); |
| app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); |
| app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events)); |
| app.use('/api/github', createGitHubRoutes(events, settingsService)); |
| app.use('/api/context', createContextRoutes(settingsService)); |
| app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); |
| app.use('/api/mcp', createMCPRoutes(mcpTestService)); |
| app.use('/api/pipeline', createPipelineRoutes(pipelineService)); |
| app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); |
| app.use('/api/notifications', createNotificationsRoutes(notificationService)); |
| app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService)); |
| app.use( |
| '/api/projects', |
| createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService) |
| ); |
|
|
| |
| |
| app.use((req, res, next) => { |
| |
| if (req.path.startsWith('/api/') || req.path === '/health' || req.path === '/api-docs' || path.extname(req.path)) { |
| return next(); |
| } |
| res.sendFile(path.join(UI_DIST_PATH, 'index.html')); |
| }); |
|
|
| |
| const server = createServer(app); |
|
|
| |
| const wss = new WebSocketServer({ noServer: true }); |
| const terminalWss = new WebSocketServer({ noServer: true }); |
| const terminalService = getTerminalService(settingsService); |
|
|
| |
| |
| |
| |
| function authenticateWebSocket(request: import('http').IncomingMessage): boolean { |
| const url = new URL(request.url || '', `http://${request.headers.host}`); |
|
|
| |
| const query: Record<string, string | undefined> = {}; |
| url.searchParams.forEach((value, key) => { |
| query[key] = value; |
| }); |
|
|
| |
| const cookieHeader = request.headers.cookie; |
| const cookies = cookieHeader ? cookie.parse(cookieHeader) : {}; |
|
|
| |
| if ( |
| checkRawAuthentication( |
| request.headers as Record<string, string | string[] | undefined>, |
| query, |
| cookies |
| ) |
| ) { |
| return true; |
| } |
|
|
| |
| const wsToken = url.searchParams.get('wsToken'); |
| if (wsToken && validateWsConnectionToken(wsToken)) { |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| |
| server.on('upgrade', (request, socket, head) => { |
| const { pathname } = new URL(request.url || '', `http://${request.headers.host}`); |
|
|
| |
| if (!authenticateWebSocket(request)) { |
| logger.info('Authentication failed, rejecting connection'); |
| socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); |
| socket.destroy(); |
| return; |
| } |
|
|
| if (pathname === '/api/events') { |
| wss.handleUpgrade(request, socket, head, (ws) => { |
| wss.emit('connection', ws, request); |
| }); |
| } else if (pathname === '/api/terminal/ws') { |
| terminalWss.handleUpgrade(request, socket, head, (ws) => { |
| terminalWss.emit('connection', ws, request); |
| }); |
| } else { |
| socket.destroy(); |
| } |
| }); |
|
|
| |
| wss.on('connection', (ws: WebSocket) => { |
| logger.info('Client connected, ready state:', ws.readyState); |
|
|
| |
| const unsubscribe = events.subscribe((type, payload) => { |
| |
| |
| const isHighFrequency = |
| type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress'; |
| const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger); |
|
|
| log('Event received:', { |
| type, |
| hasPayload: !!payload, |
| wsReadyState: ws.readyState, |
| }); |
|
|
| if (ws.readyState === WebSocket.OPEN) { |
| const message = JSON.stringify({ type, payload }); |
| ws.send(message); |
| } else { |
| logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState); |
| } |
| }); |
|
|
| ws.on('close', () => { |
| logger.info('Client disconnected'); |
| unsubscribe(); |
| }); |
|
|
| ws.on('error', (error) => { |
| logger.error('ERROR:', error); |
| unsubscribe(); |
| }); |
| }); |
|
|
| |
| const terminalConnections: Map<string, Set<WebSocket>> = new Map(); |
| |
| const lastResizeDimensions: Map<string, { cols: number; rows: number }> = new Map(); |
| |
| const lastResizeTime: Map<string, number> = new Map(); |
| const RESIZE_MIN_INTERVAL_MS = 100; |
|
|
| |
| terminalService.onExit((sessionId) => { |
| lastResizeDimensions.delete(sessionId); |
| lastResizeTime.delete(sessionId); |
| terminalConnections.delete(sessionId); |
| }); |
|
|
| |
| terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage) => { |
| |
| const url = new URL(req.url || '', `http://${req.headers.host}`); |
| const sessionId = url.searchParams.get('sessionId'); |
| const token = url.searchParams.get('token'); |
|
|
| logger.info(`Connection attempt for session: ${sessionId}`); |
|
|
| |
| if (!isTerminalEnabled()) { |
| logger.info('Terminal is disabled'); |
| ws.close(4003, 'Terminal access is disabled'); |
| return; |
| } |
|
|
| |
| if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) { |
| logger.info('Invalid or missing token'); |
| ws.close(4001, 'Authentication required'); |
| return; |
| } |
|
|
| if (!sessionId) { |
| logger.info('No session ID provided'); |
| ws.close(4002, 'Session ID required'); |
| return; |
| } |
|
|
| |
| const session = terminalService.getSession(sessionId); |
| if (!session) { |
| logger.warn( |
| `Terminal session ${sessionId} not found. ` + |
| `The session may have exited, been deleted, or was never created. ` + |
| `Active terminal sessions: ${terminalService.getSessionCount()}` |
| ); |
| ws.close( |
| 4004, |
| 'Session not found. The terminal session may have expired or been closed. Please create a new terminal.' |
| ); |
| return; |
| } |
|
|
| logger.info(`Client connected to session ${sessionId}`); |
|
|
| |
| if (!terminalConnections.has(sessionId)) { |
| terminalConnections.set(sessionId, new Set()); |
| } |
| terminalConnections.get(sessionId)!.add(ws); |
|
|
| |
| ws.send( |
| JSON.stringify({ |
| type: 'connected', |
| sessionId, |
| shell: session.shell, |
| cwd: session.cwd, |
| }) |
| ); |
|
|
| |
| |
| const scrollback = terminalService.getScrollbackAndClearPending(sessionId); |
| if (scrollback && scrollback.length > 0) { |
| ws.send( |
| JSON.stringify({ |
| type: 'scrollback', |
| data: scrollback, |
| }) |
| ); |
| } |
|
|
| |
| const unsubscribeData = terminalService.onData((sid, data) => { |
| if (sid === sessionId && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'data', data })); |
| } |
| }); |
|
|
| |
| const unsubscribeExit = terminalService.onExit((sid, exitCode) => { |
| if (sid === sessionId && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'exit', exitCode })); |
| ws.close(1000, 'Session ended'); |
| } |
| }); |
|
|
| |
| ws.on('message', (message) => { |
| try { |
| const msg = JSON.parse(message.toString()); |
|
|
| switch (msg.type) { |
| case 'input': |
| |
| if (typeof msg.data !== 'string') { |
| ws.send(JSON.stringify({ type: 'error', message: 'Invalid input type' })); |
| break; |
| } |
| |
| if (msg.data.length > 1024 * 1024) { |
| ws.send(JSON.stringify({ type: 'error', message: 'Input too large' })); |
| break; |
| } |
| |
| terminalService.write(sessionId, msg.data); |
| break; |
|
|
| case 'resize': |
| |
| if ( |
| typeof msg.cols !== 'number' || |
| typeof msg.rows !== 'number' || |
| !Number.isInteger(msg.cols) || |
| !Number.isInteger(msg.rows) || |
| msg.cols < 1 || |
| msg.cols > 1000 || |
| msg.rows < 1 || |
| msg.rows > 500 |
| ) { |
| break; |
| } |
| |
| if (msg.cols && msg.rows) { |
| const now = Date.now(); |
| const lastTime = lastResizeTime.get(sessionId) || 0; |
| const lastDimensions = lastResizeDimensions.get(sessionId); |
|
|
| |
| if (now - lastTime < RESIZE_MIN_INTERVAL_MS) { |
| break; |
| } |
|
|
| |
| if ( |
| !lastDimensions || |
| lastDimensions.cols !== msg.cols || |
| lastDimensions.rows !== msg.rows |
| ) { |
| |
| |
| const isFirstResize = !lastDimensions; |
| terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize); |
| lastResizeDimensions.set(sessionId, { |
| cols: msg.cols, |
| rows: msg.rows, |
| }); |
| lastResizeTime.set(sessionId, now); |
| } |
| } |
| break; |
|
|
| case 'ping': |
| |
| ws.send(JSON.stringify({ type: 'pong' })); |
| break; |
|
|
| default: |
| logger.warn(`Unknown message type: ${msg.type}`); |
| } |
| } catch (error) { |
| logger.error('Error processing message:', error); |
| } |
| }); |
|
|
| ws.on('close', () => { |
| logger.info(`Client disconnected from session ${sessionId}`); |
| unsubscribeData(); |
| unsubscribeExit(); |
|
|
| |
| const connections = terminalConnections.get(sessionId); |
| if (connections) { |
| connections.delete(ws); |
| if (connections.size === 0) { |
| terminalConnections.delete(sessionId); |
| |
| |
| |
| |
| } |
| } |
| }); |
|
|
| ws.on('error', (error) => { |
| logger.error(`Error on session ${sessionId}:`, error); |
| unsubscribeData(); |
| unsubscribeExit(); |
| }); |
| }); |
|
|
| |
| const startServer = (port: number, host: string) => { |
| server.listen(port, host, () => { |
| const terminalStatus = isTerminalEnabled() |
| ? isTerminalPasswordRequired() |
| ? 'enabled (password protected)' |
| : 'enabled' |
| : 'disabled'; |
|
|
| |
| const listenAddr = `${host}:${port}`; |
| const httpUrl = `http://${HOSTNAME}:${port}`; |
| const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`; |
| const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`; |
| const healthUrl = `http://${HOSTNAME}:${port}/api/health`; |
|
|
| const sHeader = 'π Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH); |
| const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH); |
| const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH); |
| const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH); |
| const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH); |
| const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH); |
| const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH); |
|
|
| logger.info(` |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β ${sHeader}β |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ |
| β β |
| β ${s1}β |
| β ${s2}β |
| β ${s3}β |
| β ${s4}β |
| β ${s5}β |
| β ${s6}β |
| β β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| `); |
| }); |
|
|
| server.on('error', (error: NodeJS.ErrnoException) => { |
| if (error.code === 'EADDRINUSE') { |
| const portStr = port.toString(); |
| const nextPortStr = (port + 1).toString(); |
| const killCmd = `lsof -ti:${portStr} | xargs kill -9`; |
| const altCmd = `PORT=${nextPortStr} npm run dev:server`; |
|
|
| const eHeader = `β ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH); |
| const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH); |
| const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH); |
| const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH); |
| const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH); |
| const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH); |
| const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH); |
| const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH); |
| const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH); |
|
|
| logger.error(` |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β ${eHeader}β |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ |
| β β |
| β ${e1}β |
| β β |
| β ${e2}β |
| β β |
| β ${e3}β |
| β ${e4}β |
| β β |
| β ${e5}β |
| β ${e6}β |
| β β |
| β ${e7}β |
| β ${e8}β |
| β β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| `); |
| process.exit(1); |
| } else { |
| logger.error('Error starting server:', error); |
| process.exit(1); |
| } |
| }); |
| }; |
|
|
| startServer(PORT, HOST); |
|
|
| |
| process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => { |
| logger.error('Unhandled Promise Rejection:', { |
| reason: reason instanceof Error ? reason.message : String(reason), |
| stack: reason instanceof Error ? reason.stack : undefined, |
| }); |
| |
| |
| }); |
|
|
| process.on('uncaughtException', (error: Error) => { |
| logger.error('Uncaught Exception:', { |
| message: error.message, |
| stack: error.stack, |
| }); |
| |
| |
| process.exit(1); |
| }); |
|
|
| |
| const SHUTDOWN_TIMEOUT_MS = 30000; |
|
|
| |
| const gracefulShutdown = async (signal: string) => { |
| logger.info(`${signal} received, shutting down...`); |
|
|
| |
| const forceExitTimeout = setTimeout(() => { |
| logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`); |
| process.exit(1); |
| }, SHUTDOWN_TIMEOUT_MS); |
|
|
| |
| |
| |
| await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`); |
|
|
| terminalService.cleanup(); |
| server.close(() => { |
| clearTimeout(forceExitTimeout); |
| logger.info('Server closed'); |
| process.exit(0); |
| }); |
| }; |
|
|
| process.on('SIGTERM', () => { |
| gracefulShutdown('SIGTERM'); |
| }); |
|
|
| process.on('SIGINT', () => { |
| gracefulShutdown('SIGINT'); |
| }); |
|
|