/ src / errors.ts
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  }