From 1e4d03be702ab2eb3033760adfb2c73815dad434 Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:58:02 +0530 Subject: [PATCH] fix: format CliError/ArgumentError instead of leaking a raw stack trace in web fetch runClientOwnedWebFetch runs on the client-owned fast path in main.ts, outside the try/catch every other command routes through in commanderAdapter.ts. Any CliError it threw (e.g. FETCH_BLOCKED) or ArgumentError from clientOptions() escaped straight to Node's default uncaught-exception handler instead of the structured error envelope the rest of the CLI uses. Fixes #246 --- src/fetch/command.test.ts | 22 ++++++++++++++++++++++ src/fetch/command.ts | 14 ++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/fetch/command.test.ts b/src/fetch/command.test.ts index 5b9823d2..eca2a703 100644 --- a/src/fetch/command.test.ts +++ b/src/fetch/command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { formatWebFetchMarkdown, runClientOwnedWebFetch } from './command.js'; +import { CliError } from '../errors.js'; describe('web fetch command', () => { it('renders fetch metadata before content', () => { @@ -10,4 +11,25 @@ describe('web fetch command', () => { await runClientOwnedWebFetch(['web', 'fetch', '--url', 'https://a'], { webFetch, stdout: { write: vi.fn() } as never }); expect(webFetch).toHaveBeenCalledOnce(); }); + it('formats a thrown CliError instead of letting it escape as a raw stack trace', async () => { + const webFetch = vi.fn().mockRejectedValue(new CliError('FETCH_BLOCKED', 'The site blocked non-browser fetches.', 'Use webcmd web fetch-browser for this URL.', 1)); + const write = vi.fn(); + const priorExitCode = process.exitCode; + await runClientOwnedWebFetch(['web', 'fetch', '--url', 'https://a'], { webFetch, stderr: { write } as never }); + const output = write.mock.calls.map((call) => String(call[0])).join(''); + expect(output).toContain('FETCH_BLOCKED'); + expect(output).toContain('Use webcmd web fetch-browser for this URL.'); + expect(process.exitCode).toBe(1); + process.exitCode = priorExitCode; + }); + it('formats an ArgumentError from bad flags instead of letting it escape as a raw stack trace', async () => { + const write = vi.fn(); + const priorExitCode = process.exitCode; + await runClientOwnedWebFetch(['web', 'fetch', '--url', 'not-a-url'], { stderr: { write } as never }); + const output = write.mock.calls.map((call) => String(call[0])).join(''); + expect(output).toContain('ARGUMENT'); + expect(output).toContain('--url must be an http or https URL'); + expect(process.exitCode).toBe(2); + process.exitCode = priorExitCode; + }); }); diff --git a/src/fetch/command.ts b/src/fetch/command.ts index 103e8487..b0fef9af 100644 --- a/src/fetch/command.ts +++ b/src/fetch/command.ts @@ -1,5 +1,5 @@ import { cli, Strategy } from '../registry.js'; -import { ArgumentError } from '../errors.js'; +import { ArgumentError, CliError, EXIT_CODES, toEnvelope } from '../errors.js'; import { webFetch, type WebFetchOptions, type WebFetchResult } from './client.js'; export const webFetchCommand = cli({ @@ -31,7 +31,13 @@ function clientOptions(argv: readonly string[]): WebFetchOptions { return { url: values.url, timeoutSeconds: int('timeout', 30), maxChars: int('max-chars', 50000), allowPrivate: values['allow-private'] === true || values['allow-private'] === 'true' }; } -export async function runClientOwnedWebFetch(argv: readonly string[], dependencies: { webFetch?: typeof webFetch; stdout?: NodeJS.WritableStream } = {}): Promise { - const result = await (dependencies.webFetch ?? webFetch)(clientOptions(argv)); - (dependencies.stdout ?? process.stdout).write(`${formatWebFetchMarkdown(result)}\n`); +export async function runClientOwnedWebFetch(argv: readonly string[], dependencies: { webFetch?: typeof webFetch; stdout?: NodeJS.WritableStream; stderr?: NodeJS.WritableStream } = {}): Promise { + try { + const result = await (dependencies.webFetch ?? webFetch)(clientOptions(argv)); + (dependencies.stdout ?? process.stdout).write(`${formatWebFetchMarkdown(result)}\n`); + } catch (err) { + const { formatErrorEnvelope } = await import('../output.js'); + (dependencies.stderr ?? process.stderr).write(formatErrorEnvelope(toEnvelope(err), { cmdName: 'web/fetch' })); + process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR; + } }