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
14 changes: 14 additions & 0 deletions src/cli-error-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 4 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 25 additions & 0 deletions src/commanderAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 4 additions & 4 deletions src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
});
Expand All @@ -150,15 +150,15 @@ 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
if (verbose && err instanceof Error && err.stack) {
envelope.error.stack = err.stack;
}

process.stderr.write(formatErrorEnvelope(envelope, { cmdName, traceMode }));
process.stderr.write(formatErrorEnvelope(envelope, { cmdName, traceMode, fmt: errorEnvelopeFormat(fmt) }));
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/hosted/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>();
Expand Down
3 changes: 2 additions & 1 deletion src/hosted/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 41 additions & 10 deletions src/output.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '';
Expand Down Expand Up @@ -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',
Expand All @@ -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);
});
});
20 changes: 20 additions & 0 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface RenderOptions {
export interface ErrorRenderOptions {
cmdName?: string;
traceMode?: unknown;
fmt?: unknown;
}

export interface StreamRenderOptions extends RenderOptions {
Expand Down Expand Up @@ -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 (
Expand Down
Loading