diff --git a/src/cli.test.ts b/src/cli.test.ts index 9bf6f584..f138106e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -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 { diff --git a/src/cli.ts b/src/cli.ts index 3e49cb69..352f6177 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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(); function getBrowserCacheDir(): string { return process.env.WEBCMD_CACHE_DIR || path.join(os.homedir(), '.webcmd', 'cache'); @@ -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', @@ -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; @@ -2296,7 +2298,7 @@ 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...]') @@ -2304,6 +2306,7 @@ cli({ .passThroughOptions() .helpOption(false) .action((args: string[]) => passthroughExternal(ext.name, args)); + externalRootCommands.add(command); } // ── Antigravity serve (long-running, special case) ──────────────────────── @@ -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 { - 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 { applyUnknownOptionContract(program); try { await program.parseAsync(); diff --git a/src/external.test.ts b/src/external.test.ts index 0f926b2c..a595c36a 100644 --- a/src/external.test.ts +++ b/src/external.test.ts @@ -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)); @@ -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', @@ -151,29 +183,59 @@ describe('executeExternalCli passthrough', () => { .mockReturnValueOnce({ error: einval, status: null, signal: null } as unknown as ReturnType) .mockReturnValueOnce({ status: 0, signal: null } as unknown as ReturnType); - 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); - 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); - 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); + 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); + 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."); }); }); diff --git a/src/external.ts b/src/external.ts index 9f95d7bc..c3977226 100644 --- a/src/external.ts +++ b/src/external.ts @@ -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' }); @@ -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) { @@ -191,8 +192,7 @@ 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; } } @@ -200,18 +200,14 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext 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 { diff --git a/src/hooks.test.ts b/src/hooks.test.ts index 41087fe0..4d805f15 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -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, @@ -11,6 +15,7 @@ import { clearAllHooks, shouldEmitStartupHook, shouldRunStartupSideEffects, + WEBCMD_ROOT_COMMANDS, type HookContext, } from './hooks.js'; @@ -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) => { @@ -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) => { diff --git a/src/hooks.ts b/src/hooks.ts index b3ea8600..8da048bb 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -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 = new Set([ 'adapter', 'auth', 'browser', @@ -97,6 +96,7 @@ const BUILTIN_COMMANDS = new Set([ 'plugin', 'profile', 'session', + 'site', 'skills', 'update', 'validate', @@ -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 { diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts new file mode 100644 index 00000000..2434219a --- /dev/null +++ b/src/hosted/external.test.ts @@ -0,0 +1,354 @@ +import { Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { makeHostedConfig } from './config.js'; +import { runHostedCli } from './runner.js'; +import type { ExternalCliConfig } from '../external.js'; +import type { HostedCredentialStore } from './credentials.js'; +import { PKG_VERSION } from '../version.js'; + +function sink(): { stream: Writable; text: () => string } { + let data = ''; + return { + stream: new Writable({ + write(chunk, _encoding, callback) { + data += String(chunk); + callback(); + }, + }), + text: () => data, + }; +} + +const manifest = { + userId: 'user_demo', + metadata: { + contractSchemaVersion: 1, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, + generatedAt: '2026-08-27T00:00:00.000Z', + }, + commands: [{ + site: 'github', + name: 'whoami', + command: 'github/whoami', + description: 'Show GitHub identity', + access: 'read', + strategy: 'PUBLIC', + browser: false, + args: [], + columns: ['username'], + }], +}; + +function manifestResponse(): Response { + return new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }); +} + +const registry: ExternalCliConfig[] = [ + { name: 'gh', binary: 'gh', description: 'GitHub CLI' }, + { name: 'github', binary: 'gh-shadow', description: 'Shadows a hosted site name' }, + { name: 'agent-context', binary: 'agent-context', description: 'External with a retired Webcmd command name' }, + { name: 'profile', binary: 'profile-shadow', description: 'Shadows a Webcmd root command' }, + { name: 'site', binary: 'site-shadow', description: 'Shadows the Webcmd site root command' }, + { name: 'validate', binary: 'validate-shadow', description: 'Shadows a local-only Webcmd root command' }, + { name: 'antigravity', binary: 'antigravity-shadow', description: 'Shadows an optional Webcmd root command' }, +]; + +type RunFn = (name: string, args: string[], configs: ExternalCliConfig[]) => number; + +function harness(run: ReturnType>) { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => manifestResponse()); + return { + stdout, + stderr, + fetchImpl, + opts: { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + externals: { list: () => registry, run }, + }, + }; +} + +function ownershipHarness(withExternal: boolean) { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => manifestResponse()); + const list = vi.fn(() => registry); + const run = vi.fn(() => 0); + const getCredential = vi.fn(async () => 'key'); + const credentialStore: HostedCredentialStore = { + get: getCredential, + put: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + backend: () => 'os', + }; + const opts = { + config: makeHostedConfig({ + apiBaseUrl: 'https://api.example.com', + apiKeyRef: 'wcmd_ownership_collision', + credentialBackend: 'os', + }), + credentialStore, + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + ...(withExternal ? { externals: { list, run } } : {}), + }; + return { stdout, stderr, fetchImpl, list, run, getCredential, opts }; +} + +describe('hosted external CLI execution', () => { + it('spawns a registered external and returns its exit code', async () => { + const run = vi.fn(() => 3); + const h = harness(run); + + const result = await runHostedCli(['gh', 'pr', 'list', '--limit', '5'], h.opts); + + expect(run).toHaveBeenCalledWith('gh', ['pr', 'list', '--limit', '5'], registry); + expect(result).toEqual({ handled: true, exitCode: 3 }); + expect(h.stderr.text()).toBe(''); + }); + + it('forwards --version to the external instead of printing the webcmd version', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['gh', '--version'], h.opts); + + expect(run).toHaveBeenCalledWith('gh', ['--version'], registry); + expect(h.stdout.text()).toBe(''); + expect(result).toEqual({ handled: true, exitCode: 0 }); + }); + + it('sends nothing but the manifest request to Cloud', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + await runHostedCli(['gh', 'pr', 'list'], h.opts); + + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + expect(String(h.fetchImpl.mock.calls[0]![0])).toBe('https://api.example.com/v1/manifest'); + }); + + it('keeps an external suffix workspace flag out of Cloud request metadata', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + await runHostedCli(['gh', 'issue', 'list', '--workspace', 'external-only-value'], h.opts); + + expect(run).toHaveBeenCalledWith( + 'gh', + ['issue', 'list', '--workspace', 'external-only-value'], + registry, + ); + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = h.fetchImpl.mock.calls[0]!; + expect(String(url)).toBe('https://api.example.com/v1/manifest'); + expect(new Headers(init?.headers).get('x-webcmd-workspace')).toBeNull(); + expect(String(init?.body ?? '')).not.toContain('external-only-value'); + }); + + it.each([ + { name: 'split', tail: ['--session', 'child-session'] }, + { name: 'equals', tail: ['--session=child-session'] }, + ])('forwards a child-owned $name session flag to the external', async ({ tail }) => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['gh', 'issue', 'list', ...tail], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(run).toHaveBeenCalledWith('gh', ['issue', 'list', ...tail], registry); + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + expect(h.stderr.text()).toBe(''); + }); + + it('applies session validation when a hosted site shadows the registered external', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['github', 'whoami', '--session=child-session'], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(run).not.toHaveBeenCalled(); + expect(h.fetchImpl).toHaveBeenCalledTimes(1); + expect(String(h.fetchImpl.mock.calls[0]![0])).toBe('https://api.example.com/v1/manifest'); + expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); + }); + + it('validates a Webcmd root before a same-name external candidate', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['profile', 'list', '--session=child-session'], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(run).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); + }); + + it('keeps a local-only Webcmd root ahead of external fallback', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['validate'], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(run).not.toHaveBeenCalled(); + expect(h.stderr.text()).toContain('webcmd validate is local-only'); + }); + + it('keeps bare validate identical and side-effect free with or without a same-name external', async () => { + const variants = [ownershipHarness(false), ownershipHarness(true)]; + const results = []; + for (const variant of variants) { + results.push(await runHostedCli(['validate'], variant.opts)); + expect(variant.stdout.text()).toBe(''); + expect(variant.stderr.text()).toContain('webcmd validate is local-only'); + expect(variant.list).not.toHaveBeenCalled(); + expect(variant.run).not.toHaveBeenCalled(); + expect(variant.getCredential).not.toHaveBeenCalled(); + expect(variant.fetchImpl).not.toHaveBeenCalled(); + } + + expect(results).toEqual([ + { handled: true, exitCode: 78 }, + { handled: true, exitCode: 78 }, + ]); + expect(variants[1]!.stderr.text()).toBe(variants[0]!.stderr.text()); + }); + + it('keeps installed antigravity identical and side-effect free with or without a same-name external', async () => { + const variants = [ownershipHarness(false), ownershipHarness(true)]; + const results = []; + for (const variant of variants) { + results.push(await runHostedCli(['antigravity', 'serve'], { + ...variant.opts, + installedLocalCommandRoots: new Set(['antigravity']), + })); + expect(variant.stdout.text()).toBe(''); + expect(variant.stderr.text()).toContain('webcmd antigravity is local-only'); + expect(variant.list).not.toHaveBeenCalled(); + expect(variant.run).not.toHaveBeenCalled(); + expect(variant.getCredential).not.toHaveBeenCalled(); + expect(variant.fetchImpl).not.toHaveBeenCalled(); + } + + expect(results).toEqual([ + { handled: true, exitCode: 78 }, + { handled: true, exitCode: 78 }, + ]); + expect(variants[1]!.stderr.text()).toBe(variants[0]!.stderr.text()); + }); + + it('keeps site session validation identical and side-effect free with or without a same-name external', async () => { + const variants = [ownershipHarness(false), ownershipHarness(true)]; + const results = []; + for (const variant of variants) { + results.push(await runHostedCli(['site', 'list', '--session=child-session'], variant.opts)); + expect(variant.stdout.text()).toBe(''); + expect(variant.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); + expect(variant.list).not.toHaveBeenCalled(); + expect(variant.run).not.toHaveBeenCalled(); + expect(variant.getCredential).not.toHaveBeenCalled(); + expect(variant.fetchImpl).not.toHaveBeenCalled(); + } + + expect(results).toEqual([ + { handled: true, exitCode: 2 }, + { handled: true, exitCode: 2 }, + ]); + expect(variants[1]!.stderr.text()).toBe(variants[0]!.stderr.text()); + }); + + it('validates a Webcmd root before consulting a same-name external or hosted preflight', async () => { + const run = vi.fn(() => 0); + const list = vi.fn(() => registry); + const getCredential = vi.fn(async () => 'key'); + const credentialStore: HostedCredentialStore = { + get: getCredential, + put: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + backend: () => 'os', + }; + const h = harness(run); + + const result = await runHostedCli(['validate', '--session=child-session'], { + ...h.opts, + config: makeHostedConfig({ + apiBaseUrl: 'https://api.example.com', + apiKeyRef: 'wcmd_validate_collision', + credentialBackend: 'os', + }), + credentialStore, + externals: { list, run }, + }); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); + expect(list).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(getCredential).not.toHaveBeenCalled(); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + it('runs an external whose name is not a registered Webcmd root', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['agent-context', '--json'], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(run).toHaveBeenCalledWith('agent-context', ['--json'], registry); + expect(h.stderr.text()).toBe(''); + }); + + it.each([ + { name: 'direct', tail: ['--get-completions'] }, + { name: 'after separator', tail: ['--', '--get-completions'] }, + ])('forwards child-owned completion sentinel $name to the external', async ({ tail }) => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['gh', ...tail], h.opts); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(run).toHaveBeenCalledWith('gh', tail, registry); + expect(h.stderr.text()).toBe(''); + }); + + it('lets a hosted site win over an external of the same name', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + await runHostedCli(['github', 'whoami'], { + ...h.opts, + fetchImpl: async (url: RequestInfo | URL) => String(url).endsWith('/v1/manifest') + ? manifestResponse() + : new Response(JSON.stringify({ + ok: true, + result: [], + execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' }, + }), { status: 200 }), + }); + + expect(run).not.toHaveBeenCalled(); + }); + + it('still reports an unknown site when the name is neither', async () => { + const run = vi.fn(() => 0); + const h = harness(run); + + const result = await runHostedCli(['nonesuch', 'thing'], h.opts); + + expect(run).not.toHaveBeenCalled(); + expect(result.exitCode).toBe(2); + expect(h.stderr.text()).toContain('Site "nonesuch" is not installed.'); + }); +}); diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index 87139d1c..c30fe290 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -152,6 +152,97 @@ describe('hosted CLI process lifecycle', () => { expect(fixture.requests).toEqual([]); }, 20_000); + it('runs external list locally without contacting Cloud when hosted mode is configured', async () => { + const fixture = await createHostedFixture('success'); + + const result = await runCli(['external', 'list', '-f', 'json'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'gh', binary: 'gh' }), + ])); + expect(fixture.requests).toEqual([]); + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + + it.each([ + { name: 'profile', selectors: ['--profile', 'work'] }, + { name: 'workspace', selectors: ['--workspace', 'ws'] }, + ])('runs external list locally with a leading $name selector', async ({ selectors }) => { + const fixture = await createHostedFixture('success'); + + const result = await runCli([...selectors, 'external', 'list', '-f', 'json'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'gh', binary: 'gh' }), + ])); + expect(fixture.requests).toEqual([]); + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + + it('runs a registered external with its child arguments and exit code in hosted mode', async () => { + const fixture = await createHostedFixture('success'); + const registryPath = path.join(fixture.root, '.webcmd', 'external-clis.yaml'); + await writeFile(registryPath, JSON.stringify([{ + name: 'fixture-node', + binary: process.execPath, + }])); + + const result = await runCli([ + 'fixture-node', + '-e', + 'process.stdout.write(`external:${process.argv[1]}`); process.exit(3)', + 'child-value', + ], fixture.env); + + expect(result.status).toBe(3); + expect(result.stdout).toBe('external:child-value'); + expect(result.stderr).toBe(''); + expect(fixture.requests).toEqual(['GET /v1/manifest']); + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + + it.each([ + { name: 'split', tail: ['--session', 'child-session'] }, + { name: 'equals', tail: ['--session=child-session'] }, + ])('forwards a child-owned $name session flag to a registered external in local mode', async ({ tail }) => { + const fixture = await createLocalExternalFixture('fixture-node'); + + const result = await runCli(['fixture-node', fixture.script, ...tail], fixture.env); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(JSON.stringify(tail)); + expect(result.stderr).toBe(''); + }, 20_000); + + it.each([ + { installed: false, status: 0 }, + { installed: true, status: 78 }, + ])('treats optional antigravity as Webcmd-owned only when installed: $installed', async ({ installed, status }) => { + const fixture = await createHostedFixture('success'); + const script = await registerArgvExternal(fixture.root, 'antigravity'); + if (installed) { + const pluginDir = path.join(fixture.root, '.webcmd', 'plugins', 'antigravity'); + await mkdir(pluginDir, { recursive: true }); + await writeFile(path.join(pluginDir, 'serve.js'), 'export async function startServe() {}\n'); + } + + const result = await runCli(['antigravity', script, 'child-value'], fixture.env); + + expect(result.status).toBe(status); + expect(fixture.requests).toEqual(installed ? [] : ['GET /v1/manifest']); + if (installed) { + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('webcmd antigravity is local-only'); + } else { + expect(result.stdout).toBe(JSON.stringify(['child-value'])); + expect(result.stderr).toBe(''); + } + }, 20_000); + it.each([ { name: 'profile before skills', @@ -430,6 +521,42 @@ async function createLocalStartupPluginFixture(): Promise<{ root: string; env: N }; } +async function createLocalExternalFixture(name: string): Promise<{ + root: string; + env: NodeJS.ProcessEnv; + script: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), 'webcmd-local-external-')); + tempRoots.push(root); + const configDir = path.join(root, 'config'); + await mkdir(configDir, { recursive: true }); + await writeFile(path.join(configDir, 'config.json'), '{"mode":"local"}\n'); + const script = await registerArgvExternal(root, name); + return { + root, + script, + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + WEBCMD_CONFIG_DIR: configDir, + WEBCMD_NO_UPDATE_CHECK: '1', + }, + }; +} + +async function registerArgvExternal(root: string, name: string): Promise { + const registryDir = path.join(root, '.webcmd'); + const script = path.join(root, `${name}-argv.mjs`); + await mkdir(registryDir, { recursive: true }); + await writeFile(path.join(registryDir, 'external-clis.yaml'), JSON.stringify([{ + name, + binary: process.execPath, + }])); + await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + return script; +} + function sendChunkedJson(response: import('node:http').ServerResponse, value: unknown, status = 200): void { const body = JSON.stringify(value); const split = Math.floor(body.length / 2); diff --git a/src/hosted/programmatic.test.ts b/src/hosted/programmatic.test.ts index f519b288..00675af5 100644 --- a/src/hosted/programmatic.test.ts +++ b/src/hosted/programmatic.test.ts @@ -1,5 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { runHostedProgrammatic } from './programmatic.js'; +import { PKG_VERSION } from '../version.js'; + +const externalDefaults = vi.hoisted(() => ({ + list: vi.fn(() => [{ name: 'gh', binary: 'gh' }]), + run: vi.fn(() => 0), +})); + +vi.mock('../external.js', () => ({ + loadExternalClis: externalDefaults.list, + executeExternalCli: externalDefaults.run, +})); const manifest = { userId: 'u1', @@ -250,6 +261,24 @@ describe('runHostedProgrammatic', () => { expect(urls.some((url) => url.includes('/mcp'))).toBe(false); }); + it('does not load or execute local externals from the programmatic runner', async () => { + externalDefaults.list.mockClear(); + externalDefaults.run.mockClear(); + + const result = await runHostedProgrammatic({ + argv: ['gh', '--version'], + apiBaseUrl: 'http://127.0.0.1:8787', + accessToken: 't', + fetchImpl: fakeCloud(), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`${PKG_VERSION}\n`); + expect(result.stderr).toBe(''); + expect(externalDefaults.list).not.toHaveBeenCalled(); + expect(externalDefaults.run).not.toHaveBeenCalled(); + }); + it('truncates oversized stdout without failing the invocation', async () => { const big = 'x'.repeat(300 * 1024); const result = await runHostedProgrammatic({ diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 600a7922..24acc657 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -348,10 +348,15 @@ describe('hosted root command surface', () => { ); it.each(profileForms.flatMap(profile => dispatchForms.map(dispatch => ({ profile, dispatch }))))( - 'gives completion priority across $profile.name/$dispatch.name', + 'leaves a child-owned completion sentinel with $profile.name/$dispatch.name', ({ profile, dispatch }) => { const argv = [...profile.tokens, ...dispatch.tokens, '--get-completions']; - expect(parseHostedRootCommandSurface(argv)).toEqual({ kind: 'completion', argv }); + expect(parseHostedRootCommandSurface(argv)).toEqual({ + kind: 'dispatch', + argv: [...dispatch.tokens, '--get-completions'], + ...(profile.profile !== undefined ? { profile: profile.profile } : {}), + literal: false, + }); }, ); @@ -360,10 +365,8 @@ describe('hosted root command surface', () => { { name: 'beats help', argv: ['--help', '--get-completions'] }, { name: 'beats unknown', argv: ['--unknown', '--get-completions'] }, { name: 'beats missing profile', argv: ['--profile', '--get-completions'] }, - { name: 'after separator', argv: ['--', '--get-completions'] }, - { name: 'after site tokens', argv: ['github', 'whoami', '--get-completions'] }, { name: 'beats non-fast clustered version', argv: ['-Vx', '--get-completions'] }, - ])('matches the local main completion fast-path: $name', ({ argv }) => { + ])('recognizes completion on the Webcmd root surface: $name', ({ argv }) => { expect(parseHostedRootCommandSurface(argv)).toEqual({ kind: 'completion', argv }); }); @@ -416,7 +419,7 @@ describe('hosted root preflight call order', () => { ['--help', '--get-completions'], ['--unknown', '--get-completions'], ['--profile', '--get-completions'], - ['--', '--get-completions'], + ['--get-completions', '--'], ['-Vx', '--get-completions'], ])('completion preflight performs exactly one manifest request and no execute: %j', async (...argv) => { const stdout = sink(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 0206b3ad..bf47d057 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -18,12 +18,14 @@ import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } f import { getHostedBuiltinCommands, getHostedRootHelp, + HOSTED_ROOT_HELP, isLocalClientRootCommand, LOCAL_ONLY_COMMAND_HELP, } from '../completion-shared.js'; import { splitAdapterCommandKey } from '../adapter-source.js'; import { ArgumentError, CliError, ConfigError, EXIT_CODES, InterruptedError, toEnvelope } from '../errors.js'; import { getRequestedHelpFormat, renderStructuredHelp } from '../help.js'; +import { WEBCMD_ROOT_COMMANDS } from '../hooks.js'; import { enableVerbose } from '../logger.js'; import { findPackageRoot } from '../package-paths.js'; import { errorEnvelopeFormat, formatErrorEnvelope, requestedFormatFromArgv, requestedMachineFormat, render as renderOutput } from '../output.js'; @@ -36,6 +38,7 @@ import { BrowserRunError } from '../browser/run/types.js'; import { CLI_COMMAND } from '../brand.js'; import { formatPluginSearchEmptyCopy, presentPluginSearch } from '../plugin-search-presentation.js'; import { missingPluginGuidance } from '../discovery.js'; +import type { ExternalCliConfig } from '../external.js'; import { webFetchCommand } from '../fetch/command.js'; import { runHostedArtifactDownload } from './artifact-download.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; @@ -100,6 +103,13 @@ export interface HostedRunnerOptions { files?: VirtualFileMap; /** When set, every file write lands here instead of the filesystem. */ outputs?: VirtualOutputSink; + /** Explicitly grants access to the installed client's external registry and executor. */ + externals?: { + list(): ExternalCliConfig[]; + run(name: string, args: string[], configs: ExternalCliConfig[]): number; + }; + /** Optional local roots that are owned only when installed on this client. */ + installedLocalCommandRoots?: ReadonlySet; } interface HostedDispatchIo { @@ -121,6 +131,12 @@ interface TrustedCommandResolution { accessClass: 'read' | 'write'; } +interface DeferredExternalSession { + error: BrowserSessionArgvError; + args: string[]; + configs: ExternalCliConfig[]; +} + class CommanderCompatibleError extends Error { constructor( readonly output: string, @@ -153,7 +169,28 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const stderr = opts.stderr ?? process.stderr; try { - argv = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(argv)); + argv = rejectPositionalBrowserSessionArgv(argv); + let deferredExternalSession: DeferredExternalSession | undefined; + try { + argv = rejectMisplacedSessionSelectorArgv(argv); + } catch (error) { + if (!(error instanceof BrowserSessionArgvError) || !opts.externals) throw error; + const root = parseHostedRootCommandSurface(argv); + if (root.kind !== 'dispatch') throw error; + const [site, ...args] = root.argv; + if (!site || isWebcmdOwnedRoot(site, opts.installedLocalCommandRoots)) throw error; + const configs = opts.externals.list(); + if (!configs.some(config => config.name === site)) throw error; + deferredExternalSession = { error, args, configs }; + } + const rootSurface = parseHostedRootCommandSurface(argv); + const rootName = rootSurface.kind === 'dispatch' ? rootSurface.argv[0] : undefined; + if (rootName === 'validate' || (rootName && opts.installedLocalCommandRoots?.has(rootName))) { + throw new ConfigError(`${CLI_COMMAND} ${rootName} is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); + } + const externals = rootName && isWebcmdOwnedRoot(rootName, opts.installedLocalCommandRoots) + ? undefined + : opts.externals; const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, env: opts.env, @@ -168,7 +205,8 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const client = new HostedClient({ apiBaseUrl: config.hosted.apiBaseUrl, apiKey: credential.apiKey, - workspace: resolveWorkspace(argv, opts.env ?? process.env), + workspace: (rootSurface.kind === 'dispatch' ? rootSurface.workspace : undefined) + ?? resolveWorkspace([], opts.env ?? process.env), fetchImpl: opts.fetchImpl, ...(opts.signal ? { signal: opts.signal } : {}), }); @@ -183,7 +221,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { fileIo, ...(usesVirtualFileIo ? { virtualScaffold: { files: virtualFiles, outputs: virtualOutputs } } : {}), }; - await dispatchHosted( + const exitCode = await dispatchHosted( argv, client, stdout, @@ -195,8 +233,11 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.hasLocalClientCommandHandlers !== false, opts.signal, opts.onTrustedCommandResolution, + externals, + opts.installedLocalCommandRoots, + deferredExternalSession, ); - return { handled: true, exitCode: EXIT_CODES.SUCCESS }; + return { handled: true, exitCode: exitCode ?? EXIT_CODES.SUCCESS }; } catch (caught) { if (caught instanceof StreamWriteError) throw caught; const err = opts.signal?.aborted ? new InterruptedError() : caught; @@ -258,7 +299,13 @@ async function dispatchHosted( hasLocalClientCommandHandlers = true, signal?: AbortSignal, onResolvedCommand?: (resolution: TrustedCommandResolution) => void, -): Promise { + externals?: { + list(): ExternalCliConfig[]; + run(name: string, args: string[], configs: ExternalCliConfig[]): number; + }, + installedLocalCommandRoots?: ReadonlySet, + deferredExternalSession?: DeferredExternalSession, +): Promise { const rootHelp = getHostedRootHelp(hasLocalClientCommandHandlers); const normalized = parseHostedRootCommandSurface(argv); if (normalized.kind === 'help') { @@ -503,7 +550,25 @@ async function dispatchHosted( const site = args[0]!; const commandName = args[1]; const siteExists = manifest.commands.some(command => command.site === site); + if (siteExists && deferredExternalSession) throw deferredExternalSession.error; if (!siteExists) { + // Externals are local binaries, not adapters: registry lookup, PATH check, + // spawn. Nothing reaches Cloud. This runs before parseUnknownSiteRootOptions + // so `webcmd gh --version` forwards --version to gh, matching the local + // passThroughOptions() behavior instead of printing the webcmd version. + if (externals) { + const externalConfigs = deferredExternalSession?.configs ?? externals.list(); + if (externalConfigs.some(config => config.name === site)) { + if (isWebcmdOwnedRoot(site, installedLocalCommandRoots)) { + throw new ConfigError(`${CLI_COMMAND} ${site} is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); + } + return externals.run( + site, + deferredExternalSession?.args ?? args.slice(1), + externalConfigs, + ); + } + } const unknownRoot = parseUnknownSiteRootOptions(args, normalized.literal); if (unknownRoot.version) { await writeToStream(stdout, `${PKG_VERSION}\n`); @@ -1620,6 +1685,15 @@ function parseUnknownSiteRootOptions( return { help, version: false, ...(profile !== undefined ? { profile } : {}) }; } +function isWebcmdOwnedRoot(name: string, installedLocalCommandRoots?: ReadonlySet): boolean { + return isUnconditionalWebcmdRoot(name) || installedLocalCommandRoots?.has(name) === true; +} + +function isUnconditionalWebcmdRoot(name: string): boolean { + return WEBCMD_ROOT_COMMANDS.has(name) + || HOSTED_ROOT_HELP.commands.some(command => command.name.split(/\s/, 1)[0] === name); +} + function hasTerminalBeforeSeparator( argv: readonly string[], predicate: (token: string) => boolean, diff --git a/src/main.ts b/src/main.ts index 25b4f3b3..4c782007 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,7 @@ import { PKG_VERSION } from './version.js'; import { EXIT_CODES } from './errors.js'; import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_MAJOR } from './runtime-detect.js'; import { CONFIG_DIR_NAME } from './brand.js'; -import { parseHostedRootCommandSurface } from './root-command-surface.js'; +import { configureHostedWorkspaceOption, parseHostedRootCommandSurface, rootCompletionSentinelIndex } from './root-command-surface.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -36,7 +36,6 @@ const USER_PLUGINS = path.join(os.homedir(), CONFIG_DIR_NAME, 'plugins'); // ── Ultra-fast path: lightweight commands bypass full discovery ────────── // These are high-frequency or trivial paths that must not pay the startup tax. const argv = process.argv.slice(2); -const normalizedRootArgv = normalizedRootArgvFor(argv); if (typeof (globalThis as { Bun?: unknown }).Bun === 'undefined' && !isSupportedNodeVersion(process.version)) { process.stderr.write( @@ -73,24 +72,43 @@ if (!fastPathHandled && argv[0] === 'completion' && argv.length >= 2) { // memory just to decide what commands exist. Awaiting the selected branch and // assigning exitCode lets Node flush pending stdout/stderr before shutdown. if (!fastPathHandled) { + const rootSurface = parseRootSurface(argv); if (argv[0] === 'setup') { const { runHostedSetup } = await import('./hosted/setup.js'); process.exitCode = await runHostedSetup({ argv: argv.slice(1) }); - } else if (normalizedRootArgv?.[0] === 'skills' || normalizedRootArgv?.[0] === 'update') { + } else if ( + rootSurface?.kind === 'dispatch' + && (rootSurface.argv[0] === 'skills' + || rootSurface.argv[0] === 'update' + || rootSurface.argv[0] === 'external') + ) { const { createProgram } = await import('./cli.js'); - await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' }); - } else if (isWebFetch(normalizedRootArgv)) { + const program = createProgram(BUILTIN_CLIS, USER_CLIS); + if (rootSurface?.kind === 'dispatch' && rootSurface.workspace !== undefined) { + configureHostedWorkspaceOption(program); + } + await program.parseAsync(argv, { from: 'user' }); + } else if ( + rootSurface?.kind === 'dispatch' + && rootSurface.argv[0] === 'web' + && rootSurface.argv[1] === 'fetch' + ) { const { runWebFetchCommand } = await import('./fetch/command.js'); await runWebFetchCommand(argv); } else { const { shouldUseHostedMode } = await import('./hosted/config.js'); if (shouldUseHostedMode()) { const { runHostedCli } = await import('./hosted/runner.js'); + const { executeExternalCli, loadExternalClis } = await import('./external.js'); // The installed CLI already owns local web/fetch transport authority. // Programmatic embedders remain opt-in and default to no network access. const result = await runHostedCli(argv, { enableServerWebFetch: true, hasLocalClientCommandHandlers: true, + externals: { list: loadExternalClis, run: executeExternalCli }, + installedLocalCommandRoots: fs.existsSync(path.join(USER_PLUGINS, 'antigravity', 'serve.js')) + ? new Set(['antigravity']) + : undefined, }); process.exitCode = result.exitCode; } else { @@ -105,22 +123,17 @@ if (!fastPathHandled) { } } -function normalizedRootArgvFor(args: readonly string[]): string[] | undefined { +function parseRootSurface(args: readonly string[]) { try { - const parsed = parseHostedRootCommandSurface(args); - return parsed.kind === 'dispatch' ? parsed.argv : undefined; + return parseHostedRootCommandSurface(args); } catch { return undefined; } } -function isWebFetch(args: readonly string[] | undefined): boolean { - return args?.[0] === 'web' && args[1] === 'fetch'; -} - async function runLocalMain(): Promise { // Fast path: --get-completions — read from manifest, skip discovery -const getCompIdx = process.argv.indexOf('--get-completions'); +const getCompIdx = rootCompletionSentinelIndex(argv); if (getCompIdx !== -1) { // Only include manifests that actually exist on disk. // With sparse override, the user clis dir may exist but have no manifest. @@ -131,7 +144,7 @@ if (getCompIdx !== -1) { const userManifest = getCliManifestPath(USER_CLIS); try { fs.accessSync(userManifest); manifestPaths.push(userManifest); } catch { uncoveredCommandRoots.push(USER_CLIS); } if (hasAllManifests(manifestPaths, uncoveredCommandRoots)) { - const rest = process.argv.slice(getCompIdx + 1); + const rest = argv.slice(getCompIdx + 1); let cursor: number | undefined; const words: string[] = []; for (let i = 0; i < rest.length; i++) { @@ -154,7 +167,7 @@ if (getCompIdx !== -1) { // Dynamic imports: these are deferred so the fast path above never pays the cost. const { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } = await import('./discovery.js'); const { getCompletions } = await import('./completion.js'); -const { runCli } = await import('./cli.js'); +const { createProgram, isExternalRootCommand, runCli } = await import('./cli.js'); const { emitHook, shouldEmitStartupHook, shouldRunStartupSideEffects } = await import('./hooks.js'); const { installNodeNetwork } = await import('./node-network.js'); const { registerUpdateNoticeOnExit, checkForUpdateBackground } = await import('./update-check.js'); @@ -190,7 +203,7 @@ if (runStartupSideEffects) { // ── Fallback completion: manifest unavailable, use full registry ───────── if (getCompIdx !== -1) { - const rest = process.argv.slice(getCompIdx + 1); + const rest = argv.slice(getCompIdx + 1); let cursor: number | undefined; const words: string[] = []; for (let i = 0; i < rest.length; i++) { @@ -208,8 +221,13 @@ if (getCompIdx !== -1) { } const { rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); +const program = createProgram(BUILTIN_CLIS, USER_CLIS); try { - let rewritten = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(process.argv.slice(2))); + let rewritten = rejectPositionalBrowserSessionArgv(process.argv.slice(2)); + const rootSurface = parseRootSurface(rewritten); + if (!(rootSurface?.kind === 'dispatch' && isExternalRootCommand(program, rootSurface.argv[0]))) { + rewritten = rejectMisplacedSessionSelectorArgv(rewritten); + } // Use the metadata that discovery actually registered. The core manifest is // intentionally empty, while installed plugins and legacy user CLIs are not. const { getRegistry } = await import('./registry.js'); @@ -226,5 +244,5 @@ try { if (shouldEmitStartupHook(argv)) { await emitHook('onStartup', { command: '__startup__', args: {} }); } -await runCli(BUILTIN_CLIS, USER_CLIS); +await runCli(BUILTIN_CLIS, USER_CLIS, program); } diff --git a/src/root-command-surface.ts b/src/root-command-surface.ts index e68f7690..27fdf8fc 100644 --- a/src/root-command-surface.ts +++ b/src/root-command-surface.ts @@ -7,6 +7,8 @@ export const ROOT_PROFILE_DESCRIPTION = 'Chrome profile/context alias for browse export const ROOT_SESSION_FLAGS = '--session '; export const ROOT_SESSION_DESCRIPTION = 'Existing readable Session ID from `webcmd session create `'; export const ROOT_SESSION_SELECTOR_POSITION = 'root'; +const ROOT_WORKSPACE_FLAGS = '--workspace '; +const ROOT_WORKSPACE_DESCRIPTION = 'Hosted workspace id/slug for the request'; export const COMPLETION_SENTINEL = '--get-completions'; /** @@ -22,11 +24,15 @@ export function configureRootCommandSurface(program: Command): Command { .enablePositionalOptions(); } +export function configureHostedWorkspaceOption(program: Command): Command { + return program.option(ROOT_WORKSPACE_FLAGS, ROOT_WORKSPACE_DESCRIPTION); +} + export type HostedRootCommandSurface = | { kind: 'help'; exitCode: number } | { kind: 'version'; output: string } | { kind: 'completion'; argv: string[] } - | { kind: 'dispatch'; argv: string[]; profile?: string; session?: string; literal: boolean }; + | { kind: 'dispatch'; argv: string[]; profile?: string; session?: string; workspace?: string; literal: boolean }; /** * Parse only the root command surface without registering or discovering local @@ -40,21 +46,20 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo if (input[0] === '--version' || input[0] === '-V') { return { kind: 'version', output: `${PKG_VERSION}\n` }; } - // The local completion path scans the complete raw argv before discovery or - // Commander parsing, including after `--` and malformed root options. - if (input.includes(COMPLETION_SENTINEL)) { + // Completion is a Webcmd root sentinel. Once a command or `--` begins the + // child surface, the same token belongs to that command and must be kept. + if (rootCompletionSentinelIndex(input) !== -1) { return { kind: 'completion', argv: input }; } let stdout = ''; let stderr = ''; const boundary = findRootCommandBoundary(input); - const root = configureRootCommandSurface(new Command('webcmd')) + const root = configureHostedWorkspaceOption(configureRootCommandSurface(new Command('webcmd'))) // Hosted-only: registered here (not in the shared configureRootCommandSurface) // so the local CLI surface is unaffected. Lets Commander's structural parse // consume `--workspace ` before the site/command token instead of // throwing an unknown-option error. - .option('--workspace ', 'Hosted workspace id/slug for the request') .exitOverride() .configureOutput({ writeOut: value => { stdout += value; }, @@ -89,7 +94,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo throw structuralErrorFromCommander(error, root, stderr); } - const { profile, session } = root.opts<{ profile?: string; session?: string }>(); + const { profile, session, workspace } = root.opts<{ profile?: string; session?: string; workspace?: string }>(); if (boundary.commandIndex === undefined && boundary.separatorIndex === undefined) return { kind: 'help', exitCode: 1 }; const literal = boundary.separatorIndex !== undefined; const parsedArgv = boundary.commandIndex !== undefined @@ -101,6 +106,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo argv: parsedArgv, ...(profile !== undefined ? { profile } : {}), ...(session !== undefined ? { session } : {}), + ...(workspace !== undefined ? { workspace } : {}), literal, }; } @@ -110,6 +116,14 @@ interface RootCommandBoundary { separatorIndex?: number; } +export function rootCompletionSentinelIndex(argv: readonly string[]): number { + const index = argv.indexOf(COMPLETION_SENTINEL); + if (index === -1) return -1; + const boundary = findRootCommandBoundary(argv); + const childIndex = boundary.commandIndex ?? boundary.separatorIndex; + return childIndex === undefined || index < childIndex ? index : -1; +} + /** Locates the undiscovered command token while respecting root value options. */ function findRootCommandBoundary(argv: readonly string[]): RootCommandBoundary { for (let index = 0; index < argv.length; index += 1) {