Skip to content

Commit 7da751e

Browse files
roodboiclaude
andcommitted
fix(cli): usage errors emit the JSON envelope under --json
CliUsageError returned before the jsonRequested check, printing styled help to stdout. Usage errors now emit {ok:false, error:{code:E_USAGE}} with the message on stderr; error handling extracted to a helper. From PR review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d82ae58 commit 7da751e

3 files changed

Lines changed: 55 additions & 15 deletions

File tree

src/cli/run.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { cancel } from "@clack/prompts";
22
import {
33
emitCliResult,
4+
errorResult,
45
errorResultFromUnknown,
56
HackCliError,
67
} from "../lib/cli-result.ts";
@@ -104,29 +105,50 @@ export async function runCli(argv: readonly string[]): Promise<number> {
104105
},
105106
});
106107
} catch (error: unknown) {
107-
if (error instanceof CliUsageError) {
108-
logger.error({ message: error.message });
109-
await printHelpForPath(CLI_SPEC, []);
110-
return 1;
111-
}
108+
return await handleRunCliError({ error, jsonRequested });
109+
}
110+
}
112111

112+
/**
113+
* Maps a top-level CLI failure to its exit path. Under `--json`, stdout
114+
* stays a single parseable envelope for every error class (usage errors
115+
* included — they emit E_USAGE and skip the printed help); the human-readable
116+
* message still reaches stderr.
117+
*/
118+
async function handleRunCliError(opts: {
119+
readonly error: unknown;
120+
readonly jsonRequested: boolean;
121+
}): Promise<number> {
122+
const { error, jsonRequested } = opts;
123+
if (error instanceof CliUsageError) {
113124
if (jsonRequested) {
114-
emitCliResult({ result: errorResultFromUnknown({ error }) });
125+
emitCliResult({
126+
result: errorResult({ code: "E_USAGE", message: error.message }),
127+
});
128+
logger.error({ message: error.message });
115129
return 1;
116130
}
131+
logger.error({ message: error.message });
132+
await printHelpForPath(CLI_SPEC, []);
133+
return 1;
134+
}
117135

118-
if (error instanceof HackCliError) {
119-
logger.error({ message: `${error.code}: ${error.message}` });
120-
return 1;
121-
}
136+
if (jsonRequested) {
137+
emitCliResult({ result: errorResultFromUnknown({ error }) });
138+
return 1;
139+
}
122140

123-
const message = error instanceof Error ? error.message : "Unknown error";
124-
cancel(message);
125-
if (error instanceof Error && error.stack) {
126-
logger.error({ message: error.stack });
127-
}
141+
if (error instanceof HackCliError) {
142+
logger.error({ message: `${error.code}: ${error.message}` });
128143
return 1;
129144
}
145+
146+
const message = error instanceof Error ? error.message : "Unknown error";
147+
cancel(message);
148+
if (error instanceof Error && error.stack) {
149+
logger.error({ message: error.stack });
150+
}
151+
return 1;
130152
}
131153

132154
/**

src/lib/cli-result.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type HackErrorCode =
2525
| "E_ENV_KEY_MISSING"
2626
| "E_PERMISSION"
2727
| "E_INTERACTIVE_REQUIRED"
28+
| "E_USAGE"
2829
| "E_UNEXPECTED";
2930

3031
export type CliResultError = {

tests/lifecycle-json.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,23 @@ for (const action of ["up", "down", "restart"] as const) {
9999
});
100100
}
101101

102+
test("usage errors under --json emit an E_USAGE envelope instead of help text", async () => {
103+
const result = await runCliWithCapturedOutput([
104+
"up",
105+
"--json",
106+
"--definitely-not-a-flag",
107+
]);
108+
109+
expect(result.exitCode).toBe(1);
110+
const parsed = JSON.parse(result.stdout) as {
111+
ok: boolean;
112+
error?: { code: string; message: string };
113+
};
114+
expect(parsed.ok).toBe(false);
115+
expect(parsed.error?.code).toBe("E_USAGE");
116+
expect(result.stdout).not.toContain("Usage:");
117+
});
118+
102119
async function runCliWithCapturedOutput(
103120
args: readonly string[]
104121
): Promise<CapturedRunResult> {

0 commit comments

Comments
 (0)