diff --git a/.gitignore b/.gitignore index 55ba86b2..2904e438 100644 --- a/.gitignore +++ b/.gitignore @@ -91,4 +91,7 @@ yarn.lock # Fork-specific workflow files (should never be committed) DEVELOPMENT_WORKFLOW.md -check-sync.sh \ No newline at end of file +check-sync.sh +# API key files +data/.api-key +data/credentials.json diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index caa4dd6a..59cc6f57 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -597,6 +597,26 @@ const startServer = (port: number) => { startServer(PORT); +// Global error handlers to prevent crashes from uncaught errors +process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { + logger.error('Unhandled Promise Rejection:', { + reason: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + // Don't exit - log the error and continue running + // This prevents the server from crashing due to unhandled rejections +}); + +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception:', { + message: error.message, + stack: error.stack, + }); + // Exit on uncaught exceptions to prevent undefined behavior + // The process is in an unknown state after an uncaught exception + process.exit(1); +}); + // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM received, shutting down...'); diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 098ce29c..64ace35d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import * as os from 'os'; import * as pty from 'node-pty'; import { ClaudeUsage } from '../routes/claude/types.js'; +import { createLogger } from '@automaker/utils'; /** * Claude Usage Service @@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js'; * - macOS: Uses 'expect' command for PTY * - Windows/Linux: Uses node-pty for PTY */ +const logger = createLogger('ClaudeUsage'); + export class ClaudeUsageService { private claudeBinary = 'claude'; private timeout = 30000; // 30 second timeout @@ -164,21 +167,40 @@ export class ClaudeUsageService { const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; - const ptyProcess = pty.spawn(shell, args, { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - } as Record, - }); + let ptyProcess: any = null; + + try { + ptyProcess = pty.spawn(shell, args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }); + } catch (spawnError) { + // pty.spawn() can throw synchronously if the native module fails to load + // or if PTY is not available in the current environment (e.g., containers without /dev/pts) + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + + // Return a user-friendly error instead of crashing + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } const timeoutId = setTimeout(() => { if (!settled) { settled = true; - ptyProcess.kill(); + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); @@ -188,7 +210,7 @@ export class ClaudeUsageService { } }, this.timeout); - ptyProcess.onData((data) => { + ptyProcess.onData((data: string) => { output += data; // Check if we've seen the usage data (look for "Current session") @@ -196,12 +218,12 @@ export class ClaudeUsageService { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.kill('SIGTERM'); } }, 2000); @@ -212,14 +234,14 @@ export class ClaudeUsageService { // Fallback: if we see "Esc to cancel" but haven't seen usage data yet if (!hasSeenUsageData && output.includes('Esc to cancel')) { setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } }, 3000); } }); - ptyProcess.onExit(({ exitCode }) => { + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { clearTimeout(timeoutId); if (settled) return; settled = true; diff --git a/apps/ui/tests/utils/api/client.ts b/apps/ui/tests/utils/api/client.ts index c3f18074..abe6ef2f 100644 --- a/apps/ui/tests/utils/api/client.ts +++ b/apps/ui/tests/utils/api/client.ts @@ -368,3 +368,42 @@ export async function authenticateForTests(page: Page): Promise { const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; return authenticateWithApiKey(page, apiKey); } + +/** + * Check if the backend server is healthy + * Returns true if the server responds with status 200, false otherwise + */ +export async function checkBackendHealth(page: Page, timeout = 5000): Promise { + try { + const response = await page.request.get(`${API_BASE_URL}/api/health`, { + timeout, + }); + return response.ok(); + } catch { + return false; + } +} + +/** + * Wait for the backend to be healthy, with retry logic + * Throws an error if the backend doesn't become healthy within the timeout + */ +export async function waitForBackendHealth( + page: Page, + maxWaitMs = 30000, + checkIntervalMs = 500 +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + if (await checkBackendHealth(page, checkIntervalMs)) { + return; + } + await page.waitForTimeout(checkIntervalMs); + } + + throw new Error( + `Backend did not become healthy within ${maxWaitMs}ms. ` + + `Last health check failed or timed out.` + ); +}