mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 02:23:38 +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 { escapeShellArg } from '../utils/shell-executor';
|
||||||
import { InteractivePrompt } from '../utils/prompt';
|
import { InteractivePrompt } from '../utils/prompt';
|
||||||
import packageJson from '../../package.json';
|
import packageJson from '../../package.json';
|
||||||
import { hasUnifiedConfig } from '../config/unified-config-loader';
|
import { isUnifiedMode } from '../config/unified-config-loader';
|
||||||
import { isUnifiedConfigEnabled } from '../config/feature-flags';
|
import { exitWithError } from '../errors';
|
||||||
|
import { ExitCode } from '../errors/exit-codes';
|
||||||
|
|
||||||
interface AuthCommandArgs {
|
interface AuthCommandArgs {
|
||||||
profileName?: string;
|
profileName?: string;
|
||||||
@@ -75,13 +76,6 @@ class AuthCommands {
|
|||||||
this.instanceMgr = new InstanceManager();
|
this.instanceMgr = new InstanceManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if unified config mode is active
|
|
||||||
*/
|
|
||||||
private isUnifiedMode(): boolean {
|
|
||||||
return hasUnifiedConfig() || isUnifiedConfigEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show help for auth commands
|
* Show help for auth commands
|
||||||
*/
|
*/
|
||||||
@@ -171,7 +165,7 @@ class AuthCommands {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('Example:');
|
console.log('Example:');
|
||||||
console.log(` ${color('ccs auth create work', 'command')}`);
|
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)
|
// Check if profile already exists (check both legacy and unified)
|
||||||
@@ -180,7 +174,7 @@ class AuthCommands {
|
|||||||
if (!force && (existsLegacy || existsUnified)) {
|
if (!force && (existsLegacy || existsUnified)) {
|
||||||
console.log(fail(`Profile already exists: ${profileName}`));
|
console.log(fail(`Profile already exists: ${profileName}`));
|
||||||
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
||||||
process.exit(1);
|
exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -189,7 +183,7 @@ class AuthCommands {
|
|||||||
const instancePath = this.instanceMgr.ensureInstance(profileName);
|
const instancePath = this.instanceMgr.ensureInstance(profileName);
|
||||||
|
|
||||||
// Create/update profile entry based on config mode
|
// Create/update profile entry based on config mode
|
||||||
if (this.isUnifiedMode()) {
|
if (isUnifiedMode()) {
|
||||||
// Use unified config (config.yaml)
|
// Use unified config (config.yaml)
|
||||||
if (existsUnified) {
|
if (existsUnified) {
|
||||||
this.registry.touchAccountUnified(profileName);
|
this.registry.touchAccountUnified(profileName);
|
||||||
@@ -222,7 +216,7 @@ class AuthCommands {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('Please install Claude CLI first:');
|
console.log('Please install Claude CLI first:');
|
||||||
console.log(` ${color('https://claude.ai/download', 'path')}`);
|
console.log(` ${color('https://claude.ai/download', 'path')}`);
|
||||||
process.exit(1);
|
exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path: claudeCli, needsShell } = claudeInfo;
|
const { path: claudeCli, needsShell } = claudeInfo;
|
||||||
@@ -279,17 +273,18 @@ class AuthCommands {
|
|||||||
console.log('To retry:');
|
console.log('To retry:');
|
||||||
console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`);
|
console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
process.exit(1);
|
exitWithError('Login failed or cancelled', ExitCode.AUTH_ERROR);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err: Error) => {
|
child.on('error', (err: Error) => {
|
||||||
console.log(fail(`Failed to execute Claude CLI: ${err.message}`));
|
exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR);
|
||||||
process.exit(1);
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(fail(`Failed to create profile: ${(error as Error).message}`));
|
exitWithError(
|
||||||
process.exit(1);
|
`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(dim(`Total: ${profileNames.length} profile(s)`));
|
||||||
console.log('');
|
console.log('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(fail(`Failed to list profiles: ${(error as Error).message}`));
|
exitWithError(`Failed to list profiles: ${(error as Error).message}`, ExitCode.GENERAL_ERROR);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +447,7 @@ class AuthCommands {
|
|||||||
console.log(fail('Profile name is required'));
|
console.log(fail('Profile name is required'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`Usage: ${color('ccs auth show <profile> [--json]', 'command')}`);
|
console.log(`Usage: ${color('ccs auth show <profile> [--json]', 'command')}`);
|
||||||
process.exit(1);
|
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -510,8 +504,7 @@ class AuthCommands {
|
|||||||
);
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(fail((error as Error).message));
|
exitWithError((error as Error).message, ExitCode.PROFILE_ERROR);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +519,7 @@ class AuthCommands {
|
|||||||
console.log(fail('Profile name is required'));
|
console.log(fail('Profile name is required'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`Usage: ${color('ccs auth remove <profile> [--yes]', 'command')}`);
|
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
|
// Check existence in both legacy and unified
|
||||||
@@ -535,7 +528,7 @@ class AuthCommands {
|
|||||||
|
|
||||||
if (!existsLegacy && !existsUnified) {
|
if (!existsLegacy && !existsUnified) {
|
||||||
console.log(fail(`Profile not found: ${profileName}`));
|
console.log(fail(`Profile not found: ${profileName}`));
|
||||||
process.exit(1);
|
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -577,7 +570,7 @@ class AuthCommands {
|
|||||||
this.instanceMgr.deleteInstance(profileName);
|
this.instanceMgr.deleteInstance(profileName);
|
||||||
|
|
||||||
// Delete profile from appropriate config
|
// Delete profile from appropriate config
|
||||||
if (this.isUnifiedMode() && existsUnified) {
|
if (isUnifiedMode() && existsUnified) {
|
||||||
this.registry.removeAccountUnified(profileName);
|
this.registry.removeAccountUnified(profileName);
|
||||||
}
|
}
|
||||||
if (existsLegacy) {
|
if (existsLegacy) {
|
||||||
@@ -587,8 +580,10 @@ class AuthCommands {
|
|||||||
console.log(ok(`Profile removed: ${profileName}`));
|
console.log(ok(`Profile removed: ${profileName}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(fail(`Failed to remove profile: ${(error as Error).message}`));
|
exitWithError(
|
||||||
process.exit(1);
|
`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(fail('Profile name is required'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`Usage: ${color('ccs auth default <profile>', 'command')}`);
|
console.log(`Usage: ${color('ccs auth default <profile>', 'command')}`);
|
||||||
process.exit(1);
|
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use unified or legacy based on config mode
|
// Use unified or legacy based on config mode
|
||||||
if (this.isUnifiedMode()) {
|
if (isUnifiedMode()) {
|
||||||
this.registry.setDefaultUnified(profileName);
|
this.registry.setDefaultUnified(profileName);
|
||||||
} else {
|
} else {
|
||||||
this.registry.setDefaultProfile(profileName);
|
this.registry.setDefaultProfile(profileName);
|
||||||
@@ -622,8 +617,7 @@ class AuthCommands {
|
|||||||
);
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(fail((error as Error).message));
|
exitWithError((error as Error).message, ExitCode.PROFILE_ERROR);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +629,7 @@ class AuthCommands {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use unified or legacy based on config mode
|
// Use unified or legacy based on config mode
|
||||||
if (this.isUnifiedMode()) {
|
if (isUnifiedMode()) {
|
||||||
this.registry.clearDefaultUnified();
|
this.registry.clearDefaultUnified();
|
||||||
} else {
|
} else {
|
||||||
this.registry.clearDefaultProfile();
|
this.registry.clearDefaultProfile();
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ import * as os from 'os';
|
|||||||
import { findSimilarStrings } from '../utils/helpers';
|
import { findSimilarStrings } from '../utils/helpers';
|
||||||
import { Config, Settings, ProfileMetadata } from '../types';
|
import { Config, Settings, ProfileMetadata } from '../types';
|
||||||
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-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 { getProfileSecrets } from '../config/secrets-manager';
|
||||||
import { isUnifiedConfigEnabled } from '../config/feature-flags';
|
|
||||||
|
|
||||||
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
|
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
|
||||||
|
|
||||||
@@ -79,20 +78,12 @@ class ProfileDetector {
|
|||||||
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
|
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.
|
* Load unified config if available.
|
||||||
* Returns null if not in unified mode or load fails.
|
* Returns null if not in unified mode or load fails.
|
||||||
*/
|
*/
|
||||||
private readUnifiedConfig(): UnifiedConfig | null {
|
private readUnifiedConfig(): UnifiedConfig | null {
|
||||||
if (!this.isUnifiedMode()) return null;
|
if (!isUnifiedMode()) return null;
|
||||||
return loadUnifiedConfig();
|
return loadUnifiedConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { ProfileMetadata } from '../types';
|
import { ProfileMetadata } from '../types';
|
||||||
import {
|
import {
|
||||||
hasUnifiedConfig,
|
|
||||||
loadOrCreateUnifiedConfig,
|
loadOrCreateUnifiedConfig,
|
||||||
saveUnifiedConfig,
|
saveUnifiedConfig,
|
||||||
|
isUnifiedMode,
|
||||||
} from '../config/unified-config-loader';
|
} from '../config/unified-config-loader';
|
||||||
import { isUnifiedConfigEnabled } from '../config/feature-flags';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile Registry (Simplified)
|
* Profile Registry (Simplified)
|
||||||
@@ -47,13 +46,6 @@ export class ProfileRegistry {
|
|||||||
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
|
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
|
* Read profiles from disk
|
||||||
*/
|
*/
|
||||||
@@ -306,7 +298,7 @@ export class ProfileRegistry {
|
|||||||
* Check if account exists in unified config
|
* Check if account exists in unified config
|
||||||
*/
|
*/
|
||||||
hasAccountUnified(name: string): boolean {
|
hasAccountUnified(name: string): boolean {
|
||||||
if (!this.isUnifiedMode()) return false;
|
if (!isUnifiedMode()) return false;
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return !!config.accounts[name];
|
return !!config.accounts[name];
|
||||||
}
|
}
|
||||||
@@ -315,7 +307,7 @@ export class ProfileRegistry {
|
|||||||
* Get all accounts from unified config
|
* Get all accounts from unified config
|
||||||
*/
|
*/
|
||||||
getAllAccountsUnified(): Record<string, { created: string; last_used: string | null }> {
|
getAllAccountsUnified(): Record<string, { created: string; last_used: string | null }> {
|
||||||
if (!this.isUnifiedMode()) return {};
|
if (!isUnifiedMode()) return {};
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return config.accounts;
|
return config.accounts;
|
||||||
}
|
}
|
||||||
@@ -324,7 +316,7 @@ export class ProfileRegistry {
|
|||||||
* Get default from unified config
|
* Get default from unified config
|
||||||
*/
|
*/
|
||||||
getDefaultUnified(): string | undefined {
|
getDefaultUnified(): string | undefined {
|
||||||
if (!this.isUnifiedMode()) return undefined;
|
if (!isUnifiedMode()) return undefined;
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return config.default;
|
return config.default;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-4
@@ -13,6 +13,9 @@ import {
|
|||||||
} from './utils/websearch-manager';
|
} from './utils/websearch-manager';
|
||||||
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
||||||
|
|
||||||
|
// Import centralized error handling
|
||||||
|
import { handleError, runCleanup } from './errors';
|
||||||
|
|
||||||
// Import extracted command handlers
|
// Import extracted command handlers
|
||||||
import { handleVersionCommand } from './commands/version-command';
|
import { handleVersionCommand } from './commands/version-command';
|
||||||
import { handleHelpCommand } from './commands/help-command';
|
import { handleHelpCommand } from './commands/help-command';
|
||||||
@@ -566,8 +569,28 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run main
|
// ========== Global Error Handlers ==========
|
||||||
main().catch((error) => {
|
|
||||||
console.error('Fatal error:', error.message);
|
// Handle uncaught exceptions
|
||||||
process.exit(1);
|
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
|
// JSON body parsing
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// REST API routes (Phase 03)
|
// REST API routes (modularized)
|
||||||
const { apiRoutes } = await import('./routes');
|
const { apiRoutes } = await import('./routes/index');
|
||||||
app.use('/api', apiRoutes);
|
app.use('/api', apiRoutes);
|
||||||
|
|
||||||
// Shared data routes (Phase 07)
|
// Shared data routes (Phase 07)
|
||||||
@@ -51,10 +51,6 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
const { usageRoutes } = await import('./usage-routes');
|
const { usageRoutes } = await import('./usage-routes');
|
||||||
app.use('/api/usage', usageRoutes);
|
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
|
// Dev mode: use Vite middleware for HMR
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
const { createServer: createViteServer } = await import('vite');
|
const { createServer: createViteServer } = await import('vite');
|
||||||
|
|||||||
Reference in New Issue
Block a user