Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 ───────────────────
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
});

Expand All @@ -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;
}
});

Expand All @@ -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;
}

Expand Down Expand Up @@ -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.'));
}
Expand All @@ -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;
}
});

Expand Down Expand Up @@ -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;
}
});

Expand All @@ -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);
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/clis/antigravity/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -594,7 +594,7 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
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);
Expand Down
27 changes: 25 additions & 2 deletions src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { render as renderOutput } from './output.js';
import { executeCommand } from './execution.js';
import {
CliError,
EXIT_CODES,
ERROR_ICONS,
getErrorMessage,
BrowserConnectError,
Expand All @@ -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".`);
}

/**
Expand Down Expand Up @@ -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';
Expand Down
9 changes: 5 additions & 4 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
81 changes: 71 additions & 10 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand All @@ -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 {
Expand Down
Loading
Loading