mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
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:
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,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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user