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
This commit is contained in:
kaitranntt
2025-12-19 11:47:43 -05:00
parent 9346ff2be9
commit 22dbfd91c5
11 changed files with 834 additions and 66 deletions
+27 -33
View File
@@ -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 <profile> [--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 <profile> [--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 <profile>', '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();
+2 -11
View File
@@ -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();
}
+4 -12
View File
@@ -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<string, { created: string; last_used: string | null }> {
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;
}
+27 -4
View File
@@ -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<void> {
}
}
// 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);
+151
View File
@@ -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
}
}
}
},
};
}
+167
View File
@@ -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<T extends unknown[]>(
fn: (...args: T) => Promise<void>
): (...args: T) => Promise<void> {
return async (...args: T): Promise<void> => {
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);
}
}
+173
View File
@@ -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;
}
+79
View File
@@ -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, string> = {
[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);
}
+63
View File
@@ -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';
+139
View File
@@ -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<number> {
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);
}
+2 -6
View File
@@ -35,8 +35,8 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// JSON body parsing
app.use(express.json());
// REST API routes (Phase 03)
const { apiRoutes } = await import('./routes');
// REST API routes (modularized)
const { apiRoutes } = await import('./routes/index');
app.use('/api', apiRoutes);
// Shared data routes (Phase 07)
@@ -51,10 +51,6 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { usageRoutes } = await import('./usage-routes');
app.use('/api/usage', usageRoutes);
// CLIProxy server settings routes (Phase 5)
const cliproxyServerRoutes = (await import('./routes/proxy-routes')).default;
app.use('/api/cliproxy-server', cliproxyServerRoutes);
// Dev mode: use Vite middleware for HMR
if (options.dev) {
const { createServer: createViteServer } = await import('vite');