From 07366bdcf9a9df7f56c6e2de8c7bc4773a63bf36 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 19 Aug 2026 20:28:51 +0530 Subject: [PATCH] fix(cli): honour -f json on the error envelope Built-in and adapter failures still go to stderr, but a requested JSON format is no longer dumped as YAML. Other formats keep the YAML envelope. --- src/cli-error-report.test.ts | 14 ++++++++++ src/cli.ts | 6 +++-- src/commanderAdapter.test.ts | 25 ++++++++++++++++++ src/commanderAdapter.ts | 8 +++--- src/hosted/runner.test.ts | 17 ++++++++++++ src/hosted/runner.ts | 3 ++- src/output.test.ts | 51 +++++++++++++++++++++++++++++------- src/output.ts | 20 ++++++++++++++ 8 files changed, 127 insertions(+), 17 deletions(-) diff --git a/src/cli-error-report.test.ts b/src/cli-error-report.test.ts index f8ef3ddc..cf61ee23 100644 --- a/src/cli-error-report.test.ts +++ b/src/cli-error-report.test.ts @@ -37,6 +37,20 @@ describe('reportCliError', () => { expect(process.exitCode).toBe(EXIT_CODES.GENERIC_ERROR); }); + it('renders JSON when -f json is on argv', () => { + const previous = process.argv; + process.argv = ['node', 'webcmd', 'validate', 'nope', '-f', 'json']; + try { + expect(JSON.parse(capture(new CliError('ARGUMENT', 'No command matches "nope".', undefined, EXIT_CODES.USAGE_ERROR)))) + .toEqual({ + ok: false, + error: { code: 'ARGUMENT', message: 'No command matches "nope".', exitCode: EXIT_CODES.USAGE_ERROR }, + }); + } finally { + process.argv = previous; + } + }); + it('omits the stack unless WEBCMD_DEBUG is set', () => { expect(yaml.load(capture(new Error('boom'))) as any).not.toHaveProperty('error.stack'); vi.stubEnv('WEBCMD_DEBUG', '1'); diff --git a/src/cli.ts b/src/cli.ts index 6f9e3bde..36d17c5f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import './fetch/command.js'; import { commandListPresentation, filterCommandsByTag, toPresentableCommand } from './command-presentation.js'; import { configureCompletionCommandSurface, configureListCommandSurface, configurePluginInstallSurface, configurePluginListSurface, configurePluginSearchSurface } from './builtin-command-surface.js'; import { OUTPUT_FORMAT_HELP, resolveOutputFormat } from './command-surface.js'; -import { render as renderOutput, formatErrorEnvelope } from './output.js'; +import { render as renderOutput, formatErrorEnvelope, errorEnvelopeFormat, requestedFormatFromArgv } from './output.js'; import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js'; @@ -2201,7 +2201,9 @@ export function reportCliError(err: unknown, stderr: NodeJS.WritableStream = pro if (process.env.WEBCMD_DEBUG && err instanceof Error && err.stack) { envelope.error.stack = err.stack; } - stderr.write(formatErrorEnvelope(envelope)); + stderr.write(formatErrorEnvelope(envelope, { + fmt: errorEnvelopeFormat(requestedFormatFromArgv(process.argv.slice(2))), + })); process.exitCode = envelope.error.exitCode; } diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index b8baf798..84dc894a 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -382,6 +382,31 @@ describe('commanderAdapter error envelope output', () => { stderrSpy.mockRestore(); }); + it('outputs a JSON error envelope on stderr when -f json is set', async () => { + const program = new Command(); + const siteCmd = program.command('github'); + registerCommandToProgram(siteCmd, cmd); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockExecuteCommand.mockRejectedValueOnce( + new EmptyResultError( + 'github/issue', + 'Pass the full issue URL instead of a bare issue number.', + ), + ); + + await program.parseAsync(['node', 'webcmd', 'github', 'issue', '12345', '-f', 'json']); + + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(JSON.parse(output)).toMatchObject({ + ok: false, + error: { code: 'EMPTY_RESULT' }, + }); + expect(output).not.toContain('# AutoFix'); + + stderrSpy.mockRestore(); + }); + it('outputs YAML error envelope for selector errors', async () => { const program = new Command(); const siteCmd = program.command('github'); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index bed9899c..6624fd62 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -13,7 +13,7 @@ import { Command } from 'commander'; import { log } from './logger.js'; import { type CliCommand, fullName, getRegistry } from './registry.js'; -import { formatErrorEnvelope, render as renderOutput } from './output.js'; +import { errorEnvelopeFormat, formatErrorEnvelope, render as renderOutput } from './output.js'; import { configureCommandSurface, parseOutputFormat, prepareCommandArgs } from './command-surface.js'; import { commandHelpData, @@ -135,7 +135,7 @@ export function registerCommandToProgram( ...(runtime.stdout ? { stdout: runtime.stdout } : {}), }); } catch (err) { - renderError(err, fullName(cmd), optionsRecord.verbose === true, optionsRecord.trace); + renderError(err, fullName(cmd), optionsRecord.verbose === true, optionsRecord.trace, optionsRecord.format); process.exitCode = resolveExitCode(err); } }); @@ -150,7 +150,7 @@ function resolveExitCode(err: unknown): number { // ── Error rendering ───────────────────────────────────────────────────────── -function renderError(err: unknown, cmdName: string, verbose: boolean, traceMode?: unknown): void { +function renderError(err: unknown, cmdName: string, verbose: boolean, traceMode?: unknown, fmt?: unknown): void { const envelope = toEnvelope(err); // In verbose mode, include stack trace for debugging @@ -158,7 +158,7 @@ function renderError(err: unknown, cmdName: string, verbose: boolean, traceMode? envelope.error.stack = err.stack; } - process.stderr.write(formatErrorEnvelope(envelope, { cmdName, traceMode })); + process.stderr.write(formatErrorEnvelope(envelope, { cmdName, traceMode, fmt: errorEnvelopeFormat(fmt) })); } /** diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 888def09..59469e47 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -516,6 +516,23 @@ describe('runHostedCli', () => { expect(stderr.text()).toContain('Run `webcmd setup` and choose local mode to install this plugin.'); }); + it('renders a JSON error envelope on stderr when -f json is set', async () => { + const stdout = sink(); + const stderr = sink(); + const result = await runHostedCli(['profile', 'use', 'work', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + }); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(stdout.text()).toBe(''); + expect(JSON.parse(stderr.text())).toMatchObject({ + ok: false, + error: { code: 'CONFIG', message: 'webcmd profile use is not available in hosted mode.' }, + }); + }); + it.each(['catalog'])('rejects unsupported hosted plugin %s without an API call', async (subcommand) => { const stderr = sink(); const fetchImpl = vi.fn(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 159c0c9a..d45b0629 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -24,7 +24,7 @@ import { CliError, ConfigError, EXIT_CODES, toEnvelope } from '../errors.js'; import { getRequestedHelpFormat, renderStructuredHelp } from '../help.js'; import { enableVerbose } from '../logger.js'; import { findPackageRoot } from '../package-paths.js'; -import { formatErrorEnvelope, render as renderOutput } from '../output.js'; +import { errorEnvelopeFormat, formatErrorEnvelope, requestedFormatFromArgv, render as renderOutput } from '../output.js'; import { StreamWriteError, writeToStream } from '../stream-write.js'; import { PKG_VERSION } from '../version.js'; import { getCompletionScriptFast } from '../completion-fast.js'; @@ -151,6 +151,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { await writeToStream(stderr, formatErrorEnvelope(toEnvelope(err), { cmdName: hostedCommandName(argv), traceMode: hostedTraceMode(argv), + fmt: errorEnvelopeFormat(requestedFormatFromArgv(argv)), })); return { handled: true, diff --git a/src/output.test.ts b/src/output.test.ts index ea0f5d57..5d4f6b20 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { Writable } from 'node:stream'; import { describe, expect, it } from 'vitest'; -import { formatErrorEnvelope, formatOutput, render } from './output.js'; +import { formatErrorEnvelope, formatOutput, render, requestedFormatFromArgv } from './output.js'; function sink(isTTY: boolean): { stream: Writable; text: () => string } { let data = ''; @@ -140,16 +140,18 @@ describe('formatOutput', () => { }); describe('formatErrorEnvelope', () => { + const envelope = { + ok: false as const, + error: { + code: 'AUTH_REQUIRED', + message: 'Sign in first', + help: 'Run webcmd github login.', + exitCode: 77, + }, + }; + it('returns the local YAML envelope bytes without writing to stderr', () => { - expect(formatErrorEnvelope({ - ok: false, - error: { - code: 'AUTH_REQUIRED', - message: 'Sign in first', - help: 'Run webcmd github login.', - exitCode: 77, - }, - })).toBe([ + expect(formatErrorEnvelope(envelope)).toBe([ 'ok: false', 'error:', ' code: AUTH_REQUIRED', @@ -159,4 +161,33 @@ describe('formatErrorEnvelope', () => { '', ].join('\n')); }); + + it('serializes JSON when -f json was requested', () => { + expect(formatErrorEnvelope(envelope, { fmt: 'json' })).toBe(`${JSON.stringify(envelope, null, 2)}\n`); + }); + + it('keeps YAML for table and omitted formats', () => { + expect(formatErrorEnvelope(envelope, { fmt: 'table' })).toMatch(/^ok: false\nerror:/); + }); + + it('omits YAML AutoFix comments from JSON envelopes', () => { + const text = formatErrorEnvelope({ + ok: false, + error: { code: 'EMPTY_RESULT', message: 'none', exitCode: 66 }, + }, { fmt: 'json', cmdName: 'github/issue' }); + expect(text).not.toContain('# AutoFix'); + expect(JSON.parse(text)).toMatchObject({ ok: false, error: { code: 'EMPTY_RESULT' } }); + }); +}); + +describe('requestedFormatFromArgv', () => { + it.each([ + { argv: ['list', '-f', 'json'], format: 'json' }, + { argv: ['list', '--format', 'JSON'], format: 'JSON' }, + { argv: ['list', '--format=yaml'], format: 'yaml' }, + { argv: ['list'], format: undefined }, + { argv: ['list', '--', '-f', 'json'], format: undefined }, + ])('$argv → $format', ({ argv, format }) => { + expect(requestedFormatFromArgv(argv)).toBe(format); + }); }); diff --git a/src/output.ts b/src/output.ts index 471c1b6d..8b4bd2ab 100644 --- a/src/output.ts +++ b/src/output.ts @@ -25,6 +25,7 @@ export interface RenderOptions { export interface ErrorRenderOptions { cmdName?: string; traceMode?: unknown; + fmt?: unknown; } export interface StreamRenderOptions extends RenderOptions { @@ -76,8 +77,27 @@ export async function render(data: unknown, opts: StreamRenderOptions = {}): Pro console.log(output.endsWith('\n') ? output.slice(0, -1) : output); } +export function errorEnvelopeFormat(fmt?: unknown): 'json' | 'yaml' { + return typeof fmt === 'string' && fmt.trim().toLowerCase() === 'json' ? 'json' : 'yaml'; +} + +export function requestedFormatFromArgv(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const token = argv[i]!; + if (token === '--') break; + if (token === '-f' || token === '--format') { + const value = argv[i + 1]; + return value && !value.startsWith('-') ? value : undefined; + } + if (token.startsWith('--format=')) return token.slice('--format='.length) || undefined; + } + return undefined; +} + /** Serialize the local error envelope without writing to process-global stderr. */ export function formatErrorEnvelope(envelope: ErrorEnvelope, opts: ErrorRenderOptions = {}): string { + const fmt = errorEnvelopeFormat(opts.fmt); + if (fmt === 'json') return formatOutput(envelope, { fmt: 'json', fmtExplicit: true }); let output = yaml.dump(envelope, { sortKeys: false, lineWidth: 120, noRefs: true }); const code = envelope.error.code; if (