diff --git a/docs/reports/typed-error-exit-code-compat-audit.md b/docs/reports/typed-error-exit-code-compat-audit.md new file mode 100644 index 00000000..9a1af171 --- /dev/null +++ b/docs/reports/typed-error-exit-code-compat-audit.md @@ -0,0 +1,45 @@ +# Typed-Error Exit-Code Compat Audit (P4) + +Date: 2026-06-18. Phase 4 of the maintainability/traceability epic. + +## Question (open Q1) + +Are CCS CLI exit codes a documented public contract that users or CI scripts depend on? This determines whether migrating `throw new Error(...)` to typed errors (which changes the exit code) is safe. + +## Finding + +Typed exit codes are **already wired end-to-end**. `handleError` -> `getExitCode` extracts `error.code` from any `CCSError` and passes it to `process.exit` (`src/errors/error-handler.ts`, `src/ccs.ts:145`). The `ExitCode` enum and the class-to-code mapping in `src/errors/error-types.ts` are complete and pre-date this epic. + +**Only documented public contract:** `ccs doctor` documents exit codes 0 (healthy) / 1 (unhealthy) in `src/commands/doctor-command.ts:55-58`. `ccs doctor` is OUTSIDE the P4 priority subdomains and is NOT touched by P4. Its 0/1 contract is preserved. + +**No CI/scripts assert on ccs exit codes.** `scripts/ci-parity-gate.sh` uses `set -euo pipefail` but performs no `ccs` exit-code branching. `.github/workflows/*` perform no ccs exit-code assertions. Prior exit-code changes in CHANGELOG are treated as bugfixes; no documented breaking changes. + +## Decision + +**Migrate freely** in the P4 priority subdomains (`cliproxy/quota`, `cliproxy/auth`, `web-server/routes`, `auth`). Preserve `GENERAL_ERROR(1)` only where no clear subclass applies. Behavior-lock tests assert the new typed codes. + +## Exit-code mapping (the contract this audit locks) + +| Typed class | ExitCode | Value | Used for (P4 sites) | +|---|---|---:|---| +| `ProfileError` | `PROFILE_ERROR` | 7 | profile/account/variant not found, already exists | +| `AuthError` | `AUTH_ERROR` | 4 | OAuth/token/Kiro/GitLab auth flow failures, refresh ownership | +| `ConfigError` | `CONFIG_ERROR` | 2 | settings/config structure, path, not-initialized, read/write profiles | +| `ValidationError` | `GENERAL_ERROR` | 1 | input format validation (no exit-code shift) | +| `ProviderError` | `PROVIDER_ERROR` | 6 | unsupported provider backend | +| `NetworkError` | `NETWORK_ERROR` | 3 | (recoverable) | +| `ProxyError` | `PROXY_ERROR` | 8 | | +| `MigrationError` | `MIGRATION_ERROR` | 9 | | + +## Per-site decisions + +See the P4 commit for the full site list. Summary by subclass chosen: +- `ProfileError`: profile/account not-found + already-exists (`src/auth/profile-registry.ts`, `src/cliproxy/auth/auth-token-manager.ts`, `src/cliproxy/auth/auth-types.ts`). +- `AuthError`: OAuth start failed, paste-callback unavailable, Kiro auth method unsupported, token refresh ownership (`src/cliproxy/auth/oauth-handler.ts`, `provider-refreshers/index.ts`). +- `ConfigError`: invalid settings path, settings not found, CLIProxy config not initialized, GitLab URL format, failed read/write profiles, copilot sync failure (`src/web-server/routes/*`, `src/cliproxy/auth/oauth-handler.ts`, `src/auth/profile-registry.ts`). +- `ValidationError`: invalid profile name, invalid target, invalid host, Kiro IDC start-url (`src/web-server/routes/*`, `src/cliproxy/auth/auth-types.ts`). +- `ProviderError`: unsupported provider backend (`src/web-server/routes/image-analysis-routes.ts`). + +## Outcome + +Typed-error adoption in the locked subdomains: 0/23 -> 21/23 (91.3%), well above the 40% target. Exit-code changes are intentional and documented here; release notes for the epic PR should mention the differentiated exit codes. diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 50f43107..b910b298 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -13,6 +13,7 @@ import { mutateConfig, } from '../config/config-loader-facade'; import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy'; +import { ConfigError, ProfileError } from '../errors/error-types'; const logger = createLogger('auth:profile-registry'); @@ -132,7 +133,7 @@ export class ProfileRegistry { return JSON.parse(data) as ProfileData; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Failed to read profiles: ${message}`); + throw new ConfigError(`Failed to read profiles: ${message}`, this.profilesPath); } } @@ -159,7 +160,7 @@ export class ProfileRegistry { fs.unlinkSync(tempPath); } const message = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Failed to write profiles: ${message}`); + throw new ConfigError(`Failed to write profiles: ${message}`, this.profilesPath); } } @@ -170,7 +171,7 @@ export class ProfileRegistry { const data = this._read(); if (data.profiles[name]) { - throw new Error(`Profile already exists: ${name}`); + throw new ProfileError(`Profile already exists: ${name}`, name); } // v3.0 minimal schema: only essential fields @@ -203,7 +204,7 @@ export class ProfileRegistry { const data = this._read(); if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); + throw new ProfileError(`Profile not found: ${name}`, name); } return this.normalizeLegacyProfileMetadata(data.profiles[name]); @@ -216,7 +217,7 @@ export class ProfileRegistry { const data = this._read(); if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); + throw new ProfileError(`Profile not found: ${name}`, name); } data.profiles[name] = this.normalizeLegacyProfileMetadata({ @@ -234,7 +235,7 @@ export class ProfileRegistry { const data = this._read(); if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); + throw new ProfileError(`Profile not found: ${name}`, name); } delete data.profiles[name]; @@ -287,7 +288,7 @@ export class ProfileRegistry { const data = this._read(); if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); + throw new ProfileError(`Profile not found: ${name}`, name); } data.default = name; @@ -330,7 +331,7 @@ export class ProfileRegistry { createAccountUnified(name: string, metadata: CreateMetadata = {}): void { mutateConfig((config) => { if (config.accounts[name]) { - throw new Error(`Account already exists: ${name}`); + throw new ProfileError(`Account already exists: ${name}`, name); } config.accounts[name] = this.normalizeUnifiedAccountConfig({ created: new Date().toISOString(), @@ -350,7 +351,7 @@ export class ProfileRegistry { updateAccountUnified(name: string, updates: Partial): void { mutateConfig((config) => { if (!config.accounts[name]) { - throw new Error(`Account not found: ${name}`); + throw new ProfileError(`Account not found: ${name}`, name); } config.accounts[name] = this.normalizeUnifiedAccountConfig({ ...config.accounts[name], @@ -365,7 +366,7 @@ export class ProfileRegistry { removeAccountUnified(name: string): void { mutateConfig((config) => { if (!config.accounts[name]) { - throw new Error(`Account not found: ${name}`); + throw new ProfileError(`Account not found: ${name}`, name); } delete config.accounts[name]; if (config.default === name) { @@ -382,7 +383,7 @@ export class ProfileRegistry { const exists = config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; if (!exists) { - throw new Error(`Profile not found: ${name}`); + throw new ProfileError(`Profile not found: ${name}`, name); } config.default = name; }); @@ -434,7 +435,7 @@ export class ProfileRegistry { touchAccountUnified(name: string): void { mutateConfig((config) => { if (!config.accounts[name]) { - throw new Error(`Account not found: ${name}`); + throw new ProfileError(`Account not found: ${name}`, name); } config.accounts[name].last_used = new Date().toISOString(); config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]); diff --git a/src/cliproxy/auth/auth-token-manager.ts b/src/cliproxy/auth/auth-token-manager.ts index 06cac201..afcca979 100644 --- a/src/cliproxy/auth/auth-token-manager.ts +++ b/src/cliproxy/auth/auth-token-manager.ts @@ -11,6 +11,7 @@ import { randomBytes } from 'crypto'; import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from '../config/generator'; import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; +import { ProfileError } from '../../errors/error-types'; /** * Generate a cryptographically secure token. @@ -133,7 +134,7 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined const variant = config.cliproxy.variants[variantName]; if (!variant) { - throw new Error(`Variant '${variantName}' not found`); + throw new ProfileError(`Variant '${variantName}' not found`, variantName); } if (!variant.auth) { diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 41661cce..ee44afa1 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -5,6 +5,7 @@ */ import { CLIProxyProvider } from '../types'; +import { ProfileError, ValidationError } from '../../errors/error-types'; import type { AccountInfo } from '../accounts/account-manager'; import { buildProviderMap, @@ -109,7 +110,7 @@ export function getKiroCLIAuthArgs( const startUrl = options?.idcStartUrl?.trim(); if (!startUrl) { - throw new Error('Kiro IDC login requires --kiro-idc-start-url'); + throw new ValidationError('Kiro IDC login requires --kiro-idc-start-url', 'kiroIDCStartUrl'); } const args = [getKiroCLIAuthFlag('idc'), '--kiro-idc-start-url', startUrl]; @@ -382,7 +383,7 @@ export function getManagementOAuthCallbackPath(): string { export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig { const config = OAUTH_CONFIGS[provider]; if (!config) { - throw new Error(`Unknown provider: ${provider}`); + throw new ProfileError(`Unknown provider: ${provider}`, provider); } return config; } diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 3fe7375d..1ce30b65 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -16,6 +16,7 @@ import { fail, info, warn, color, ok } from '../../utils/ui'; import { createLogger } from '../../services/logging'; import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager'; import { generateConfig } from '../config/config-generator'; +import { AuthError, ConfigError } from '../../errors/error-types'; import { CLIProxyBackend, CLIProxyProvider } from '../types'; import { AccountInfo, @@ -247,8 +248,9 @@ export async function requestPasteCallbackStart( kiroMethod: options?.kiroMethod, }); if (!startPath) { - throw new Error( - `Paste-callback start is not available for ${provider} with the selected method` + throw new AuthError( + `Paste-callback start is not available for ${provider} with the selected method`, + provider ); } const normalizedGitLabBaseUrl = @@ -261,7 +263,7 @@ export async function requestPasteCallbackStart( }); if (!response.ok) { - throw new Error(`OAuth start failed with status ${response.status}`); + throw new AuthError(`OAuth start failed with status ${response.status}`, provider); } return (await response.json()) as PasteCallbackStartData; @@ -315,11 +317,11 @@ export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | un try { parsed = new URL(normalized); } catch { - throw new Error('GitLab URL must be a valid http:// or https:// URL'); + throw new ConfigError('GitLab URL must be a valid http:// or https:// URL'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new Error('GitLab URL must use http:// or https://'); + throw new ConfigError('GitLab URL must use http:// or https://'); } parsed.hash = ''; @@ -619,7 +621,7 @@ function buildOAuthArgs( if (provider === 'kiro') { const method = normalizeKiroAuthMethod(options.kiroMethod); if (!isKiroCLIAuthMethod(method)) { - throw new Error(`Kiro auth method '${method}' is not supported by CLI flow.`); + throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro'); } args.push( ...getKiroCLIAuthArgs(method, { diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index ea85348a..23ed4916 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -15,6 +15,7 @@ import { getTokenRefreshOwnership, isRefreshDelegatedToCLIProxy, } from '../../provider-capabilities'; +import { AuthError } from '../../../errors/error-types'; /** Token refresh result */ export interface ProviderRefreshResult { @@ -26,7 +27,7 @@ export interface ProviderRefreshResult { } function assertNever(value: never): never { - throw new Error(`Unhandled token refresh ownership: ${String(value)}`); + throw new AuthError(`Unhandled token refresh ownership: ${String(value)}`); } /** diff --git a/src/errors/__tests__/typed-error-migration-exit-codes.test.ts b/src/errors/__tests__/typed-error-migration-exit-codes.test.ts new file mode 100644 index 00000000..cd7f8646 --- /dev/null +++ b/src/errors/__tests__/typed-error-migration-exit-codes.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test'; +import { + AuthError, + BinaryError, + CCSError, + ConfigError, + isCCSError, + MigrationError, + NetworkError, + ProfileError, + ProviderError, + ProxyError, + RetryableError, + UserAbortError, + ValidationError, +} from '../error-types'; +import { ExitCode } from '../exit-codes'; + +/** + * P4 behavior-lock: the typed-error -> exit-code mapping is the contract this + * epic relies on. Migrating `throw new Error` to typed subclasses changes the + * process exit code (via handleError -> getExitCode); these tests lock the + * mapping so a future change is caught. See + * docs/reports/typed-error-exit-code-compat-audit.md. + */ +describe('typed-error taxonomy -> exit-code mapping (P4 contract)', () => { + test('each typed class carries its documented ExitCode', () => { + expect(new ConfigError('m').code).toBe(ExitCode.CONFIG_ERROR); + expect(new NetworkError('m').code).toBe(ExitCode.NETWORK_ERROR); + expect(new AuthError('m').code).toBe(ExitCode.AUTH_ERROR); + expect(new BinaryError('m').code).toBe(ExitCode.BINARY_ERROR); + expect(new ProviderError('m', 'p').code).toBe(ExitCode.PROVIDER_ERROR); + expect(new ProfileError('m').code).toBe(ExitCode.PROFILE_ERROR); + expect(new ProxyError('m').code).toBe(ExitCode.PROXY_ERROR); + expect(new MigrationError('m').code).toBe(ExitCode.MIGRATION_ERROR); + expect(new UserAbortError().code).toBe(ExitCode.USER_ABORT); + // These two intentionally keep GENERAL_ERROR (no shift for callers). + expect(new ValidationError('m').code).toBe(ExitCode.GENERAL_ERROR); + expect(new RetryableError('m').code).toBe(ExitCode.GENERAL_ERROR); + }); + + test('all typed errors are CCSError and Error (instanceof chains preserved)', () => { + const samples = [ + new ConfigError('m'), + new AuthError('m'), + new ProfileError('m'), + new ProviderError('m', 'p'), + new ValidationError('m'), + ]; + for (const e of samples) { + expect(e).toBeInstanceOf(CCSError); + expect(e).toBeInstanceOf(Error); + expect(isCCSError(e)).toBe(true); + } + }); + + test('plain Error is NOT a CCSError (migration boundary)', () => { + expect(isCCSError(new Error('plain'))).toBe(false); + }); + + test('typed errors preserve their message (message-based assertions are stable)', () => { + expect(new ProfileError(`Profile not found: x`).message).toBe('Profile not found: x'); + expect(new AuthError(`OAuth start failed with status 400`).message).toBe( + 'OAuth start failed with status 400' + ); + expect(new ConfigError(`Invalid settings path`).message).toBe('Invalid settings path'); + }); + + test('structured context is carried (profileName / provider / configPath)', () => { + expect(new ProfileError('m', 'my-profile').profileName).toBe('my-profile'); + expect(new AuthError('m', 'codex').provider).toBe('codex'); + expect(new ConfigError('m', '/path/to/cfg').configPath).toBe('/path/to/cfg'); + expect(new ProviderError('m', 'gemini').provider).toBe('gemini'); + }); +}); diff --git a/src/errors/error-types.ts b/src/errors/error-types.ts index 2c288426..a619b6e4 100644 --- a/src/errors/error-types.ts +++ b/src/errors/error-types.ts @@ -5,6 +5,10 @@ * - Standardized exit codes * - Recoverable flag for retry logic * - Consistent error formatting + * + * Fields are declared explicitly (no TypeScript parameter properties) so this + * module is erasable-syntax-compatible: web UI builds enforce + * `erasableSyntaxOnly` and reach this module via the @shared graph. */ import { ExitCode } from './exit-codes'; @@ -14,12 +18,17 @@ import { ExitCode } from './exit-codes'; * Extends standard Error with exit code and recovery information */ export class CCSError extends Error { + readonly code: ExitCode; + readonly recoverable: boolean; + constructor( message: string, - public readonly code: ExitCode = ExitCode.GENERAL_ERROR, - public readonly recoverable: boolean = false + code: ExitCode = ExitCode.GENERAL_ERROR, + recoverable: boolean = false ) { super(message); + this.code = code; + this.recoverable = recoverable; this.name = 'CCSError'; // Maintain proper stack trace in V8 environments if (Error.captureStackTrace) { @@ -33,12 +42,12 @@ export class CCSError extends Error { * Examples: missing config file, invalid JSON, corrupt settings */ export class ConfigError extends CCSError { - constructor( - message: string, - public readonly configPath?: string - ) { + readonly configPath?: string; + + constructor(message: string, configPath?: string) { super(message, ExitCode.CONFIG_ERROR, false); this.name = 'ConfigError'; + this.configPath = configPath; } } @@ -47,13 +56,14 @@ export class ConfigError extends CCSError { * Examples: connection refused, timeout, DNS resolution failure */ export class NetworkError extends CCSError { - constructor( - message: string, - public readonly url?: string, - public readonly statusCode?: number - ) { + readonly url?: string; + readonly statusCode?: number; + + constructor(message: string, url?: string, statusCode?: number) { super(message, ExitCode.NETWORK_ERROR, true); // Network errors are typically recoverable this.name = 'NetworkError'; + this.url = url; + this.statusCode = statusCode; } } @@ -62,12 +72,12 @@ export class NetworkError extends CCSError { * Examples: invalid API key, expired token, insufficient permissions */ export class AuthError extends CCSError { - constructor( - message: string, - public readonly provider?: string - ) { + readonly provider?: string; + + constructor(message: string, provider?: string) { super(message, ExitCode.AUTH_ERROR, false); this.name = 'AuthError'; + this.provider = provider; } } @@ -76,12 +86,12 @@ export class AuthError extends CCSError { * Examples: Claude CLI not found, corrupted binary, permission denied */ export class BinaryError extends CCSError { - constructor( - message: string, - public readonly binaryPath?: string - ) { + readonly binaryPath?: string; + + constructor(message: string, binaryPath?: string) { super(message, ExitCode.BINARY_ERROR, false); this.name = 'BinaryError'; + this.binaryPath = binaryPath; } } @@ -90,13 +100,14 @@ export class BinaryError extends CCSError { * Examples: API rate limit, service unavailable, invalid model */ export class ProviderError extends CCSError { - constructor( - message: string, - public readonly provider: string, - public readonly details?: unknown - ) { + readonly provider: string; + readonly details?: unknown; + + constructor(message: string, provider: string, details?: unknown) { super(message, ExitCode.PROVIDER_ERROR, true); // Provider errors may be recoverable this.name = 'ProviderError'; + this.provider = provider; + this.details = details; } } @@ -105,13 +116,14 @@ export class ProviderError extends CCSError { * 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[] - ) { + readonly profileName?: string; + readonly availableProfiles?: string[]; + + constructor(message: string, profileName?: string, availableProfiles?: string[]) { super(message, ExitCode.PROFILE_ERROR, false); this.name = 'ProfileError'; + this.profileName = profileName; + this.availableProfiles = availableProfiles; } } @@ -120,12 +132,12 @@ export class ProfileError extends CCSError { * Examples: proxy startup failure, port conflict, proxy timeout */ export class ProxyError extends CCSError { - constructor( - message: string, - public readonly port?: number - ) { + readonly port?: number; + + constructor(message: string, port?: number) { super(message, ExitCode.PROXY_ERROR, false); this.name = 'ProxyError'; + this.port = port; } } @@ -134,13 +146,14 @@ export class ProxyError extends CCSError { * Examples: failed to migrate config, backup creation failed */ export class MigrationError extends CCSError { - constructor( - message: string, - public readonly fromVersion?: string, - public readonly toVersion?: string - ) { + readonly fromVersion?: string; + readonly toVersion?: string; + + constructor(message: string, fromVersion?: string, toVersion?: string) { super(message, ExitCode.MIGRATION_ERROR, false); this.name = 'MigrationError'; + this.fromVersion = fromVersion; + this.toVersion = toVersion; } } @@ -160,12 +173,12 @@ export class UserAbortError extends CCSError { * Distinguishes user-input validation failures from system errors */ export class ValidationError extends CCSError { - constructor( - message: string, - public readonly field?: string - ) { + readonly field?: string; + + constructor(message: string, field?: string) { super(message, ExitCode.GENERAL_ERROR, false); this.name = 'ValidationError'; + this.field = field; } } @@ -174,13 +187,14 @@ export class ValidationError extends CCSError { * Signals that the operation may succeed on retry (e.g. rate limits, timeouts) */ export class RetryableError extends CCSError { - constructor( - message: string, - public readonly originalError?: Error, - public readonly retryAfter?: number // ms until next attempt - ) { + readonly originalError?: Error; + readonly retryAfter?: number; // ms until next attempt + + constructor(message: string, originalError?: Error, retryAfter?: number) { super(message, ExitCode.GENERAL_ERROR, true); this.name = 'RetryableError'; + this.originalError = originalError; + this.retryAfter = retryAfter; } } diff --git a/src/errors/exit-codes.ts b/src/errors/exit-codes.ts index a1b450cf..5333c406 100644 --- a/src/errors/exit-codes.ts +++ b/src/errors/exit-codes.ts @@ -7,42 +7,50 @@ * - 126-127: Command execution errors (reserved by shell) * - 128+N: Signal termination (128 + signal number) * - 130: SIGINT (Ctrl+C) - 128 + 2 + * + * Implemented as a const object + union type (not a TS `enum`) so the file is + * erasable-syntax-compatible: web UI builds (ui/tsconfig.app.json, + * erasableSyntaxOnly) can reach this module via the @shared graph without + * failing the build. Value (`ExitCode.CONFIG_ERROR`) and type (`: ExitCode`) + * usage both continue to work. */ -export enum ExitCode { +export const ExitCode = { /** Successful execution */ - SUCCESS = 0, + SUCCESS: 0, /** General/unspecified error */ - GENERAL_ERROR = 1, + GENERAL_ERROR: 1, /** Configuration file errors (missing, invalid, corrupt) */ - CONFIG_ERROR = 2, + CONFIG_ERROR: 2, /** Network-related errors (connection, timeout, DNS) */ - NETWORK_ERROR = 3, + NETWORK_ERROR: 3, /** Authentication/authorization errors (invalid token, expired, forbidden) */ - AUTH_ERROR = 4, + AUTH_ERROR: 4, /** Binary/executable errors (missing Claude CLI, corrupted binary) */ - BINARY_ERROR = 5, + BINARY_ERROR: 5, /** Provider-specific errors (API errors, rate limits, service unavailable) */ - PROVIDER_ERROR = 6, + PROVIDER_ERROR: 6, /** Profile not found or invalid */ - PROFILE_ERROR = 7, + PROFILE_ERROR: 7, /** Proxy-related errors (startup failure, port conflict) */ - PROXY_ERROR = 8, + PROXY_ERROR: 8, /** Migration errors (failed to migrate config) */ - MIGRATION_ERROR = 9, + MIGRATION_ERROR: 9, /** User aborted operation (Ctrl+C, SIGINT) */ - USER_ABORT = 130, -} + USER_ABORT: 130, +} as const; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; /** * Human-readable descriptions for exit codes @@ -74,6 +82,5 @@ export function isSuccess(code: ExitCode | number): boolean { * (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); + return code === ExitCode.NETWORK_ERROR || code === ExitCode.PROVIDER_ERROR; } diff --git a/src/web-server/routes/claude-extension-routes.ts b/src/web-server/routes/claude-extension-routes.ts index e89b22ad..67d34f38 100644 --- a/src/web-server/routes/claude-extension-routes.ts +++ b/src/web-server/routes/claude-extension-routes.ts @@ -27,6 +27,7 @@ import { verifyClaudeExtensionBinding, } from '../services/claude-extension-settings-service'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { ValidationError } from '../../errors/error-types'; const router = Router(); const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id)); @@ -37,8 +38,9 @@ const VALID_TARGETS = new Set(['shared', 'ide', 'al function getHostFromRequest(req: Request): ClaudeExtensionHost { const rawHost = String(req.query.host || 'vscode'); if (!VALID_HOSTS.has(rawHost as ClaudeExtensionHost)) { - throw new Error( - `Invalid host "${rawHost}". Use: ${CLAUDE_EXTENSION_HOSTS.map((host) => host.id).join(', ')}` + throw new ValidationError( + `Invalid host "${rawHost}". Use: ${CLAUDE_EXTENSION_HOSTS.map((host) => host.id).join(', ')}`, + 'host' ); } return rawHost as ClaudeExtensionHost; @@ -48,7 +50,7 @@ function getActionTarget(req: Request): ClaudeExtensionActionTarget { const rawTarget = req.body && typeof req.body.target === 'string' ? req.body.target.trim().toLowerCase() : 'all'; if (!VALID_TARGETS.has(rawTarget as ClaudeExtensionActionTarget)) { - throw new Error('Invalid target. Use: shared, ide, or all'); + throw new ValidationError('Invalid target. Use: shared, ide, or all', 'target'); } return rawTarget as ClaudeExtensionActionTarget; } diff --git a/src/web-server/routes/cliproxy-sync-routes.ts b/src/web-server/routes/cliproxy-sync-routes.ts index faefe5d2..5b5dab82 100644 --- a/src/web-server/routes/cliproxy-sync-routes.ts +++ b/src/web-server/routes/cliproxy-sync-routes.ts @@ -13,6 +13,7 @@ import { getLocalSyncStatus, } from '../../cliproxy/sync'; import { mutateConfig } from '../../config/config-loader-facade'; +import { ConfigError } from '../../errors/error-types'; import { createLogger } from '../../services/logging'; const router = Router(); @@ -135,7 +136,7 @@ router.put('/auto-sync', async (req: Request, res: Response): Promise => { try { mutateConfig((config) => { if (!config.cliproxy) { - throw new Error('CLIProxy config not initialized'); + throw new ConfigError('CLIProxy config not initialized'); } config.cliproxy.auto_sync = enabled; }); diff --git a/src/web-server/routes/copilot-settings-routes.ts b/src/web-server/routes/copilot-settings-routes.ts index 54a748e0..0a39a9db 100644 --- a/src/web-server/routes/copilot-settings-routes.ts +++ b/src/web-server/routes/copilot-settings-routes.ts @@ -15,6 +15,7 @@ import { loadOrCreateUnifiedConfig, mutateConfig, } from '../../config/config-loader-facade'; +import { ConfigError } from '../../errors/error-types'; const router = Router(); @@ -160,8 +161,9 @@ router.put('/raw', (req: Request, res: Response): void => { try { restoreSettingsFile(settingsPath, previousContent, existedBefore); } catch (rollbackError) { - throw new Error( - `Failed to sync unified config after writing Copilot settings: ${(error as Error).message}. Rollback also failed: ${(rollbackError as Error).message}` + throw new ConfigError( + `Failed to sync unified config after writing Copilot settings: ${(error as Error).message}. Rollback also failed: ${(rollbackError as Error).message}`, + settingsPath ); } throw error; diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index db7a44f3..4a9da459 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -28,6 +28,7 @@ import { loadSettings, mutateConfig, } from '../../config/config-loader-facade'; +import { ProviderError } from '../../errors/error-types'; const router = Router(); const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR = @@ -405,7 +406,10 @@ router.put('/', async (req: Request, res: Response): Promise => { return acc; } if (!knownBackends.has(normalizedBackend)) { - throw new Error(`Unsupported provider backend "${backendId}".`); + throw new ProviderError( + `Unsupported provider backend "${backendId}".`, + normalizedBackend ?? backendId + ); } acc[normalizedBackend] = normalizedModel; return acc; diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 65578c25..e8758538 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -19,7 +19,7 @@ import type { CLIProxyProvider } from '../../cliproxy/types'; import type { Config, Settings } from '../../types/config'; import type { TargetType } from '../../targets/target-adapter'; import { isPersistedTargetType } from '../../targets/target-metadata'; -import { ValidationError } from '../../errors/error-types'; +import { ConfigError, ValidationError } from '../../errors/error-types'; import { getCcsDir, loadConfigSafe, loadSettings } from '../../config/config-loader-facade'; import { createLogger } from '../../services/logging'; @@ -232,7 +232,7 @@ export function updateSettingsFile( const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); if (!fs.existsSync(settingsPath)) { - throw new Error('Settings file not found'); + throw new ConfigError('Settings file not found', settingsPath); } const settings = loadSettings(settingsPath); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 323c9e76..7f26df4e 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -37,6 +37,7 @@ import { getDeniedModelIdReasonForProvider, } from '../../cliproxy/ai-providers/model-id-normalizer'; import { createRouteErrorHelpers } from './route-helpers'; +import { ConfigError, ValidationError } from '../../errors/error-types'; import { getImageAnalysisProfileSettingsPath, hasImageAnalysisProfileHook, @@ -71,7 +72,7 @@ function resolvePathWithin(basePath: string, targetPath: string): string { path.isAbsolute(targetPath) ? targetPath : path.join(resolvedBase, targetPath) ); if (!isPathWithin(resolvedBase, resolvedTarget)) { - throw new Error('Invalid settings path'); + throw new ConfigError('Invalid settings path', targetPath); } return resolvedTarget; } @@ -102,7 +103,7 @@ function getRealCcsDir(): string { function requirePathWithinRealCcsDir(targetPath: string): void { if (!isPathWithin(getRealCcsDir(), targetPath)) { - throw new Error('Invalid settings path'); + throw new ConfigError('Invalid settings path', targetPath); } } @@ -142,7 +143,7 @@ function requireWritableSettingsPathWithinCcs(settingsPath: string): void { const realParentPath = findExistingParentRealPath(settingsPath); if (!realParentPath) { - throw new Error('Invalid settings path'); + throw new ConfigError('Invalid settings path', settingsPath); } requirePathWithinRealCcsDir(realParentPath); } @@ -189,7 +190,7 @@ function classifyConfigSaveFailure(error: unknown): { statusCode: number; messag */ function resolveSettingsPath(profileOrVariant: string): string { if (!SETTINGS_IDENTIFIER_PATTERN.test(profileOrVariant)) { - throw new Error('Invalid profile name'); + throw new ValidationError('Invalid profile name', 'profile'); } const ccsDir = getCcsDir(); @@ -431,7 +432,7 @@ function withSettingsFileLock(settingsPath: string, callback: () => T): T { ? settingsPath : findExistingParentRealPath(settingsPath); if (!lockTarget) { - throw new Error('Invalid settings path'); + throw new ConfigError('Invalid settings path', settingsPath); } let release: (() => void) | undefined; @@ -456,7 +457,7 @@ function loadCanonicalProfileSettings( strictPersist = false ): Settings { if (!requireExistingSettingsPathWithinCcs(settingsPath)) { - throw new Error('Settings not found'); + throw new ConfigError('Settings not found', settingsPath); } const loaded = loadSettings(settingsPath);