Skip to content

Commit 3a21319

Browse files
committed
fix(cli): emit JSON envelope for Commander parse errors under --output json
When a subcommand fired a parse error (unknown command, missing required argument, invalid option), Commander's outputError callback wrote plain text to stderr immediately and the catch block exited 5 with no further output. A machine consumer that always parses stderr as JSON received an unexpected plain-text string and crashed its JSON.parse. Root cause: configureOutput was only applied to the root program, not to subcommands. Each subcommand retained the default outputError that calls write(str) directly. applyExitOverrideDeep now also propagates configureOutput to every leaf so the message is buffered instead of written. In the CommanderError catch block, a resolved output mode is used to either write a VALIDATION_ERROR JSON envelope or the buffered plain-text message. An argv scan fallback handles the edge case where --output json appears after the bad argument and was not yet parsed when the error fired. The renderCommanderError helper is extracted to src/lib/render-error.ts (alongside the existing rephraseUnknownOption helper) so it is unit-testable without a subprocess. Eight unit tests cover JSON/text output, null fallback, message trimming, and rephrased global-flag embedding. Four subprocess regression tests in the [fix-5] block cover missing-arg, unknown subcommand, argv-fallback, and text-mode no-regression paths.
1 parent 18f6e6e commit 3a21319

4 files changed

Lines changed: 195 additions & 18 deletions

File tree

src/index.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { createTestCommand } from './commands/test.js';
1212
import { createUsageCommand } from './commands/usage.js';
1313
import { ApiError, CLIError, RequestTimeoutError } from './lib/errors.js';
1414
import { Output, isOutputMode } from './lib/output.js';
15-
import { rephraseUnknownOption } from './lib/render-error.js';
15+
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
1616
import { maybeEmitSkillNudge } from './lib/skill-nudge.js';
1717
import { VERSION } from './version.js';
1818

@@ -77,27 +77,32 @@ program.addCommand(createTestCommand());
7777
program.addCommand(createAgentCommand({}));
7878
program.addCommand(createUsageCommand());
7979

80-
// Propagate exitOverride to every subcommand in the tree.
81-
// Commander's addCommand() does NOT inherit exitOverride from the parent,
82-
// so commands built externally (createTestCommand, createProjectCommand, …)
83-
// and attached via addCommand() still have _exitCallback = null and call
84-
// process.exit directly. Recursively set exitOverride so CommanderError
85-
// bubbles up to our catch block for every leaf subcommand.
80+
// Buffer Commander error messages instead of writing immediately. The catch
81+
// block re-emits in the correct format (JSON or text) once the requested
82+
// output mode is known. Safe because applyExitOverrideDeep ensures every
83+
// command throws CommanderError rather than calling process.exit directly.
84+
let pendingCommanderErrorMsg: string | null = null;
85+
86+
// Propagate exitOverride AND the buffered outputError config to every
87+
// subcommand in the tree. Commander's addCommand() does NOT inherit either
88+
// from the parent, so commands built externally (createTestCommand, etc.) and
89+
// attached via addCommand() still have _exitCallback = null and a default
90+
// outputError that writes immediately. Both must be applied after the full
91+
// command tree is assembled so every leaf subcommand behaves consistently.
8692
function applyExitOverrideDeep(cmd: Command): void {
8793
cmd.exitOverride();
94+
cmd.configureOutput({
95+
outputError(str, _write) {
96+
const rephrased = rephraseUnknownOption(str);
97+
pendingCommanderErrorMsg = rephrased !== null ? `${rephrased}\n` : str;
98+
},
99+
});
88100
for (const child of cmd.commands) {
89101
applyExitOverrideDeep(child);
90102
}
91103
}
92104
applyExitOverrideDeep(program);
93105

94-
program.configureOutput({
95-
outputError(str, write) {
96-
const rephrased = rephraseUnknownOption(str);
97-
write(rephrased !== null ? `${rephrased}\n` : str);
98-
},
99-
});
100-
101106
/**
102107
* Render a leaf command's full path (group + leaf), e.g. `test run` /
103108
* `auth whoami`, by walking parents up to (but not including) the root program.
@@ -192,9 +197,8 @@ try {
192197
process.exit(err.exitCode);
193198
}
194199
if (err instanceof CommanderError) {
195-
// Commander already wrote the error message (via configureOutput) or the
196-
// help/version text to stdout. Map exit codes per the CLI taxonomy:
197-
// help / version → 0 (user asked for it — no error)
200+
// Map exit codes per the CLI taxonomy:
201+
// help / version → 0 (user asked for it; Commander already wrote the text)
198202
// parse errors → 5 (VALIDATION_ERROR family: missing arg, invalid
199203
// option, unknown command, etc.)
200204
//
@@ -213,6 +217,23 @@ try {
213217
) {
214218
process.exit(0);
215219
}
220+
// For parse errors, write the buffered message in the correct format.
221+
// rawMode from program.opts() is reliable when --output was parsed before
222+
// the error. When the error occurs first (e.g. `testsprite badcmd --output
223+
// json`), rawMode is the default 'text'; scan argv as a best-effort
224+
// fallback so machine consumers still receive a JSON envelope.
225+
const commanderMode = (() => {
226+
if (rawMode === 'json') return 'json' as const;
227+
for (let i = 2; i < process.argv.length; i++) {
228+
const arg = process.argv[i]!;
229+
if (arg === '--output' && process.argv[i + 1] === 'json') return 'json' as const;
230+
if (arg === '--output=json') return 'json' as const;
231+
}
232+
return mode;
233+
})();
234+
process.stderr.write(
235+
renderCommanderError(pendingCommanderErrorMsg, err.message, commanderMode),
236+
);
216237
process.exit(5);
217238
}
218239
if (err instanceof CLIError) {

src/lib/render-error.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* subcommand name.
88
*/
99
import { describe, expect, it } from 'vitest';
10-
import { rephraseUnknownOption } from './render-error.js';
10+
import { renderCommanderError, rephraseUnknownOption } from './render-error.js';
1111

1212
describe('rephraseUnknownOption', () => {
1313
it('rephrases --dry-run placed after subcommand', () => {
@@ -88,3 +88,60 @@ describe('rephraseUnknownOption', () => {
8888
expect(result).toContain('testsprite --endpoint-url <value> <subcommand>');
8989
});
9090
});
91+
92+
describe('renderCommanderError', () => {
93+
it('json mode: emits VALIDATION_ERROR envelope', () => {
94+
const out = renderCommanderError("error: unknown command 'foo'\n", 'fallback', 'json');
95+
const parsed = JSON.parse(out) as {
96+
error: { code: string; message: string; requestId: string; nextAction: string };
97+
};
98+
expect(parsed.error.code).toBe('VALIDATION_ERROR');
99+
expect(parsed.error.message).toBe("error: unknown command 'foo'");
100+
expect(parsed.error.requestId).toBe('local');
101+
expect(typeof parsed.error.nextAction).toBe('string');
102+
expect(parsed.error.nextAction.length).toBeGreaterThan(0);
103+
});
104+
105+
it('json mode: uses fallback when pendingMsg is null', () => {
106+
const out = renderCommanderError(null, 'missing required argument', 'json');
107+
const parsed = JSON.parse(out) as { error: { message: string } };
108+
expect(parsed.error.message).toBe('missing required argument');
109+
});
110+
111+
it('json mode: trims trailing newline from message', () => {
112+
const out = renderCommanderError('error: bad option\n', 'bad', 'json');
113+
const parsed = JSON.parse(out) as { error: { message: string } };
114+
expect(parsed.error.message).toBe('error: bad option');
115+
});
116+
117+
it('json mode: output is valid JSON ending with newline', () => {
118+
const out = renderCommanderError('error: something', 'fallback', 'json');
119+
expect(out.endsWith('\n')).toBe(true);
120+
expect(() => JSON.parse(out)).not.toThrow();
121+
});
122+
123+
it('text mode: returns pendingMsg as-is', () => {
124+
const out = renderCommanderError('error: bad command\n', 'bad', 'text');
125+
expect(out).toBe('error: bad command\n');
126+
});
127+
128+
it('text mode: synthesizes from fallback when pendingMsg is null', () => {
129+
const out = renderCommanderError(null, 'missing required argument', 'text');
130+
expect(out).toContain('missing required argument');
131+
});
132+
133+
it('json mode: rephrased global-flag message is embedded in envelope', () => {
134+
const rephrased = rephraseUnknownOption("error: unknown option '--output'")!;
135+
const out = renderCommanderError(`${rephrased}\n`, 'unknown option', 'json');
136+
const parsed = JSON.parse(out) as { error: { code: string; message: string } };
137+
expect(parsed.error.code).toBe('VALIDATION_ERROR');
138+
expect(parsed.error.message).toContain('--output');
139+
expect(parsed.error.message).toContain('global flag');
140+
});
141+
142+
it('json mode: envelope has exactly the expected top-level key', () => {
143+
const out = renderCommanderError('error: test', 'test', 'json');
144+
const parsed = JSON.parse(out) as Record<string, unknown>;
145+
expect(Object.keys(parsed)).toEqual(['error']);
146+
});
147+
});

src/lib/render-error.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Extracted so the output-interceptor rephrasing logic (P10) is
55
* unit-testable without spawning a full CLI process.
66
*/
7+
import type { OutputMode } from './output.js';
78

89
/**
910
* Global flags that belong before the subcommand, not after it.
@@ -29,6 +30,44 @@ const GLOBAL_FLAG_ARITY: Record<string, 'boolean' | 'value'> = {
2930
* it is an ordinary unknown flag that should fall through to the original
3031
* Commander output.
3132
*/
33+
/**
34+
* Format a Commander parse-error message for the requested output mode.
35+
* Returns the string to write to stderr; the caller writes it.
36+
*
37+
* pendingMsg: message captured by configureOutput.outputError before the
38+
* CommanderError was thrown (may already be rephrased by
39+
* rephraseUnknownOption). Null only when Commander threw without
40+
* first calling outputError (should not happen for parse errors).
41+
* fallbackMsg: err.message from CommanderError, used when pendingMsg is null.
42+
* mode: the output mode resolved from --output (or argv fallback).
43+
*/
44+
export function renderCommanderError(
45+
pendingMsg: string | null,
46+
fallbackMsg: string,
47+
mode: OutputMode,
48+
): string {
49+
const rawMsg = (pendingMsg ?? fallbackMsg).trim();
50+
if (mode === 'json') {
51+
return (
52+
JSON.stringify(
53+
{
54+
error: {
55+
code: 'VALIDATION_ERROR',
56+
message: rawMsg,
57+
nextAction: 'Run testsprite --help or testsprite <command> --help for usage.',
58+
requestId: 'local',
59+
},
60+
},
61+
null,
62+
2,
63+
) + '\n'
64+
);
65+
}
66+
// Text mode: emit the buffered message as-is (already formatted by Commander
67+
// or rephrased by rephraseUnknownOption). Synthesize when null.
68+
return pendingMsg ?? `${rawMsg}\n`;
69+
}
70+
3271
export function rephraseUnknownOption(raw: string): string | null {
3372
// Commander emits: "error: unknown option '--foo'"
3473
const match = /unknown option\s+'--([^']+)'/.exec(raw);

test/cli.subprocess.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,4 +1110,64 @@ describe('[fix-5] Commander parse errors → exit 5; help/version → exit 0', (
11101110
expect(result.exitCode).toBe(0);
11111111
expect(result.stdout.trim()).toBeTruthy(); // version string on stdout
11121112
}, 30_000);
1113+
1114+
it('--output json: missing required arg emits JSON VALIDATION_ERROR envelope, not plain text', async () => {
1115+
// `test result` requires a positional <test-id>. With --output json, the
1116+
// CommanderError must be rendered as a machine-readable JSON envelope so
1117+
// a coding agent parsing stderr does not receive an unexpected plain-text
1118+
// error and crash its JSON.parse.
1119+
const result = await runCli(['--output', 'json', 'test', 'result'], {
1120+
TESTSPRITE_API_KEY: 'sk-subproc',
1121+
TESTSPRITE_API_URL: baseUrl,
1122+
});
1123+
expect(result.exitCode).toBe(5);
1124+
let parsed: { error: { code: string; message: string; requestId: string } };
1125+
expect(() => {
1126+
parsed = JSON.parse(result.stderr) as typeof parsed;
1127+
}).not.toThrow();
1128+
expect(parsed!.error.code).toBe('VALIDATION_ERROR');
1129+
expect(parsed!.error.requestId).toBe('local');
1130+
expect(parsed!.error.message).toBeTruthy();
1131+
}, 30_000);
1132+
1133+
it('--output json: unknown subcommand emits JSON envelope (--output before bad arg)', async () => {
1134+
// --output json appears before the unknown subcommand, so Commander parses
1135+
// it and program.opts().output is 'json' when the error fires.
1136+
const result = await runCli(['--output', 'json', 'test', 'not-a-real-subcommand'], {
1137+
TESTSPRITE_API_KEY: 'sk-subproc',
1138+
TESTSPRITE_API_URL: baseUrl,
1139+
});
1140+
expect(result.exitCode).toBe(5);
1141+
let parsed: { error: { code: string } };
1142+
expect(() => {
1143+
parsed = JSON.parse(result.stderr) as typeof parsed;
1144+
}).not.toThrow();
1145+
expect(parsed!.error.code).toBe('VALIDATION_ERROR');
1146+
}, 30_000);
1147+
1148+
it('--output json after bad arg: argv fallback detects json mode and emits envelope', async () => {
1149+
// The error fires before --output json is parsed, so program.opts().output
1150+
// is the default 'text'. The argv fallback scan must still detect json mode.
1151+
const result = await runCli(['test', 'not-a-real-subcommand', '--output', 'json'], {
1152+
TESTSPRITE_API_KEY: 'sk-subproc',
1153+
TESTSPRITE_API_URL: baseUrl,
1154+
});
1155+
expect(result.exitCode).toBe(5);
1156+
let parsed: { error: { code: string } };
1157+
expect(() => {
1158+
parsed = JSON.parse(result.stderr) as typeof parsed;
1159+
}).not.toThrow();
1160+
expect(parsed!.error.code).toBe('VALIDATION_ERROR');
1161+
}, 30_000);
1162+
1163+
it('text mode: Commander parse error still emits plain text (no regression)', async () => {
1164+
const result = await runCli(['test', 'result'], {
1165+
TESTSPRITE_API_KEY: 'sk-subproc',
1166+
TESTSPRITE_API_URL: baseUrl,
1167+
});
1168+
expect(result.exitCode).toBe(5);
1169+
// Must NOT be a JSON envelope in text mode.
1170+
expect(() => JSON.parse(result.stderr)).toThrow();
1171+
expect(result.stderr).toContain('test-id');
1172+
}, 30_000);
11131173
});

0 commit comments

Comments
 (0)