mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
ab0af2de5c
* feat(exit-codes): add Unix-standard exit codes to all CliError types
Introduce EXIT_CODES constant table (sysexits.h conventions) and wire
exitCode into every CliError subclass so the process exit code reflects
the semantic type of failure:
0 success (default)
1 generic / unexpected error
2 argument / usage error (ArgumentError)
66 empty result / not found (EmptyResultError, SelectorError)
69 service unavailable (BrowserConnectError, AdapterLoadError)
77 permission / auth required (AuthRequiredError)
78 configuration error (ConfigError)
124 timeout (TimeoutError)
130 Ctrl-C / SIGINT (unchanged, tui.ts)
resolveExitCode() in commanderAdapter.ts reads err.exitCode for typed
CliErrors, and falls back to pattern-matching message text for untyped
adapter errors (auth pattern → 77, not-found pattern → 66, else → 1).
Shell scripts can now distinguish error categories:
opencli spotify status || echo "exit $?" # 69 if browser not running
opencli github issues --repo x 2>/dev/null; [ $? -eq 77 ] && opencli github auth
* fix(exit-codes): address review findings
- TIMEOUT: change from 124 → 75 (EX_TEMPFAIL); 124 is bash timeout(1)'s
own exit code, creating ambiguity when shell runs `timeout 30 opencli`
- SelectorError: change from EMPTY_RESULT(66) → GENERIC_ERROR(1); a
missing DOM selector is an adapter bug, not a user "no data" condition
- normalizeArgValue: throw ArgumentError instead of bare CliError so
invalid bool args correctly exit with USAGE_ERROR(2) not GENERIC_ERROR(1)
- resolveExitCode: explicitly map 'http' classification to GENERIC_ERROR
to keep exit-code path in sync with the render path
- tui.ts: replace hardcoded process.exit(130) with EXIT_CODES.INTERRUPTED
* feat(exit-codes): replace all hardcoded exit numbers with EXIT_CODES constants
Extend the exit code system to cover every process exit point in the codebase.
No magic numbers remain — all exit codes are now referenced by name.
Semantic upgrades beyond pure renaming:
- plugin update missing args → USAGE_ERROR (2) instead of 1
- plugin update conflicting → USAGE_ERROR (2) instead of 1
- opencli install <unknown> → USAGE_ERROR (2) instead of 1
- unknown command fallback → USAGE_ERROR (2) instead of 1
- record with no candidates → EMPTY_RESULT (66) instead of 1
- external CLI install fail → SERVICE_UNAVAIL (69) instead of 1
- daemon EADDRINUSE → SERVICE_UNAVAIL (69) instead of 1
Files touched: cli.ts, external.ts, daemon.ts, main.ts,
clis/antigravity/serve.ts
247 lines
7.6 KiB
TypeScript
247 lines
7.6 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
import yaml from 'js-yaml';
|
|
import chalk from 'chalk';
|
|
import { log } from './logger.js';
|
|
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
export interface ExternalCliInstall {
|
|
mac?: string;
|
|
linux?: string;
|
|
windows?: string;
|
|
default?: string;
|
|
}
|
|
|
|
export interface ExternalCliConfig {
|
|
name: string;
|
|
binary: string;
|
|
description?: string;
|
|
homepage?: string;
|
|
tags?: string[];
|
|
install?: ExternalCliInstall;
|
|
}
|
|
|
|
function getUserRegistryPath(): string {
|
|
const home = os.homedir();
|
|
return path.join(home, '.opencli', 'external-clis.yaml');
|
|
}
|
|
|
|
let _cachedExternalClis: ExternalCliConfig[] | null = null;
|
|
|
|
export function loadExternalClis(): ExternalCliConfig[] {
|
|
if (_cachedExternalClis) return _cachedExternalClis;
|
|
const configs = new Map<string, ExternalCliConfig>();
|
|
|
|
// 1. Load built-in
|
|
const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
|
|
try {
|
|
if (fs.existsSync(builtinPath)) {
|
|
const raw = fs.readFileSync(builtinPath, 'utf8');
|
|
const parsed = (yaml.load(raw) || []) as ExternalCliConfig[];
|
|
for (const item of parsed) configs.set(item.name, item);
|
|
}
|
|
} catch (err) {
|
|
log.warn(`Failed to parse built-in external-clis.yaml: ${getErrorMessage(err)}`);
|
|
}
|
|
|
|
// 2. Load user custom
|
|
const userPath = getUserRegistryPath();
|
|
try {
|
|
if (fs.existsSync(userPath)) {
|
|
const raw = fs.readFileSync(userPath, 'utf8');
|
|
const parsed = (yaml.load(raw) || []) as ExternalCliConfig[];
|
|
for (const item of parsed) {
|
|
configs.set(item.name, item); // Overwrite built-in if duplicated
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
|
|
}
|
|
|
|
_cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
return _cachedExternalClis;
|
|
}
|
|
|
|
export function isBinaryInstalled(binary: string): boolean {
|
|
try {
|
|
const isWindows = os.platform() === 'win32';
|
|
execFileSync(isWindows ? 'where' : 'which', [binary], { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function getInstallCmd(installConfig?: ExternalCliInstall): string | null {
|
|
if (!installConfig) return null;
|
|
const platform = os.platform();
|
|
if (platform === 'darwin' && installConfig.mac) return installConfig.mac;
|
|
if (platform === 'linux' && installConfig.linux) return installConfig.linux;
|
|
if (platform === 'win32' && installConfig.windows) return installConfig.windows;
|
|
if (installConfig.default) return installConfig.default;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Safely parses a command string into a binary and argument list.
|
|
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
|
|
* cannot be safely expressed as execFileSync arguments.
|
|
*
|
|
* Args:
|
|
* cmd: Raw command string from YAML config (e.g. "brew install gh")
|
|
*
|
|
* Returns:
|
|
* Object with `binary` and `args` fields, or throws on unsafe input.
|
|
*/
|
|
export function parseCommand(cmd: string): { binary: string; args: string[] } {
|
|
const shellOperators = /&&|\|\|?|;|[><`$#\n\r]|\$\(/;
|
|
if (shellOperators.test(cmd)) {
|
|
throw new Error(
|
|
`Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
|
|
`Please install the tool manually.`
|
|
);
|
|
}
|
|
|
|
// Tokenise respecting single- and double-quoted segments (no variable expansion).
|
|
const tokens: string[] = [];
|
|
const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = re.exec(cmd)) !== null) {
|
|
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
}
|
|
|
|
if (tokens.length === 0) {
|
|
throw new Error(`Install command is empty.`);
|
|
}
|
|
|
|
const [binary, ...args] = tokens;
|
|
return { binary, args };
|
|
}
|
|
|
|
function shouldRetryWithCmdShim(binary: string, err: unknown): boolean {
|
|
const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
|
|
return os.platform() === 'win32' && !path.extname(binary) && code === 'ENOENT';
|
|
}
|
|
|
|
function runInstallCommand(cmd: string): void {
|
|
const { binary, args } = parseCommand(cmd);
|
|
|
|
try {
|
|
execFileSync(binary, args, { stdio: 'inherit' });
|
|
} catch (err) {
|
|
if (shouldRetryWithCmdShim(binary, err)) {
|
|
execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function installExternalCli(cli: ExternalCliConfig): boolean {
|
|
if (!cli.install) {
|
|
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
|
|
console.error(`Please install '${cli.binary}' manually.`);
|
|
return false;
|
|
}
|
|
|
|
const cmd = getInstallCmd(cli.install);
|
|
if (!cmd) {
|
|
console.error(chalk.red(`No install command for your platform (${os.platform()}) for '${cli.name}'.`));
|
|
if (cli.homepage) console.error(`See: ${cli.homepage}`);
|
|
return false;
|
|
}
|
|
|
|
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
|
|
console.log(chalk.dim(`$ ${cmd}`));
|
|
try {
|
|
runInstallCommand(cmd);
|
|
console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
|
|
return true;
|
|
} catch (err) {
|
|
console.error(chalk.red(`❌ Failed to install '${cli.name}': ${getErrorMessage(err)}`));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void {
|
|
const configs = preloaded ?? loadExternalClis();
|
|
const cli = configs.find((c) => c.name === name);
|
|
if (!cli) {
|
|
throw new Error(`External CLI '${name}' not found in registry.`);
|
|
}
|
|
|
|
// 1. Check if installed
|
|
if (!isBinaryInstalled(cli.binary)) {
|
|
// 2. Try to auto install
|
|
const success = installExternalCli(cli);
|
|
if (!success) {
|
|
process.exitCode = EXIT_CODES.SERVICE_UNAVAIL;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3. Passthrough execution with stdio inherited
|
|
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 = EXIT_CODES.GENERIC_ERROR;
|
|
return;
|
|
}
|
|
|
|
if (result.status !== null) {
|
|
process.exitCode = result.status;
|
|
}
|
|
}
|
|
|
|
export interface RegisterOptions {
|
|
binary?: string;
|
|
install?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export function registerExternalCli(name: string, opts?: RegisterOptions): void {
|
|
const userPath = getUserRegistryPath();
|
|
const configDir = path.dirname(userPath);
|
|
|
|
if (!fs.existsSync(configDir)) {
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
}
|
|
|
|
let items: ExternalCliConfig[] = [];
|
|
if (fs.existsSync(userPath)) {
|
|
try {
|
|
const raw = fs.readFileSync(userPath, 'utf8');
|
|
items = (yaml.load(raw) || []) as ExternalCliConfig[];
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
const existingIndex = items.findIndex((c) => c.name === name);
|
|
|
|
const newItem: ExternalCliConfig = {
|
|
name,
|
|
binary: opts?.binary || name,
|
|
};
|
|
if (opts?.description) newItem.description = opts.description;
|
|
if (opts?.install) newItem.install = { default: opts.install };
|
|
|
|
if (existingIndex >= 0) {
|
|
items[existingIndex] = { ...items[existingIndex], ...newItem };
|
|
console.log(chalk.green(`Updated '${name}' in user registry.`));
|
|
} else {
|
|
items.push(newItem);
|
|
console.log(chalk.green(`Registered '${name}' in user registry.`));
|
|
}
|
|
|
|
const dump = yaml.dump(items, { indent: 2, sortKeys: true });
|
|
fs.writeFileSync(userPath, dump, 'utf8');
|
|
_cachedExternalClis = null; // Invalidate cache so next load reflects the change
|
|
console.log(chalk.dim(userPath));
|
|
}
|