errors.ts
1 /** 2 * Unified error types for opencli. 3 * 4 * All errors thrown by the framework should extend CliError so that 5 * the top-level handler in commanderAdapter.ts can render consistent, 6 * helpful output with emoji-coded severity and actionable hints. 7 * 8 * ## Exit codes 9 * 10 * opencli follows Unix conventions (sysexits.h) for process exit codes: 11 * 12 * 0 Success 13 * 1 Generic / unexpected error 14 * 2 Argument / usage error (ArgumentError) 15 * 66 No input / empty result (EmptyResultError) 16 * 69 Service unavailable (BrowserConnectError, AdapterLoadError) 17 * 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL 18 * 77 Permission denied / auth needed (AuthRequiredError) 19 * 78 Configuration error (ConfigError) 20 * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) 21 */ 22 23 // ── Exit code table ────────────────────────────────────────────────────────── 24 25 export const EXIT_CODES = { 26 SUCCESS: 0, 27 GENERIC_ERROR: 1, 28 USAGE_ERROR: 2, // Bad arguments / command misuse 29 EMPTY_RESULT: 66, // No data / not found (EX_NOINPUT) 30 SERVICE_UNAVAIL:69, // Daemon / browser unavailable (EX_UNAVAILABLE) 31 TEMPFAIL: 75, // Timeout — try again later (EX_TEMPFAIL) 32 NOPERM: 77, // Auth required / permission (EX_NOPERM) 33 CONFIG_ERROR: 78, // Missing / invalid config (EX_CONFIG) 34 INTERRUPTED: 130, // Ctrl-C / SIGINT 35 } as const; 36 37 export type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES]; 38 39 // ── Base class ─────────────────────────────────────────────────────────────── 40 41 export class CliError extends Error { 42 /** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */ 43 readonly code: string; 44 /** Human-readable hint on how to fix the problem */ 45 readonly hint?: string; 46 /** Unix process exit code — defaults to 1 (generic error) */ 47 readonly exitCode: ExitCode; 48 49 constructor(code: string, message: string, hint?: string, exitCode: ExitCode = EXIT_CODES.GENERIC_ERROR) { 50 super(message); 51 this.name = new.target.name; 52 this.code = code; 53 this.hint = hint; 54 this.exitCode = exitCode; 55 } 56 } 57 58 // ── Typed subclasses ───────────────────────────────────────────────────────── 59 60 export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown'; 61 62 export class BrowserConnectError extends CliError { 63 readonly kind: BrowserConnectKind; 64 constructor(message: string, hint?: string, kind: BrowserConnectKind = 'unknown') { 65 super('BROWSER_CONNECT', message, hint, EXIT_CODES.SERVICE_UNAVAIL); 66 this.kind = kind; 67 } 68 } 69 70 export class AdapterLoadError extends CliError { 71 constructor(message: string, hint?: string) { 72 super('ADAPTER_LOAD', message, hint, EXIT_CODES.SERVICE_UNAVAIL); 73 } 74 } 75 76 export class CommandExecutionError extends CliError { 77 constructor(message: string, hint?: string) { 78 super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR); 79 } 80 } 81 82 export class ConfigError extends CliError { 83 constructor(message: string, hint?: string) { 84 super('CONFIG', message, hint, EXIT_CODES.CONFIG_ERROR); 85 } 86 } 87 88 export class AuthRequiredError extends CliError { 89 readonly domain: string; 90 constructor(domain: string, message?: string) { 91 super( 92 'AUTH_REQUIRED', 93 message ?? `Not logged in to ${domain}`, 94 `Please open Chrome or Chromium and log in to https://${domain}`, 95 EXIT_CODES.NOPERM, 96 ); 97 this.domain = domain; 98 } 99 } 100 101 export class TimeoutError extends CliError { 102 constructor(label: string, seconds: number, hint?: string) { 103 super( 104 'TIMEOUT', 105 `${label} timed out after ${seconds}s`, 106 hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var', 107 EXIT_CODES.TEMPFAIL, 108 ); 109 } 110 } 111 112 export class ArgumentError extends CliError { 113 constructor(message: string, hint?: string) { 114 super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR); 115 } 116 } 117 118 export class EmptyResultError extends CliError { 119 constructor(command: string, hint?: string) { 120 super( 121 'EMPTY_RESULT', 122 `${command} returned no data`, 123 hint ?? 'The page structure may have changed, or you may need to log in', 124 EXIT_CODES.EMPTY_RESULT, 125 ); 126 } 127 } 128 129 export class SelectorError extends CliError { 130 constructor(selector: string, hint?: string) { 131 super( 132 'SELECTOR', 133 `Could not find element: ${selector}`, 134 hint ?? 'The page UI may have changed. Please report this issue.', 135 EXIT_CODES.GENERIC_ERROR, 136 ); 137 } 138 } 139 140 export class PluginError extends CliError { 141 constructor(message: string, hint?: string) { 142 super('PLUGIN', message, hint, EXIT_CODES.GENERIC_ERROR); 143 } 144 } 145 146 // ── Error Envelope ────────────────────────────────────────────────────────── 147 148 /** Structured error output — unified contract for all consumers (AI agents, scripts, humans). */ 149 export interface ErrorEnvelope { 150 ok: false; 151 error: { 152 code: string; 153 message: string; 154 help?: string; 155 exitCode: number; 156 stack?: string; 157 cause?: string; 158 }; 159 } 160 161 // ── Utilities ─────────────────────────────────────────────────────────────── 162 163 /** Extract a human-readable message from an unknown caught value. */ 164 export function getErrorMessage(error: unknown): string { 165 return error instanceof Error ? error.message : String(error); 166 } 167 168 /** Serialize an error cause chain into a readable string. */ 169 function serializeCause(cause: unknown, depth: number = 0): string { 170 if (depth > 10) return '(cause chain truncated)'; 171 if (cause instanceof Error) { 172 const parts = [cause.message]; 173 if (cause.cause) parts.push(` caused by: ${serializeCause(cause.cause, depth + 1)}`); 174 return parts.join('\n'); 175 } 176 return String(cause); 177 } 178 179 /** Build an ErrorEnvelope from any caught value. */ 180 export function toEnvelope(err: unknown): ErrorEnvelope { 181 const cause = err instanceof Error && err.cause ? serializeCause(err.cause) : undefined; 182 if (err instanceof CliError) { 183 return { 184 ok: false, 185 error: { 186 code: err.code, 187 message: err.message, 188 ...(err.hint ? { help: err.hint } : {}), 189 exitCode: err.exitCode, 190 ...(cause ? { cause } : {}), 191 }, 192 }; 193 } 194 const msg = getErrorMessage(err); 195 return { 196 ok: false, 197 error: { 198 code: 'UNKNOWN', 199 message: msg, 200 exitCode: EXIT_CODES.GENERIC_ERROR, 201 ...(cause ? { cause } : {}), 202 }, 203 }; 204 }