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
7 changes: 7 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2862,6 +2862,13 @@ describe('builtin output formats', () => {
expect(aliasResult.stdout).toBe(yamlResult.stdout);
});

it('sets a nonzero exit code when external install reports failure', async () => {
const result = await run('external', 'install', 'ntn', '-f', 'json');

expect(result.exitCode).toBe(EXIT_CODES.SERVICE_UNAVAIL);
expect(JSON.parse(result.stdout)).toMatchObject({ ok: false, action: 'install', cli: 'ntn' });
});

it('renders an empty plugin list as structured data', async () => {
const list = vi.spyOn(pluginModule, 'listPlugins').mockReturnValue([] as never);
try {
Expand Down
15 changes: 11 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { resolveAdapterSourcePath, splitAdapterCommandKey } from './adapter-sour

const CLI_FILE = fileURLToPath(import.meta.url);
const FOLLOW_POLL_MS = 1_000;
const externalRootCommands = new WeakSet<Command>();

function getBrowserCacheDir(): string {
return process.env.WEBCMD_CACHE_DIR || path.join(os.homedir(), '.webcmd', 'cache');
Expand Down Expand Up @@ -2232,6 +2233,7 @@ cli({
return;
}
const installed = installExternalCli(ext);
if (!installed) process.exitCode = EXIT_CODES.SERVICE_UNAVAIL;
await emitActionResult(command, {
ok: installed,
action: 'install',
Expand Down Expand Up @@ -2287,7 +2289,7 @@ cli({
return process.argv.slice(idx + 1);
})();
try {
executeExternalCli(name, args, externalClis);
process.exitCode = executeExternalCli(name, args, externalClis);
} catch (err) {
console.error(`Error: ${getErrorMessage(err)}`);
process.exitCode = EXIT_CODES.GENERIC_ERROR;
Expand All @@ -2296,14 +2298,15 @@ cli({

for (const ext of externalClis) {
if (program.commands.some(c => c.name() === ext.name)) continue;
program
const command = program
.command(ext.name)
.description(`(External) ${ext.description || ext.name}`)
.argument('[args...]')
.allowUnknownOption()
.passThroughOptions()
.helpOption(false)
.action((args: string[]) => passthroughExternal(ext.name, args));
externalRootCommands.add(command);
}

// ── Antigravity serve (long-running, special case) ────────────────────────
Expand Down Expand Up @@ -2459,8 +2462,12 @@ export async function loadAntigravityServe(pluginsDir: string = PLUGINS_DIR): Pr
* surfaced as an unhandled rejection, and the `exitCode` the error carried was
* lost. `parseAsync` lets the rejection reach this catch.
*/
export async function runCli(BUILTIN_CLIS: string, USER_CLIS: string): Promise<void> {
const program = createProgram(BUILTIN_CLIS, USER_CLIS);
export function isExternalRootCommand(program: Command, name: string | undefined): boolean {
const command = program.commands.find(candidate => candidate.name() === name);
return command !== undefined && externalRootCommands.has(command);
}

export async function runCli(BUILTIN_CLIS: string, USER_CLIS: string, program = createProgram(BUILTIN_CLIS, USER_CLIS)): Promise<void> {
applyUnknownOptionContract(program);
try {
await program.parseAsync();
Expand Down
76 changes: 69 additions & 7 deletions src/external.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ vi.mock('node:os', async () => {
});

import { spawnSync } from 'node:child_process';
import { executeExternalCli, formatExternalCliLabel, installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
import { executeExternalCli, formatExternalCliLabel, installExternalCli, isBinaryInstalled, parseCommand, type ExternalCliConfig } from './external.js';
import { EXIT_CODES } from './errors.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -80,6 +81,37 @@ describe('formatExternalCliLabel', () => {
});
});

describe('isBinaryInstalled', () => {
beforeEach(() => {
mockExecFileSync.mockReset();
mockExecFileSync.mockImplementation(() => {
throw new Error('PATH lookup must not run');
});
});

it('recognizes an existing explicit executable path without PATH lookup', () => {
expect(isBinaryInstalled(process.execPath)).toBe(true);
expect(mockExecFileSync).not.toHaveBeenCalled();
});

it.each([
['forward slash', '/'],
['backslash', '\\'],
])('recognizes an existing relative path qualified with a $0', (_name, separator) => {
const directory = fs.mkdtempSync('.webcmd-external-binary-');
const binary = `${directory}${separator}fixture`;
try {
fs.writeFileSync(binary, '');

expect(isBinaryInstalled(binary)).toBe(true);
expect(mockExecFileSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(directory, { recursive: true, force: true });
fs.rmSync(binary, { force: true });
}
});
});

describe('installExternalCli', () => {
const cli: ExternalCliConfig = {
name: 'readwise',
Expand Down Expand Up @@ -151,29 +183,59 @@ describe('executeExternalCli passthrough', () => {
.mockReturnValueOnce({ error: einval, status: null, signal: null } as unknown as ReturnType<typeof spawnSync>)
.mockReturnValueOnce({ status: 0, signal: null } as unknown as ReturnType<typeof spawnSync>);

executeExternalCli('tg', ['send', 'hello world', '--to', 'a"b'], [cli]);
const code = executeExternalCli('tg', ['send', 'hello world', '--to', 'a"b'], [cli]);

expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock).toHaveBeenNthCalledWith(1, 'tg', ['send', 'hello world', '--to', 'a"b'], { stdio: 'inherit' });
expect(spawnMock).toHaveBeenNthCalledWith(2, 'tg send "hello world" --to "a""b"', { stdio: 'inherit', shell: true });
expect(process.exitCode).toBe(0);
expect(code).toBe(0);
});

it('does not retry through the shell on non-Windows platforms', () => {
const einval = Object.assign(new Error('spawnSync tg EINVAL'), { code: 'EINVAL' });
spawnMock.mockReturnValueOnce({ error: einval, status: null, signal: null } as unknown as ReturnType<typeof spawnSync>);

executeExternalCli('tg', [], [cli]);
const code = executeExternalCli('tg', [], [cli]);

expect(spawnMock).toHaveBeenCalledTimes(1);
expect(process.exitCode).toBe(1);
expect(code).toBe(1);
});

it('reports a non-zero exit code when the child dies from a signal', () => {
spawnMock.mockReturnValueOnce({ status: null, signal: 'SIGKILL' } as unknown as ReturnType<typeof spawnSync>);

executeExternalCli('tg', [], [cli]);
const code = executeExternalCli('tg', [], [cli]);

expect(code).toBe(1);
});
});

describe('executeExternalCli', () => {
const spawnMock = vi.mocked(spawnSync);
const nodeBinary: ExternalCliConfig = {
name: 'fake-node',
binary: process.execPath,
description: 'Node itself, used as a guaranteed-present binary',
};

beforeEach(() => {
spawnMock.mockReset();
});

it('returns the child exit code on success', () => {
spawnMock.mockReturnValueOnce({ status: 0, signal: null } as unknown as ReturnType<typeof spawnSync>);
const code = executeExternalCli('fake-node', ['-e', 'process.exit(0)'], [nodeBinary]);
expect(code).toBe(EXIT_CODES.SUCCESS);
});

it('returns the child exit code on failure', () => {
spawnMock.mockReturnValueOnce({ status: 3, signal: null } as unknown as ReturnType<typeof spawnSync>);
const code = executeExternalCli('fake-node', ['-e', 'process.exit(3)'], [nodeBinary]);
expect(code).toBe(3);
});

expect(process.exitCode).toBe(1);
it('throws when the name is not in the registry', () => {
expect(() => executeExternalCli('absent', [], [nodeBinary]))
.toThrowError("External CLI 'absent' not found in registry.");
});
});
16 changes: 6 additions & 10 deletions src/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function loadExternalClis(): ExternalCliConfig[] {
}

export function isBinaryInstalled(binary: string): boolean {
if (path.isAbsolute(binary) || binary.includes('/') || binary.includes('\\')) return fs.existsSync(binary);
try {
const isWindows = os.platform() === 'win32';
execFileSync(isWindows ? 'where' : 'which', [binary], { stdio: 'ignore' });
Expand Down Expand Up @@ -179,7 +180,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean {
}
}

export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void {
export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): number {
const configs = preloaded ?? loadExternalClis();
const cli = configs.find((c) => c.name === name);
if (!cli) {
Expand All @@ -191,27 +192,22 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext
// 2. Try to auto install
const success = installExternalCli(cli);
if (!success) {
process.exitCode = EXIT_CODES.SERVICE_UNAVAIL;
return;
return EXIT_CODES.SERVICE_UNAVAIL;
}
}

// 3. Passthrough execution with stdio inherited
const result = spawnPassthrough(cli.binary, args);
if (result.error) {
log.error(`Failed to execute '${cli.binary}': ${result.error.message}`);
process.exitCode = EXIT_CODES.GENERIC_ERROR;
return;
return EXIT_CODES.GENERIC_ERROR;
}

if (result.signal) {
process.exitCode = EXIT_CODES.GENERIC_ERROR;
return;
return EXIT_CODES.GENERIC_ERROR;
}

if (result.status !== null) {
process.exitCode = result.status;
}
return result.status ?? EXIT_CODES.SUCCESS;
}

function quoteForCmdShell(token: string): string {
Expand Down
23 changes: 22 additions & 1 deletion src/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
*/

import { describe, it, expect, beforeEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createProgram, isExternalRootCommand } from './cli.js';
import {
onStartup,
onBeforeExecute,
Expand All @@ -11,6 +15,7 @@ import {
clearAllHooks,
shouldEmitStartupHook,
shouldRunStartupSideEffects,
WEBCMD_ROOT_COMMANDS,
type HookContext,
} from './hooks.js';

Expand Down Expand Up @@ -110,9 +115,24 @@ describe('no-op when no hooks registered', () => {
});

describe('startup hook gating', () => {
it('covers every unconditional createProgram root in the authoritative inventory', () => {
const pluginsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-root-inventory-'));
try {
const program = createProgram('', '', pluginsDir);
const actual = new Set(program.commands
.filter(command => !isExternalRootCommand(program, command.name()))
.map(command => command.name()));
const missing = [...actual].filter(name => !WEBCMD_ROOT_COMMANDS.has(name)).sort();
const stale = [...WEBCMD_ROOT_COMMANDS].filter(name => !actual.has(name)).sort();

expect({ missing, stale }).toEqual({ missing: [], stale: [] });
} finally {
fs.rmSync(pluginsDir, { recursive: true, force: true });
}
});

it.each([
['--help'],
['agent-context', '--json'],
['list', '--format', 'json'],
['list', '--json'],
])('skips startup side effects for help or requested data output: %j', (...argv) => {
Expand All @@ -124,6 +144,7 @@ describe('startup hook gating', () => {
});

it.each([
['agent-context', '--json'],
['demo', 'state', '--json'],
['demo', 'state', '-f', 'json'],
])('keeps startup side effects for real plugin command execution: %j', (...argv) => {
Expand Down
6 changes: 3 additions & 3 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@ export async function emitHook(name: HookName, ctx: HookContext, result?: unknow
}
}

const BUILTIN_COMMANDS = new Set([
'agent-context',
export const WEBCMD_ROOT_COMMANDS: ReadonlySet<string> = new Set([
'adapter',
'auth',
'browser',
Expand All @@ -97,6 +96,7 @@ const BUILTIN_COMMANDS = new Set([
'plugin',
'profile',
'session',
'site',
'skills',
'update',
'validate',
Expand All @@ -106,7 +106,7 @@ const BUILTIN_COMMANDS = new Set([

export function shouldRunStartupSideEffects(argv: readonly string[]): boolean {
if (isHelp(argv)) return false;
return !(hasExplicitOutputFormat(argv) && BUILTIN_COMMANDS.has(rootCommand(argv) ?? ''));
return !(hasExplicitOutputFormat(argv) && WEBCMD_ROOT_COMMANDS.has(rootCommand(argv) ?? ''));
}

export function shouldEmitStartupHook(argv: readonly string[]): boolean {
Expand Down
Loading
Loading