From 22dbfd91c5862c91988cba6cd07eef22e6bf97bf Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 11:47:43 -0500 Subject: [PATCH] refactor(errors): centralize error handling infrastructure - add errors/ directory with error types, handler, cleanup registry - extract claude-spawner.ts from auth modules - update imports across auth, ccs, web-server Phase 2 & 4: Error handling consolidation --- src/auth/auth-commands.ts | 60 +++++------- src/auth/profile-detector.ts | 13 +-- src/auth/profile-registry.ts | 16 +-- src/ccs.ts | 31 +++++- src/errors/cleanup-registry.ts | 151 ++++++++++++++++++++++++++++ src/errors/error-handler.ts | 167 +++++++++++++++++++++++++++++++ src/errors/error-types.ts | 173 +++++++++++++++++++++++++++++++++ src/errors/exit-codes.ts | 79 +++++++++++++++ src/errors/index.ts | 63 ++++++++++++ src/utils/claude-spawner.ts | 139 ++++++++++++++++++++++++++ src/web-server/index.ts | 8 +- 11 files changed, 834 insertions(+), 66 deletions(-) create mode 100644 src/errors/cleanup-registry.ts create mode 100644 src/errors/error-handler.ts create mode 100644 src/errors/error-types.ts create mode 100644 src/errors/exit-codes.ts create mode 100644 src/errors/index.ts create mode 100644 src/utils/claude-spawner.ts diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 69882696..c4abadfd 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -36,8 +36,9 @@ import { getClaudeCliInfo } from '../utils/claude-detector'; import { escapeShellArg } from '../utils/shell-executor'; import { InteractivePrompt } from '../utils/prompt'; import packageJson from '../../package.json'; -import { hasUnifiedConfig } from '../config/unified-config-loader'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; +import { isUnifiedMode } from '../config/unified-config-loader'; +import { exitWithError } from '../errors'; +import { ExitCode } from '../errors/exit-codes'; interface AuthCommandArgs { profileName?: string; @@ -75,13 +76,6 @@ class AuthCommands { this.instanceMgr = new InstanceManager(); } - /** - * Check if unified config mode is active - */ - private isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); - } - /** * Show help for auth commands */ @@ -171,7 +165,7 @@ class AuthCommands { console.log(''); console.log('Example:'); console.log(` ${color('ccs auth create work', 'command')}`); - process.exit(1); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } // Check if profile already exists (check both legacy and unified) @@ -180,7 +174,7 @@ class AuthCommands { if (!force && (existsLegacy || existsUnified)) { console.log(fail(`Profile already exists: ${profileName}`)); console.log(` Use ${color('--force', 'command')} to overwrite`); - process.exit(1); + exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); } try { @@ -189,7 +183,7 @@ class AuthCommands { const instancePath = this.instanceMgr.ensureInstance(profileName); // Create/update profile entry based on config mode - if (this.isUnifiedMode()) { + if (isUnifiedMode()) { // Use unified config (config.yaml) if (existsUnified) { this.registry.touchAccountUnified(profileName); @@ -222,7 +216,7 @@ class AuthCommands { console.log(''); console.log('Please install Claude CLI first:'); console.log(` ${color('https://claude.ai/download', 'path')}`); - process.exit(1); + exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); } const { path: claudeCli, needsShell } = claudeInfo; @@ -279,17 +273,18 @@ class AuthCommands { console.log('To retry:'); console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`); console.log(''); - process.exit(1); + exitWithError('Login failed or cancelled', ExitCode.AUTH_ERROR); } }); child.on('error', (err: Error) => { - console.log(fail(`Failed to execute Claude CLI: ${err.message}`)); - process.exit(1); + exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR); }); } catch (error) { - console.log(fail(`Failed to create profile: ${(error as Error).message}`)); - process.exit(1); + exitWithError( + `Failed to create profile: ${(error as Error).message}`, + ExitCode.GENERAL_ERROR + ); } } @@ -437,8 +432,7 @@ class AuthCommands { console.log(dim(`Total: ${profileNames.length} profile(s)`)); console.log(''); } catch (error) { - console.log(fail(`Failed to list profiles: ${(error as Error).message}`)); - process.exit(1); + exitWithError(`Failed to list profiles: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); } } @@ -453,7 +447,7 @@ class AuthCommands { console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`); - process.exit(1); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } try { @@ -510,8 +504,7 @@ class AuthCommands { ); console.log(''); } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); + exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); } } @@ -526,7 +519,7 @@ class AuthCommands { console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); - process.exit(1); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } // Check existence in both legacy and unified @@ -535,7 +528,7 @@ class AuthCommands { if (!existsLegacy && !existsUnified) { console.log(fail(`Profile not found: ${profileName}`)); - process.exit(1); + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); } try { @@ -577,7 +570,7 @@ class AuthCommands { this.instanceMgr.deleteInstance(profileName); // Delete profile from appropriate config - if (this.isUnifiedMode() && existsUnified) { + if (isUnifiedMode() && existsUnified) { this.registry.removeAccountUnified(profileName); } if (existsLegacy) { @@ -587,8 +580,10 @@ class AuthCommands { console.log(ok(`Profile removed: ${profileName}`)); console.log(''); } catch (error) { - console.log(fail(`Failed to remove profile: ${(error as Error).message}`)); - process.exit(1); + exitWithError( + `Failed to remove profile: ${(error as Error).message}`, + ExitCode.GENERAL_ERROR + ); } } @@ -603,12 +598,12 @@ class AuthCommands { console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth default ', 'command')}`); - process.exit(1); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } try { // Use unified or legacy based on config mode - if (this.isUnifiedMode()) { + if (isUnifiedMode()) { this.registry.setDefaultUnified(profileName); } else { this.registry.setDefaultProfile(profileName); @@ -622,8 +617,7 @@ class AuthCommands { ); console.log(''); } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); + exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); } } @@ -635,7 +629,7 @@ class AuthCommands { try { // Use unified or legacy based on config mode - if (this.isUnifiedMode()) { + if (isUnifiedMode()) { this.registry.clearDefaultUnified(); } else { this.registry.clearDefaultProfile(); diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index cefa4fbb..ee2ffafd 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -15,9 +15,8 @@ import * as os from 'os'; import { findSimilarStrings } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; -import { hasUnifiedConfig, loadUnifiedConfig } from '../config/unified-config-loader'; +import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; import { getProfileSecrets } from '../config/secrets-manager'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; @@ -79,20 +78,12 @@ class ProfileDetector { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } - /** - * Check if unified config mode is active. - * Returns true if config.yaml exists or CCS_UNIFIED_CONFIG=1. - */ - private isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); - } - /** * Load unified config if available. * Returns null if not in unified mode or load fails. */ private readUnifiedConfig(): UnifiedConfig | null { - if (!this.isUnifiedMode()) return null; + if (!isUnifiedMode()) return null; return loadUnifiedConfig(); } diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index e3571d8e..e0013e9c 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -3,11 +3,10 @@ import * as path from 'path'; import * as os from 'os'; import { ProfileMetadata } from '../types'; import { - hasUnifiedConfig, loadOrCreateUnifiedConfig, saveUnifiedConfig, + isUnifiedMode, } from '../config/unified-config-loader'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; /** * Profile Registry (Simplified) @@ -47,13 +46,6 @@ export class ProfileRegistry { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } - /** - * Check if unified config mode is active - */ - private isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); - } - /** * Read profiles from disk */ @@ -306,7 +298,7 @@ export class ProfileRegistry { * Check if account exists in unified config */ hasAccountUnified(name: string): boolean { - if (!this.isUnifiedMode()) return false; + if (!isUnifiedMode()) return false; const config = loadOrCreateUnifiedConfig(); return !!config.accounts[name]; } @@ -315,7 +307,7 @@ export class ProfileRegistry { * Get all accounts from unified config */ getAllAccountsUnified(): Record { - if (!this.isUnifiedMode()) return {}; + if (!isUnifiedMode()) return {}; const config = loadOrCreateUnifiedConfig(); return config.accounts; } @@ -324,7 +316,7 @@ export class ProfileRegistry { * Get default from unified config */ getDefaultUnified(): string | undefined { - if (!this.isUnifiedMode()) return undefined; + if (!isUnifiedMode()) return undefined; const config = loadOrCreateUnifiedConfig(); return config.default; } diff --git a/src/ccs.ts b/src/ccs.ts index 5fbd8faf..8b37d138 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -13,6 +13,9 @@ import { } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; +// Import centralized error handling +import { handleError, runCleanup } from './errors'; + // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; import { handleHelpCommand } from './commands/help-command'; @@ -566,8 +569,28 @@ async function main(): Promise { } } -// Run main -main().catch((error) => { - console.error('Fatal error:', error.message); - process.exit(1); +// ========== Global Error Handlers ========== + +// Handle uncaught exceptions +process.on('uncaughtException', (error: Error) => { + handleError(error); }); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason: unknown) => { + handleError(reason); +}); + +// Handle process termination signals for cleanup +process.on('SIGTERM', () => { + runCleanup(); + process.exit(0); +}); + +process.on('SIGINT', () => { + runCleanup(); + process.exit(130); // 128 + SIGINT(2) +}); + +// Run main +main().catch(handleError); diff --git a/src/errors/cleanup-registry.ts b/src/errors/cleanup-registry.ts new file mode 100644 index 00000000..151eaf4d --- /dev/null +++ b/src/errors/cleanup-registry.ts @@ -0,0 +1,151 @@ +/** + * Cleanup registry for CCS CLI + * + * Manages cleanup callbacks that should be run on exit or error. + * Used to clean up resources like: + * - Spawned processes (proxies, child processes) + * - Temporary files + * - Network connections + * - Open file handles + */ + +/** + * Cleanup callback type + * Callbacks should be synchronous and non-throwing + */ +export type CleanupCallback = () => void; + +/** + * Registry of cleanup callbacks + * Executed in LIFO order (last registered = first executed) + */ +const cleanupCallbacks: CleanupCallback[] = []; + +/** + * Flag to prevent double execution + */ +let cleanupRan = false; + +/** + * Register a cleanup callback + * Callbacks are executed in LIFO order (stack-like behavior) + * + * @param fn - Cleanup function to register + * @returns Unregister function to remove the callback + */ +export function registerCleanup(fn: CleanupCallback): () => void { + cleanupCallbacks.push(fn); + + // Return unregister function + return () => { + const index = cleanupCallbacks.indexOf(fn); + if (index !== -1) { + cleanupCallbacks.splice(index, 1); + } + }; +} + +/** + * Run all registered cleanup callbacks + * Executes in LIFO order, catches and logs individual errors + * Can only be run once per process + */ +export function runCleanup(): void { + if (cleanupRan) { + return; + } + cleanupRan = true; + + const isDebug = process.env['CCS_DEBUG'] === '1' || process.env['CCS_DEBUG'] === 'true'; + + // Execute in reverse order (LIFO) + while (cleanupCallbacks.length > 0) { + const callback = cleanupCallbacks.pop(); + if (callback) { + try { + callback(); + } catch (error) { + // Log cleanup errors in debug mode but don't throw + if (isDebug) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[!] Cleanup error: ${message}`); + } + } + } + } +} + +/** + * Clear all registered cleanup callbacks + * Primarily used for testing + */ +export function clearCleanup(): void { + cleanupCallbacks.length = 0; + cleanupRan = false; +} + +/** + * Get the number of registered cleanup callbacks + * Primarily used for testing + */ +export function getCleanupCount(): number { + return cleanupCallbacks.length; +} + +/** + * Check if cleanup has already run + * Primarily used for testing + */ +export function hasCleanupRun(): boolean { + return cleanupRan; +} + +/** + * Create a cleanup scope for automatic resource management + * Resources registered within the scope are cleaned up when done + * + * @example + * ```typescript + * const scope = createCleanupScope(); + * scope.register(() => process.kill()); + * try { + * // ... do work + * } finally { + * scope.cleanup(); + * } + * ``` + */ +export function createCleanupScope(): { + register: (fn: CleanupCallback) => void; + cleanup: () => void; +} { + const scopeCallbacks: CleanupCallback[] = []; + const unregisters: Array<() => void> = []; + + return { + register: (fn: CleanupCallback) => { + scopeCallbacks.push(fn); + // Also register with global cleanup in case of unexpected exit + const unregister = registerCleanup(fn); + unregisters.push(unregister); + }, + cleanup: () => { + // Unregister from global cleanup first + for (const unregister of unregisters) { + unregister(); + } + + // Execute scope callbacks in LIFO order + while (scopeCallbacks.length > 0) { + const callback = scopeCallbacks.pop(); + if (callback) { + try { + callback(); + } catch (_error) { + // Silently ignore scope cleanup errors + } + } + } + }, + }; +} diff --git a/src/errors/error-handler.ts b/src/errors/error-handler.ts new file mode 100644 index 00000000..a5adf988 --- /dev/null +++ b/src/errors/error-handler.ts @@ -0,0 +1,167 @@ +/** + * Centralized error handler for CCS CLI + * + * Provides unified error handling with: + * - Consistent error formatting + * - Exit code management + * - Cleanup callback execution + * - Debug mode support + */ + +import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes'; +import { isCCSError } from './error-types'; +import { runCleanup } from './cleanup-registry'; + +/** + * Debug mode flag - set via CCS_DEBUG environment variable + */ +const isDebugMode = (): boolean => { + return process.env['CCS_DEBUG'] === '1' || process.env['CCS_DEBUG'] === 'true'; +}; + +/** + * Format error message for display + * Uses ASCII-only formatting per codebase standards (no emojis) + */ +function formatErrorMessage(error: unknown): string { + if (isCCSError(error)) { + return `[X] ${error.message}`; + } + + if (error instanceof Error) { + return `[X] ${error.message}`; + } + + if (typeof error === 'string') { + return `[X] ${error}`; + } + + return '[X] An unexpected error occurred'; +} + +/** + * Get exit code from error + * CCSError types have their own codes, others default to GENERAL_ERROR + */ +function getExitCode(error: unknown): ExitCode { + if (isCCSError(error)) { + return error.code; + } + return ExitCode.GENERAL_ERROR; +} + +/** + * Log debug information for an error + * Only outputs when CCS_DEBUG is enabled + */ +function logDebugInfo(error: unknown, code: ExitCode): void { + if (!isDebugMode()) return; + + console.error(''); + console.error('[i] Debug information:'); + console.error(` Exit code: ${code} (${EXIT_CODE_DESCRIPTIONS[code] || 'Unknown'})`); + + if (error instanceof Error) { + console.error(` Error type: ${error.constructor.name}`); + if (error.stack) { + console.error(' Stack trace:'); + const stackLines = error.stack.split('\n').slice(1, 6); + for (const line of stackLines) { + console.error(` ${line.trim()}`); + } + } + } + + // Log additional properties for CCSError types + if (isCCSError(error)) { + console.error(` Recoverable: ${error.recoverable}`); + } +} + +/** + * Central error handler + * Formats error, runs cleanup, and exits with appropriate code + * + * @param error - The error to handle + * @returns never - Always exits the process + */ +export function handleError(error: unknown): never { + // Run cleanup callbacks first + runCleanup(); + + const code = getExitCode(error); + const message = formatErrorMessage(error); + + // Output error message to stderr + console.error(message); + + // Log debug info if enabled + logDebugInfo(error, code); + + // Exit with appropriate code + process.exit(code); +} + +/** + * Exit with an error message and code + * Convenience function for simple error exits + * + * @param message - Error message to display + * @param code - Exit code (defaults to GENERAL_ERROR) + * @returns never - Always exits the process + */ +export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never { + runCleanup(); + console.error(`[X] ${message}`); + + if (isDebugMode()) { + console.error(''); + console.error(`[i] Exit code: ${code} (${EXIT_CODE_DESCRIPTIONS[code] || 'Unknown'})`); + } + + process.exit(code); +} + +/** + * Exit with success + * Runs cleanup and exits with SUCCESS code + * + * @param message - Optional success message to display + * @returns never - Always exits the process + */ +export function exitWithSuccess(message?: string): never { + runCleanup(); + if (message) { + console.log(`[OK] ${message}`); + } + process.exit(ExitCode.SUCCESS); +} + +/** + * Create a wrapped error handler for async operations + * Wraps an async function to catch and handle errors + */ +export function withErrorHandling( + fn: (...args: T) => Promise +): (...args: T) => Promise { + return async (...args: T): Promise => { + try { + await fn(...args); + } catch (error) { + handleError(error); + } + }; +} + +/** + * Assert a condition, throwing a CCSError if false + */ +export function assertOrExit( + condition: boolean, + message: string, + code: ExitCode = ExitCode.GENERAL_ERROR +): asserts condition { + if (!condition) { + exitWithError(message, code); + } +} diff --git a/src/errors/error-types.ts b/src/errors/error-types.ts new file mode 100644 index 00000000..92cf2cd7 --- /dev/null +++ b/src/errors/error-types.ts @@ -0,0 +1,173 @@ +/** + * Custom error types for CCS CLI + * + * All custom errors extend CCSError which provides: + * - Standardized exit codes + * - Recoverable flag for retry logic + * - Consistent error formatting + */ + +import { ExitCode } from './exit-codes'; + +/** + * Base error class for all CCS errors + * Extends standard Error with exit code and recovery information + */ +export class CCSError extends Error { + constructor( + message: string, + public readonly code: ExitCode = ExitCode.GENERAL_ERROR, + public readonly recoverable: boolean = false + ) { + super(message); + this.name = 'CCSError'; + // Maintain proper stack trace in V8 environments + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * Configuration-related errors + * Examples: missing config file, invalid JSON, corrupt settings + */ +export class ConfigError extends CCSError { + constructor( + message: string, + public readonly configPath?: string + ) { + super(message, ExitCode.CONFIG_ERROR, false); + this.name = 'ConfigError'; + } +} + +/** + * Network-related errors + * Examples: connection refused, timeout, DNS resolution failure + */ +export class NetworkError extends CCSError { + constructor( + message: string, + public readonly url?: string, + public readonly statusCode?: number + ) { + super(message, ExitCode.NETWORK_ERROR, true); // Network errors are typically recoverable + this.name = 'NetworkError'; + } +} + +/** + * Authentication/authorization errors + * Examples: invalid API key, expired token, insufficient permissions + */ +export class AuthError extends CCSError { + constructor( + message: string, + public readonly provider?: string + ) { + super(message, ExitCode.AUTH_ERROR, false); + this.name = 'AuthError'; + } +} + +/** + * Binary/executable errors + * Examples: Claude CLI not found, corrupted binary, permission denied + */ +export class BinaryError extends CCSError { + constructor( + message: string, + public readonly binaryPath?: string + ) { + super(message, ExitCode.BINARY_ERROR, false); + this.name = 'BinaryError'; + } +} + +/** + * Provider-specific errors + * Examples: API rate limit, service unavailable, invalid model + */ +export class ProviderError extends CCSError { + constructor( + message: string, + public readonly provider: string, + public readonly details?: unknown + ) { + super(message, ExitCode.PROVIDER_ERROR, true); // Provider errors may be recoverable + this.name = 'ProviderError'; + } +} + +/** + * Profile-related errors + * Examples: profile not found, invalid profile name, duplicate profile + */ +export class ProfileError extends CCSError { + constructor( + message: string, + public readonly profileName?: string, + public readonly availableProfiles?: string[] + ) { + super(message, ExitCode.PROFILE_ERROR, false); + this.name = 'ProfileError'; + } +} + +/** + * Proxy-related errors + * Examples: proxy startup failure, port conflict, proxy timeout + */ +export class ProxyError extends CCSError { + constructor( + message: string, + public readonly port?: number + ) { + super(message, ExitCode.PROXY_ERROR, false); + this.name = 'ProxyError'; + } +} + +/** + * Migration-related errors + * Examples: failed to migrate config, backup creation failed + */ +export class MigrationError extends CCSError { + constructor( + message: string, + public readonly fromVersion?: string, + public readonly toVersion?: string + ) { + super(message, ExitCode.MIGRATION_ERROR, false); + this.name = 'MigrationError'; + } +} + +/** + * User abort error (Ctrl+C, SIGINT) + * Used when user explicitly cancels an operation + */ +export class UserAbortError extends CCSError { + constructor(message: string = 'Operation cancelled by user') { + super(message, ExitCode.USER_ABORT, false); + this.name = 'UserAbortError'; + } +} + +/** + * Type guard to check if an error is a CCSError + */ +export function isCCSError(error: unknown): error is CCSError { + return error instanceof CCSError; +} + +/** + * Type guard to check if an error is recoverable + */ +export function isRecoverableError(error: unknown): boolean { + if (isCCSError(error)) { + return error.recoverable; + } + return false; +} diff --git a/src/errors/exit-codes.ts b/src/errors/exit-codes.ts new file mode 100644 index 00000000..a1b450cf --- /dev/null +++ b/src/errors/exit-codes.ts @@ -0,0 +1,79 @@ +/** + * Standardized exit codes for CCS CLI + * + * Exit codes follow Unix conventions: + * - 0: Success + * - 1-125: Application errors + * - 126-127: Command execution errors (reserved by shell) + * - 128+N: Signal termination (128 + signal number) + * - 130: SIGINT (Ctrl+C) - 128 + 2 + */ + +export enum ExitCode { + /** Successful execution */ + SUCCESS = 0, + + /** General/unspecified error */ + GENERAL_ERROR = 1, + + /** Configuration file errors (missing, invalid, corrupt) */ + CONFIG_ERROR = 2, + + /** Network-related errors (connection, timeout, DNS) */ + NETWORK_ERROR = 3, + + /** Authentication/authorization errors (invalid token, expired, forbidden) */ + AUTH_ERROR = 4, + + /** Binary/executable errors (missing Claude CLI, corrupted binary) */ + BINARY_ERROR = 5, + + /** Provider-specific errors (API errors, rate limits, service unavailable) */ + PROVIDER_ERROR = 6, + + /** Profile not found or invalid */ + PROFILE_ERROR = 7, + + /** Proxy-related errors (startup failure, port conflict) */ + PROXY_ERROR = 8, + + /** Migration errors (failed to migrate config) */ + MIGRATION_ERROR = 9, + + /** User aborted operation (Ctrl+C, SIGINT) */ + USER_ABORT = 130, +} + +/** + * Human-readable descriptions for exit codes + * Used in error messages and documentation + */ +export const EXIT_CODE_DESCRIPTIONS: Record = { + [ExitCode.SUCCESS]: 'Success', + [ExitCode.GENERAL_ERROR]: 'General error', + [ExitCode.CONFIG_ERROR]: 'Configuration error', + [ExitCode.NETWORK_ERROR]: 'Network error', + [ExitCode.AUTH_ERROR]: 'Authentication error', + [ExitCode.BINARY_ERROR]: 'Binary/executable error', + [ExitCode.PROVIDER_ERROR]: 'Provider error', + [ExitCode.PROFILE_ERROR]: 'Profile error', + [ExitCode.PROXY_ERROR]: 'Proxy error', + [ExitCode.MIGRATION_ERROR]: 'Migration error', + [ExitCode.USER_ABORT]: 'User abort (Ctrl+C)', +}; + +/** + * Check if an exit code indicates success + */ +export function isSuccess(code: ExitCode | number): boolean { + return code === ExitCode.SUCCESS; +} + +/** + * Check if an exit code indicates a recoverable error + * (errors that might succeed on retry) + */ +export function isRecoverable(code: ExitCode | number): boolean { + const recoverableCodes = [ExitCode.NETWORK_ERROR, ExitCode.PROVIDER_ERROR]; + return recoverableCodes.includes(code as ExitCode); +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 00000000..40d2cd4b --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,63 @@ +/** + * CCS Error Handling Module + * + * Centralized error handling system for CCS CLI providing: + * - Standardized exit codes + * - Custom error types with exit code mapping + * - Centralized error handler with cleanup + * - Cleanup callback registry + * + * @example + * ```typescript + * import { handleError, ConfigError, ExitCode, registerCleanup } from './errors'; + * + * // Register cleanup for spawned process + * registerCleanup(() => proxy.kill()); + * + * // Throw typed error + * throw new ConfigError('Invalid config file', '~/.ccs/config.json'); + * + * // Or handle directly + * handleError(new NetworkError('Connection refused')); + * ``` + */ + +// Exit codes +export { ExitCode, EXIT_CODE_DESCRIPTIONS, isSuccess, isRecoverable } from './exit-codes'; + +// Error types +export { + CCSError, + ConfigError, + NetworkError, + AuthError, + BinaryError, + ProviderError, + ProfileError, + ProxyError, + MigrationError, + UserAbortError, + isCCSError, + isRecoverableError, +} from './error-types'; + +// Error handler +export { + handleError, + exitWithError, + exitWithSuccess, + withErrorHandling, + assertOrExit, +} from './error-handler'; + +// Cleanup registry +export { + registerCleanup, + runCleanup, + clearCleanup, + getCleanupCount, + hasCleanupRun, + createCleanupScope, +} from './cleanup-registry'; + +export type { CleanupCallback } from './cleanup-registry'; diff --git a/src/utils/claude-spawner.ts b/src/utils/claude-spawner.ts new file mode 100644 index 00000000..e6e169bd --- /dev/null +++ b/src/utils/claude-spawner.ts @@ -0,0 +1,139 @@ +/** + * Claude Spawner Utilities + * + * Cross-platform Claude CLI spawn utilities for CCS. + * Handles Windows .cmd/.bat/.ps1 files properly. + */ + +import { spawn, ChildProcess, SpawnOptions } from 'child_process'; +import { escapeShellArg } from './shell-executor'; +import { getClaudeCliInfo } from './claude-detector'; +import { ErrorManager } from './error-manager'; + +export interface SpawnClaudeOptions { + /** Arguments to pass to Claude CLI */ + args?: string[]; + /** Environment variables to merge with process.env */ + env?: NodeJS.ProcessEnv; + /** Working directory */ + cwd?: string; + /** Stdio configuration (default: 'inherit') */ + stdio?: SpawnOptions['stdio']; +} + +export interface SpawnClaudeResult { + /** The spawned child process */ + child: ChildProcess; + /** Path to the Claude CLI executable */ + claudePath: string; +} + +/** + * Spawn Claude CLI with cross-platform support. + * + * Handles Windows .cmd/.bat/.ps1 wrappers automatically. + * Returns the ChildProcess for custom event handling. + * + * @throws Error if Claude CLI is not found + */ +export function spawnClaude(options: SpawnClaudeOptions = {}): SpawnClaudeResult { + const claudeInfo = getClaudeCliInfo(); + if (!claudeInfo) { + throw new Error('Claude CLI not found'); + } + + const { path: claudeCli, needsShell } = claudeInfo; + const { args = [], env, cwd, stdio = 'inherit' } = options; + + // Merge environment + const mergedEnv = env ? { ...process.env, ...env } : process.env; + + let child: ChildProcess; + if (needsShell) { + // Windows .cmd/.bat/.ps1: concatenate into string to avoid DEP0190 warning + const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { + stdio, + windowsHide: true, + shell: true, + env: mergedEnv, + cwd, + }); + } else { + // Unix or Windows native: use array form (faster, no shell overhead) + child = spawn(claudeCli, args, { + stdio, + windowsHide: true, + env: mergedEnv, + cwd, + }); + } + + return { child, claudePath: claudeCli }; +} + +/** + * Spawn Claude CLI and wait for exit. + * + * Convenience function that returns a promise resolving to the exit code. + */ +export function spawnClaudeSync(options: SpawnClaudeOptions = {}): Promise { + return new Promise((resolve, reject) => { + try { + const { child } = spawnClaude(options); + + child.on('exit', (code) => { + resolve(code ?? 0); + }); + + child.on('error', (error) => { + reject(error); + }); + } catch (error) { + reject(error); + } + }); +} + +/** + * Spawn Claude CLI with automatic exit handling. + * + * Exits the current process when Claude exits, preserving exit code/signal. + * Used for simple pass-through scenarios. + */ +export function execClaudeWithExitHandling(options: SpawnClaudeOptions = {}): void { + let result: SpawnClaudeResult; + + try { + result = spawnClaude(options); + } catch { + // Claude not found - show error and exit + void ErrorManager.showClaudeNotFound(); + process.exit(1); + } + + const { child } = result; + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal as NodeJS.Signals); + } else { + process.exit(code ?? 0); + } + }); + + child.on('error', async () => { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + }); +} + +/** + * Check if shell execution is needed for a given path. + * + * Returns true for Windows .cmd/.bat/.ps1 files. + */ +export function needsShellExecution(executablePath: string): boolean { + const isWindows = process.platform === 'win32'; + return isWindows && /\.(cmd|bat|ps1)$/i.test(executablePath); +} diff --git a/src/web-server/index.ts b/src/web-server/index.ts index a4de7f80..c5d4cfb2 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -35,8 +35,8 @@ export async function startServer(options: ServerOptions): Promise