feat(errors): P4 typed-error taxonomy adoption (0->91% locked) + erasable-syntax fix

Epic P4. Migrates plain throw new Error to the typed-error classes in the four
locked subdomains, and makes the taxonomy erasable-syntax-compatible so it can
be adopted across UI-reachable code.

Migration (cliproxy/auth, web-server/routes, auth):
- 21 of 23 throws in the locked subdomains now use typed subclasses
  (ProfileError, AuthError, ConfigError, ValidationError, ProviderError).
- Typed adoption in locked subdomains: 0/23 -> 21/23 (91.3%), > 40% target.
- Overall typed adoption: 0.9% -> 8.6%.
- Messages preserved exactly (message-based tests stable). Exit codes now
  differentiate via handleError (ProfileError=7, AuthError=4, ConfigError=2,
  ProviderError=6). ccs doctor 0/1 contract untouched (outside scope).

Erasable-syntax fix (unblocks the migration in the UI build graph):
- exit-codes.ts: enum ExitCode -> const object + union type (value and type
  usage both preserved; no Object.values(ExitCode) consumers).
- error-types.ts: constructor parameter properties -> explicit readonly field
  declarations + body assignment.
- The web UI build enforces erasableSyntaxOnly (ui/tsconfig.app.json) and
  reaches src/errors via the @shared -> src/auth graph; pre-erasable
  error-types blocked the build once profile-registry adopted typed errors.

Compat audit: docs/reports/typed-error-exit-code-compat-audit.md (Q1 resolved:
migrate freely; only documented contract is ccs doctor, which is untouched).
Behavior-lock: src/errors/__tests__/typed-error-migration-exit-codes.test.ts
(taxonomy -> exit-code mapping, instanceof chains, context fields).

validate + validate:ci-parity green (incl. UI build).
This commit is contained in:
Tam Nhu Tran committed 2026-06-18 18:48:12 -04:00
1 parent 87aeb8f193
commit 7234ef8fcf
15 files changed
+256 -99

No files matched your search

@@ -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.
+13 -12
View File
@@ -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<AccountConfig>): 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]);
+2 -1
View File
@@ -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) {
+3 -2
View File
@@ -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;
}
+8 -6
View File
@@ -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, {
@@ -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)}`);
}
/**
@@ -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');
});
});
+61 -47
View File
@@ -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;
}
}
+22 -15
View File
@@ -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;
}
@@ -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<ClaudeExtensionActionTarget>(['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;
}
@@ -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<void> => {
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;
});
@@ -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;
@@ -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<void> => {
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;
+2 -2
View File
@@ -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);
+7 -6
View File
@@ -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<T>(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);