mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
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:
+27
-33
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user