diff --git a/src/cli.ts b/src/cli.ts index b9442ac5..fe358144 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,7 +15,7 @@ import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; import { registerAllCommands } from './commanderAdapter.js'; -import { getErrorMessage } from './errors.js'; +import { EXIT_CODES, getErrorMessage } from './errors.js'; export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { const program = new Command(); @@ -120,7 +120,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { const { verifyClis, renderVerifyReport } = await import('./verify.js'); const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); console.log(renderVerifyReport(r)); - process.exitCode = r.ok ? 0 : 1; + process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; }); // ── Built-in: explore / synthesize / generate / cascade ─────────────────── @@ -180,7 +180,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { workspace, }); console.log(renderGenerateSummary(r)); - process.exitCode = r.ok ? 0 : 1; + process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; }); // ── Built-in: record ───────────────────────────────────────────────────── @@ -204,7 +204,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { timeoutMs: parseInt(opts.timeout, 10), }); console.log(renderRecordSummary(result)); - process.exitCode = result.candidateCount > 0 ? 0 : 1; + process.exitCode = result.candidateCount > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.EMPTY_RESULT; }); program @@ -272,7 +272,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { } } catch (err) { console.error(chalk.red(`Error: ${getErrorMessage(err)}`)); - process.exitCode = 1; + process.exitCode = EXIT_CODES.GENERIC_ERROR; } }); @@ -287,7 +287,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`)); } catch (err) { console.error(chalk.red(`Error: ${getErrorMessage(err)}`)); - process.exitCode = 1; + process.exitCode = EXIT_CODES.GENERIC_ERROR; } }); @@ -299,12 +299,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .action(async (name: string | undefined, opts: { all?: boolean }) => { if (!name && !opts.all) { console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.')); - process.exitCode = 1; + process.exitCode = EXIT_CODES.USAGE_ERROR; return; } if (name && opts.all) { console.error(chalk.red('Error: Cannot specify both a plugin name and --all.')); - process.exitCode = 1; + process.exitCode = EXIT_CODES.USAGE_ERROR; return; } @@ -335,7 +335,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { console.log(); if (hasErrors) { console.error(chalk.red('Completed with some errors.')); - process.exitCode = 1; + process.exitCode = EXIT_CODES.GENERIC_ERROR; } else { console.log(chalk.green('✅ All plugins updated successfully.')); } @@ -348,7 +348,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`)); } catch (err) { console.error(chalk.red(`Error: ${getErrorMessage(err)}`)); - process.exitCode = 1; + process.exitCode = EXIT_CODES.GENERIC_ERROR; } }); @@ -438,7 +438,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { console.log(chalk.dim(` opencli ${name} hello`)); } catch (err) { console.error(chalk.red(`Error: ${getErrorMessage(err)}`)); - process.exitCode = 1; + process.exitCode = EXIT_CODES.GENERIC_ERROR; } }); @@ -454,7 +454,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { const ext = externalClis.find(e => e.name === name); if (!ext) { console.error(chalk.red(`External CLI '${name}' not found in registry.`)); - process.exitCode = 1; + process.exitCode = EXIT_CODES.USAGE_ERROR; return; } installExternalCli(ext); @@ -480,7 +480,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { executeExternalCli(name, args, externalClis); } catch (err) { console.error(chalk.red(`Error: ${getErrorMessage(err)}`)); - process.exitCode = 1; + process.exitCode = EXIT_CODES.GENERIC_ERROR; } } @@ -525,7 +525,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`)); } program.outputHelp(); - process.exitCode = 1; + process.exitCode = EXIT_CODES.USAGE_ERROR; }); program.parse(); diff --git a/src/clis/antigravity/serve.ts b/src/clis/antigravity/serve.ts index 39f20408..2b7bbd32 100644 --- a/src/clis/antigravity/serve.ts +++ b/src/clis/antigravity/serve.ts @@ -13,7 +13,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { CDPBridge } from '../../browser/cdp.js'; import type { IPage } from '../../types.js'; -import { getErrorMessage } from '../../errors.js'; +import { EXIT_CODES, getErrorMessage } from '../../errors.js'; // ─── Types ─────────────────────────────────────────────────────────── @@ -594,7 +594,7 @@ export async function startServe(opts: { port?: number } = {}): Promise { console.error('\n[serve] Shutting down...'); cdp?.close().catch(() => {}); server.close(); - process.exit(0); + process.exit(EXIT_CODES.SUCCESS); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index f76b85b0..0929eb89 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -18,6 +18,7 @@ import { render as renderOutput } from './output.js'; import { executeCommand } from './execution.js'; import { CliError, + EXIT_CODES, ERROR_ICONS, getErrorMessage, BrowserConnectError, @@ -40,7 +41,7 @@ export function normalizeArgValue(argType: string | undefined, value: unknown, n if (normalized === 'true') return true; if (normalized === 'false') return false; - throw new CliError('ARGUMENT', `"${name}" must be either "true" or "false".`); + throw new ArgumentError(`"${name}" must be either "true" or "false".`); } /** @@ -117,11 +118,33 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi }); } catch (err) { await renderError(err, fullName(cmd), optionsRecord.verbose === true); - process.exitCode = 1; + process.exitCode = resolveExitCode(err); } }); } +// ── Exit code resolution ───────────────────────────────────────────────────── + +/** + * Map any thrown value to a Unix process exit code. + * + * - CliError subclasses carry their own exitCode (set in errors.ts). + * - Generic Error objects are classified by message pattern so that + * un-typed auth / not-found errors from adapters still produce + * meaningful exit codes for shell scripts. + */ +function resolveExitCode(err: unknown): number { + if (err instanceof CliError) return err.exitCode; + + // Pattern-based fallback for untyped errors thrown by third-party adapters. + const msg = getErrorMessage(err); + const kind = classifyGenericError(msg); + if (kind === 'auth') return EXIT_CODES.NOPERM; + if (kind === 'not-found') return EXIT_CODES.EMPTY_RESULT; + if (kind === 'http') return EXIT_CODES.GENERIC_ERROR; // HTTP 4xx/5xx → generic; renderer shows details + return EXIT_CODES.GENERIC_ERROR; +} + // ── Error rendering ────────────────────────────────────────────────────────── const ISSUES_URL = 'https://github.com/jackwener/opencli/issues'; diff --git a/src/daemon.ts b/src/daemon.ts index 6297cfcc..1c30f973 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -22,6 +22,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket, type RawData } from 'ws'; import { DEFAULT_DAEMON_PORT } from './constants.js'; +import { EXIT_CODES } from './errors.js'; const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes @@ -53,7 +54,7 @@ function resetIdleTimer(): void { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { console.error('[daemon] Idle timeout, shutting down'); - process.exit(0); + process.exit(EXIT_CODES.SUCCESS); }, IDLE_TIMEOUT); } @@ -303,10 +304,10 @@ httpServer.listen(PORT, '127.0.0.1', () => { httpServer.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`); - process.exit(1); + process.exit(EXIT_CODES.SERVICE_UNAVAIL); } console.error('[daemon] Server error:', err.message); - process.exit(1); + process.exit(EXIT_CODES.GENERIC_ERROR); }); // Graceful shutdown @@ -319,7 +320,7 @@ function shutdown(): void { pending.clear(); if (extensionWs) extensionWs.close(); httpServer.close(); - process.exit(0); + process.exit(EXIT_CODES.SUCCESS); } process.on('SIGTERM', shutdown); diff --git a/src/errors.ts b/src/errors.ts index d34c8434..0b810c3d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,48 +4,96 @@ * All errors thrown by the framework should extend CliError so that * the top-level handler in commanderAdapter.ts can render consistent, * helpful output with emoji-coded severity and actionable hints. + * + * ## Exit codes + * + * opencli follows Unix conventions (sysexits.h) for process exit codes: + * + * 0 Success + * 1 Generic / unexpected error + * 2 Argument / usage error (ArgumentError) + * 66 No input / empty result (EmptyResultError) + * 69 Service unavailable (BrowserConnectError, AdapterLoadError) + * 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL + * 77 Permission denied / auth needed (AuthRequiredError) + * 78 Configuration error (ConfigError) + * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) */ +// ── Exit code table ────────────────────────────────────────────────────────── + +export const EXIT_CODES = { + SUCCESS: 0, + GENERIC_ERROR: 1, + USAGE_ERROR: 2, // Bad arguments / command misuse + EMPTY_RESULT: 66, // No data / not found (EX_NOINPUT) + SERVICE_UNAVAIL:69, // Daemon / browser unavailable (EX_UNAVAILABLE) + TEMPFAIL: 75, // Timeout — try again later (EX_TEMPFAIL) + NOPERM: 77, // Auth required / permission (EX_NOPERM) + CONFIG_ERROR: 78, // Missing / invalid config (EX_CONFIG) + INTERRUPTED: 130, // Ctrl-C / SIGINT +} as const; + +export type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES]; + +// ── Base class ─────────────────────────────────────────────────────────────── + export class CliError extends Error { /** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */ readonly code: string; /** Human-readable hint on how to fix the problem */ readonly hint?: string; + /** Unix process exit code — defaults to 1 (generic error) */ + readonly exitCode: ExitCode; - constructor(code: string, message: string, hint?: string) { + constructor(code: string, message: string, hint?: string, exitCode: ExitCode = EXIT_CODES.GENERIC_ERROR) { super(message); this.name = new.target.name; this.code = code; this.hint = hint; + this.exitCode = exitCode; } } +// ── Typed subclasses ───────────────────────────────────────────────────────── + export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown'; export class BrowserConnectError extends CliError { readonly kind: BrowserConnectKind; constructor(message: string, hint?: string, kind: BrowserConnectKind = 'unknown') { - super('BROWSER_CONNECT', message, hint); + super('BROWSER_CONNECT', message, hint, EXIT_CODES.SERVICE_UNAVAIL); this.kind = kind; } } export class AdapterLoadError extends CliError { - constructor(message: string, hint?: string) { super('ADAPTER_LOAD', message, hint); } + constructor(message: string, hint?: string) { + super('ADAPTER_LOAD', message, hint, EXIT_CODES.SERVICE_UNAVAIL); + } } export class CommandExecutionError extends CliError { - constructor(message: string, hint?: string) { super('COMMAND_EXEC', message, hint); } + constructor(message: string, hint?: string) { + super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR); + } } export class ConfigError extends CliError { - constructor(message: string, hint?: string) { super('CONFIG', message, hint); } + constructor(message: string, hint?: string) { + super('CONFIG', message, hint, EXIT_CODES.CONFIG_ERROR); + } } export class AuthRequiredError extends CliError { readonly domain: string; constructor(domain: string, message?: string) { - super('AUTH_REQUIRED', message ?? `Not logged in to ${domain}`, `Please open Chrome and log in to https://${domain}`); + super( + 'AUTH_REQUIRED', + message ?? `Not logged in to ${domain}`, + `Please open Chrome and log in to https://${domain}`, + EXIT_CODES.NOPERM, + ); this.domain = domain; } } @@ -56,27 +104,40 @@ export class TimeoutError extends CliError { 'TIMEOUT', `${label} timed out after ${seconds}s`, hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var', + EXIT_CODES.TEMPFAIL, ); } } export class ArgumentError extends CliError { - constructor(message: string, hint?: string) { super('ARGUMENT', message, hint); } + constructor(message: string, hint?: string) { + super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR); + } } export class EmptyResultError extends CliError { constructor(command: string, hint?: string) { - super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in'); + super( + 'EMPTY_RESULT', + `${command} returned no data`, + hint ?? 'The page structure may have changed, or you may need to log in', + EXIT_CODES.EMPTY_RESULT, + ); } } export class SelectorError extends CliError { constructor(selector: string, hint?: string) { - super('SELECTOR', `Could not find element: ${selector}`, hint ?? 'The page UI may have changed. Please report this issue.'); + super( + 'SELECTOR', + `Could not find element: ${selector}`, + hint ?? 'The page UI may have changed. Please report this issue.', + EXIT_CODES.GENERIC_ERROR, + ); } } -// ── Utilities ─────────────────────────────────────────────────────────── +// ── Utilities ─────────────────────────────────────────────────────────────── /** Extract a human-readable message from an unknown caught value. */ export function getErrorMessage(error: unknown): string { diff --git a/src/external.ts b/src/external.ts index 5255e389..72939e8c 100644 --- a/src/external.ts +++ b/src/external.ts @@ -6,7 +6,7 @@ import { spawnSync, execFileSync } from 'node:child_process'; import yaml from 'js-yaml'; import chalk from 'chalk'; import { log } from './logger.js'; -import { getErrorMessage } from './errors.js'; +import { EXIT_CODES, getErrorMessage } from './errors.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -180,7 +180,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext // 2. Try to auto install const success = installExternalCli(cli); if (!success) { - process.exitCode = 1; + process.exitCode = EXIT_CODES.SERVICE_UNAVAIL; return; } } @@ -189,7 +189,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext const result = spawnSync(cli.binary, args, { stdio: 'inherit' }); if (result.error) { console.error(chalk.red(`Failed to execute '${cli.binary}': ${result.error.message}`)); - process.exitCode = 1; + process.exitCode = EXIT_CODES.GENERIC_ERROR; return; } diff --git a/src/main.ts b/src/main.ts index b31c4d78..7115fcb7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,7 @@ import { runCli } from './cli.js'; import { emitHook } from './hooks.js'; import { installNodeNetwork } from './node-network.js'; import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js'; +import { EXIT_CODES } from './errors.js'; installNodeNetwork(); @@ -57,7 +58,7 @@ if (getCompIdx !== -1) { if (cursor === undefined) cursor = words.length; const candidates = getCompletions(words, cursor); process.stdout.write(candidates.join('\n') + '\n'); - process.exit(0); + process.exit(EXIT_CODES.SUCCESS); } await emitHook('onStartup', { command: '__startup__', args: {} }); diff --git a/src/tui.ts b/src/tui.ts index 713b72fd..742d7a70 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -4,6 +4,7 @@ * Uses raw stdin mode + ANSI escape codes for interactive prompts. */ import chalk from 'chalk'; +import { EXIT_CODES } from './errors.js'; export interface CheckboxItem { label: string; @@ -161,7 +162,7 @@ export async function checkboxPrompt( // Ctrl+C — exit process if (key === '\x03') { cleanup(); - process.exit(130); + process.exit(EXIT_CODES.INTERRUPTED); } }