From e44871037de0621a6b404eeee71dcedee0a5fa14 Mon Sep 17 00:00:00 2001 From: "naman.anand" Date: Thu, 21 May 2026 15:02:35 +0000 Subject: [PATCH 1/2] feat(cli-v2): add --json global flag with structured error envelope Adds a --json global flag that renders errors as a JSON envelope on stderr for CI pipelines and agents: { "ok": false, "code": "AUTH_ERROR", "message": "You are not logged in.", "hint": "Run `fern auth login`.", "docsLink": "https://buildwithfern.com/learn/cli/auth", "violations": [...], "logFile": "~/.fern/v1/logs/...", "debug": { "stack": "...", "causes": [...] } } - Threads json through withContext.handleError + the shared yargs fail handler - ValidationError and SourcedValidationError serialize each violation with file/line/column - --debug --json adds stack and cause-chain info under debug.* - logFile is embedded so CI/agents don't need to parse the dim hint line - 13 new tests cover every branch (TaskAbortSignal, CliError, ValidationError, SourcedValidationError, generic Error, non-Error throwables, logFile, debug=true/false, ANSI-free output) --- .../cli-v2/src/__test__/renderError.test.ts | 141 +++++++++++++++++ packages/cli/cli-v2/src/cli.ts | 6 + .../commands/_internal/yargsFailHandler.ts | 28 +++- packages/cli/cli-v2/src/context/GlobalArgs.ts | 10 ++ .../cli/cli-v2/src/context/withContext.ts | 13 +- packages/cli/cli-v2/src/errors/renderError.ts | 149 ++++++++++++++++++ .../unreleased/cli-v2-json-error-envelope.yml | 10 ++ 7 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/cli-v2-json-error-envelope.yml diff --git a/packages/cli/cli-v2/src/__test__/renderError.test.ts b/packages/cli/cli-v2/src/__test__/renderError.test.ts index c4098219d299..0bbc3505c6cc 100644 --- a/packages/cli/cli-v2/src/__test__/renderError.test.ts +++ b/packages/cli/cli-v2/src/__test__/renderError.test.ts @@ -143,3 +143,144 @@ describe("renderError", () => { expect(out).toContain("caused by: Error: inner"); }); }); + +describe("renderError --json mode", () => { + function renderJson(error: unknown, options: { debug?: boolean; logFile?: string } = {}): Record { + const out = renderError(error, { json: true, ...options }); + if (out == null) { + throw new Error("renderError returned null in JSON mode"); + } + return JSON.parse(out) as Record; + } + + it("returns null for TaskAbortSignal even in JSON mode", () => { + expect(renderError(new TaskAbortSignal(), { json: true })).toBeNull(); + }); + + it("emits an envelope with code, message, hint and docsLink for a CliError", () => { + const error = new CliError({ + code: CliError.Code.AuthError, + message: "You are not logged in.", + hint: "Run `fern auth login`.", + docsLink: "https://buildwithfern.com/learn/cli/auth" + }); + + const envelope = renderJson(error); + + expect(envelope).toMatchObject({ + ok: false, + code: "AUTH_ERROR", + message: "You are not logged in.", + hint: "Run `fern auth login`.", + docsLink: "https://buildwithfern.com/learn/cli/auth" + }); + expect(envelope).not.toHaveProperty("debug"); + expect(envelope).not.toHaveProperty("logFile"); + }); + + it("falls back to a per-code title when the CliError has no message", () => { + const envelope = renderJson(new CliError({ code: CliError.Code.ValidationError })); + expect(envelope.code).toBe("VALIDATION_ERROR"); + expect(envelope.message).toBe("Validation failed"); + }); + + it("serializes ValidationError violations", () => { + const error = new ValidationError([ + { + severity: "error", + relativeFilepath: "fern.yml", + message: "org is required", + nodePath: ["fern", "config"] + } + ]); + + const envelope = renderJson(error); + + expect(envelope.code).toBe("VALIDATION_ERROR"); + expect(envelope.violations).toEqual([ + { + severity: "error", + message: "org is required", + file: "fern.yml", + nodePath: "fern.config" + } + ]); + }); + + it("serializes SourcedValidationError issues with file/line/column", () => { + const location = new SourceLocation({ + absoluteFilePath: AbsoluteFilePath.of("/tmp/fern.yml"), + relativeFilePath: RelativeFilePath.of("fern.yml"), + line: 7, + column: 13 + }); + const issue = new ValidationIssue({ + location, + message: "sdks.targets.node.lang must be one of: csharp, go, java" + }); + + const envelope = renderJson(new SourcedValidationError([issue])); + + expect(envelope.code).toBe("VALIDATION_ERROR"); + expect(envelope.violations).toEqual([ + { + severity: "error", + message: "sdks.targets.node.lang must be one of: csharp, go, java", + file: "fern.yml", + line: 7, + column: 13 + } + ]); + }); + + it("sets code=null for unknown thrown values", () => { + const envelope = renderJson("oops, a string was thrown"); + expect(envelope.code).toBeNull(); + expect(envelope.message).toBe("oops, a string was thrown"); + }); + + it("sets code=null for plain Error instances", () => { + const envelope = renderJson(new Error("boom")); + expect(envelope.code).toBeNull(); + expect(envelope.message).toBe("boom"); + }); + + it("includes logFile when supplied", () => { + const envelope = renderJson(new CliError({ code: CliError.Code.InternalError, message: "boom" }), { + logFile: "/tmp/fern/logs/2026-05-20T18-58-00.log" + }); + expect(envelope.logFile).toBe("/tmp/fern/logs/2026-05-20T18-58-00.log"); + }); + + it("includes a debug block with stack and causes when debug=true", () => { + const cause = new Error("inner"); + const outer = new CliError({ code: CliError.Code.InternalError, message: "outer" }); + (outer as { cause?: unknown }).cause = cause; + + const envelope = renderJson(outer, { debug: true }); + + expect(envelope.debug).toBeDefined(); + const debugInfo = envelope.debug as { stack?: string; causes?: string[] }; + expect(debugInfo.stack).toContain("outer"); + expect(debugInfo.causes).toEqual(["Error: inner"]); + }); + + it("omits the debug block when debug=false", () => { + const envelope = renderJson(new CliError({ code: CliError.Code.InternalError, message: "boom" })); + expect(envelope).not.toHaveProperty("debug"); + }); + + it("produces parseable JSON with no ANSI escape codes", () => { + const out = renderError( + new CliError({ + code: CliError.Code.AuthError, + message: "Unauthorized.", + hint: "Run `fern auth login`." + }), + { json: true } + ); + // biome-ignore lint/suspicious/noControlCharactersInRegex: assertion that no ANSI escape (ESC=0x1b) leaks into the JSON envelope. + expect(out).not.toMatch(/\u001b\[/); + expect(() => JSON.parse(out ?? "")).not.toThrow(); + }); +}); diff --git a/packages/cli/cli-v2/src/cli.ts b/packages/cli/cli-v2/src/cli.ts index 986b89b71b7e..37f11430d6ff 100644 --- a/packages/cli/cli-v2/src/cli.ts +++ b/packages/cli/cli-v2/src/cli.ts @@ -120,6 +120,12 @@ function createCliV2(argv?: string[]): Argv { description: "Show full stack traces and error cause chains (also: FERN_DEBUG=1)", default: false }) + .option("json", { + type: "boolean", + description: + "Render errors as JSON on stderr (for CI/agents). Commands that support --json for success output already accept this flag.", + default: false + }) .option("env", { type: "string", description: "Path to a .env file to load environment variables from" diff --git a/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts b/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts index 4a1d0d41673c..877b805cbd5e 100644 --- a/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts +++ b/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts @@ -22,11 +22,14 @@ export function makeYargsFailHandler({ }): (msg: string | null, err: Error | null, y: Argv) => void { return (msg, err, y) => { const error = err ?? new CliError({ code: CliError.Code.UserError, message: msg ?? "Invalid usage." }); - const rendered = renderError(error); + const json = isJsonFlagInArgv(); + const rendered = renderError(error, { json }); if (rendered != null) { process.stderr.write(`${rendered}\n`); } - if (showHelp) { + // In JSON mode we suppress the human help block so the envelope on + // stderr is the only thing a caller has to parse. + if (showHelp && !json) { process.stderr.write("\n"); y.showHelp("error"); } @@ -37,3 +40,24 @@ export function makeYargsFailHandler({ process.exit(1); }; } + +/** + * Inspects `process.argv` for a `--json` (or `--json=true`) flag. We can't + * rely on yargs-parsed args here because `.fail` is called for usage errors + * that happen during parsing. + */ +function isJsonFlagInArgv(): boolean { + for (const arg of process.argv.slice(2)) { + if (arg === "--json") { + return true; + } + if (arg === "--no-json") { + return false; + } + if (arg.startsWith("--json=")) { + const value = arg.slice("--json=".length).toLowerCase(); + return value === "true" || value === "1" || value === "yes"; + } + } + return false; +} diff --git a/packages/cli/cli-v2/src/context/GlobalArgs.ts b/packages/cli/cli-v2/src/context/GlobalArgs.ts index b97b1fbccb1a..57a49532c569 100644 --- a/packages/cli/cli-v2/src/context/GlobalArgs.ts +++ b/packages/cli/cli-v2/src/context/GlobalArgs.ts @@ -6,4 +6,14 @@ export interface GlobalArgs { * failure. Mirrors `FERN_DEBUG=1`. Implies `--log-level debug`. */ debug?: boolean; + /** + * When true, render errors as a JSON envelope on stderr so CI pipelines + * and agents can parse failure details without scraping human output. + * + * Commands that produce structured success output (e.g. `auth whoami`, + * `sdk list`) also declare this locally for their stdout path; the + * global flag guarantees the *error* path is JSON-serializable + * regardless of which command ran. + */ + json?: boolean; } diff --git a/packages/cli/cli-v2/src/context/withContext.ts b/packages/cli/cli-v2/src/context/withContext.ts index b8477529c77f..1ddba6753c0c 100644 --- a/packages/cli/cli-v2/src/context/withContext.ts +++ b/packages/cli/cli-v2/src/context/withContext.ts @@ -39,7 +39,7 @@ export function withContext( } catch (error) { reportError(context, error); await context.telemetry.flush(); - handleError(context, error); + handleError(context, error, { json: args.json === true }); context.finish(); await exitGracefully(1); } @@ -79,16 +79,21 @@ function isFernDebugEnv(): boolean { * on the most common failure path (we previously only did this on * SIGINT and `TaskGroup` failures). */ -function handleError(context: Context, error: unknown): void { +function handleError(context: Context, error: unknown, options: { json: boolean }): void { if (error instanceof TaskAbortSignal) { return; } const debug = context.logLevel === LogLevel.Debug; - const rendered = renderError(error, { debug }); + const logFile = options.json && !context.logs.empty() ? context.logs.absoluteFilePath : undefined; + const rendered = renderError(error, { debug, json: options.json, logFile }); if (rendered != null) { process.stderr.write(`${rendered}\n`); } - context.printLogFilePath(process.stderr); + // Skip the dim log-file hint in JSON mode — it would corrupt the + // JSON envelope. The path is embedded inside the envelope as `logFile`. + if (!options.json) { + context.printLogFilePath(process.stderr); + } } /** diff --git a/packages/cli/cli-v2/src/errors/renderError.ts b/packages/cli/cli-v2/src/errors/renderError.ts index 9259ade117c4..c4dc53906f97 100644 --- a/packages/cli/cli-v2/src/errors/renderError.ts +++ b/packages/cli/cli-v2/src/errors/renderError.ts @@ -12,6 +12,51 @@ export interface RenderErrorOptions { * Enables stack/cause-chain rendering for all error classes. */ debug?: boolean; + /** + * When true, return a JSON envelope string instead of the human-readable + * Rust-style block. The envelope shape is {@link ErrorEnvelope}. + */ + json?: boolean; + /** + * Absolute path to the debug log file for the current run. Surfaced in + * the JSON envelope as `logFile` so CI/agents can attach it to issue + * reports. Ignored in human mode (the log path is printed separately by + * `Context.printLogFilePath`). + */ + logFile?: string; +} + +/** + * Structured error envelope for `--json` mode. + * + * This is the public contract for CI/agents. Every field is optional except + * `ok` (always `false`) and `message`. Callers should **not** assume new + * fields won't be added — forward-compatible consumers should ignore unknown + * keys. + */ +export interface ErrorEnvelope { + ok: false; + code: CliError.Code | null; + message: string; + hint?: string; + docsLink?: string; + violations?: ErrorEnvelopeViolation[]; + logFile?: string; + debug?: ErrorEnvelopeDebug; +} + +export interface ErrorEnvelopeViolation { + severity: string; + message: string; + file?: string; + line?: number; + column?: number; + nodePath?: string; +} + +export interface ErrorEnvelopeDebug { + stack?: string; + causes?: string[]; } /** @@ -37,6 +82,10 @@ export function renderError(error: unknown, options: RenderErrorOptions = {}): s return null; } + if (options.json === true) { + return JSON.stringify(buildErrorEnvelope(error, options), null, 2); + } + if (error instanceof SourcedValidationError) { return renderEnvelope({ code: error.code, @@ -252,3 +301,103 @@ function collectDebugLines(error: Error): string[] { } return out; } + +// --------------------------------------------------------------------------- +// JSON envelope builder (--json mode) +// --------------------------------------------------------------------------- + +function buildErrorEnvelope(error: unknown, options: RenderErrorOptions): ErrorEnvelope { + const base: ErrorEnvelope = { + ok: false, + code: null, + message: "An unknown error occurred" + }; + + if (error instanceof SourcedValidationError) { + base.code = error.code; + base.message = error.message || titleForCode(error.code, "Validation failed"); + if (error.hint != null) { + base.hint = error.hint; + } + if (error.docsLink != null) { + base.docsLink = error.docsLink; + } + base.violations = error.issues.map((issue) => { + const v: ErrorEnvelopeViolation = { + severity: "error", + message: issue.message, + file: String(issue.location.relativeFilePath), + line: issue.location.line, + column: issue.location.column + }; + return v; + }); + } else if (error instanceof ValidationError) { + base.code = error.code; + base.message = error.message || titleForCode(error.code, "Validation failed"); + if (error.hint != null) { + base.hint = error.hint; + } + if (error.docsLink != null) { + base.docsLink = error.docsLink; + } + base.violations = error.violations.map((violation) => { + const v: ErrorEnvelopeViolation = { + severity: violation.severity, + message: violation.message, + file: violation.relativeFilepath + }; + if (violation.nodePath.length > 0) { + v.nodePath = violation.nodePath + .map((seg) => (typeof seg === "string" ? seg : `${seg.key}[${seg.arrayIndex ?? ""}]`)) + .join("."); + } + return v; + }); + } else if (error instanceof CliError) { + base.code = error.code; + base.message = error.message || titleForCode(error.code, "Command failed"); + if (error.hint != null) { + base.hint = error.hint; + } + if (error.docsLink != null) { + base.docsLink = error.docsLink; + } + } else if (error instanceof Error) { + base.message = error.message || "An unexpected error occurred"; + } else { + base.message = stringifyUnknown(error); + } + + if (options.logFile != null) { + base.logFile = options.logFile; + } + + if (options.debug === true && error instanceof Error) { + const debugInfo: ErrorEnvelopeDebug = {}; + if (error.stack != null) { + debugInfo.stack = error.stack; + } + const causes: string[] = []; + let cause: unknown = (error as { cause?: unknown }).cause; + let depth = 0; + while (cause != null && depth < 5) { + if (cause instanceof Error) { + causes.push(`${cause.name}: ${cause.message}`); + cause = (cause as { cause?: unknown }).cause; + } else { + causes.push(stringifyUnknown(cause)); + cause = undefined; + } + depth += 1; + } + if (causes.length > 0) { + debugInfo.causes = causes; + } + if (debugInfo.stack != null || debugInfo.causes != null) { + base.debug = debugInfo; + } + } + + return base; +} diff --git a/packages/cli/cli/changes/unreleased/cli-v2-json-error-envelope.yml b/packages/cli/cli/changes/unreleased/cli-v2-json-error-envelope.yml new file mode 100644 index 000000000000..04d878380eb4 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/cli-v2-json-error-envelope.yml @@ -0,0 +1,10 @@ +- summary: | + Add `--json` global flag to cli-v2 that renders errors as a structured JSON + envelope on stderr for CI pipelines and agents. The envelope contains + `{ ok: false, code, message, hint, docsLink, violations, logFile, debug }`, + where violations include `file`/`line`/`column` for sourced YAML errors. + `--debug --json` adds a `debug.stack` and `debug.causes` block. + Commands that already declared a local `--json` flag for success output + continue to work; the global flag guarantees the error path is + JSON-parseable regardless of which command failed. + type: feat From bc25f424bba5ada137fafb7ca8e446614edc3209 Mon Sep 17 00:00:00 2001 From: Naman Anand Date: Thu, 21 May 2026 23:55:18 +0530 Subject: [PATCH 2/2] Make titleForCode exhaustive and add INTERNAL_ERROR Import assertNever and change titleForCode to have no fallback, returning explicit titles for all known CliError codes (including a new INTERNAL_ERROR case). Replace previous fallback usages at call sites with the new titleForCode signature and use assertNever in the default branch to enforce exhaustiveness and improve type safety. Update error envelope building so default messages use titleForCode directly. --- packages/cli/cli-v2/src/errors/renderError.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/cli/cli-v2/src/errors/renderError.ts b/packages/cli/cli-v2/src/errors/renderError.ts index c4dc53906f97..063aba477563 100644 --- a/packages/cli/cli-v2/src/errors/renderError.ts +++ b/packages/cli/cli-v2/src/errors/renderError.ts @@ -1,3 +1,4 @@ +import { assertNever } from "@fern-api/core-utils"; import { CliError, TaskAbortSignal } from "@fern-api/task-context"; import chalk from "chalk"; @@ -89,7 +90,7 @@ export function renderError(error: unknown, options: RenderErrorOptions = {}): s if (error instanceof SourcedValidationError) { return renderEnvelope({ code: error.code, - title: titleForCode(error.code, "Validation failed"), + title: titleForCode(error.code), detail: formatIssues(error.issues), hint: error.hint, docsLink: error.docsLink, @@ -101,7 +102,7 @@ export function renderError(error: unknown, options: RenderErrorOptions = {}): s if (error instanceof ValidationError) { return renderEnvelope({ code: error.code, - title: titleForCode(error.code, "Validation failed"), + title: titleForCode(error.code), detail: formatViolations(error.violations.map(toFormattableViolation)), hint: error.hint, docsLink: error.docsLink, @@ -125,7 +126,7 @@ export function renderError(error: unknown, options: RenderErrorOptions = {}): s if (error instanceof CliError) { return renderEnvelope({ code: error.code, - title: firstLine(error.message) ?? titleForCode(error.code, "Command failed"), + title: firstLine(error.message) ?? titleForCode(error.code), detail: restAfterFirstLine(error.message), hint: error.hint, docsLink: error.docsLink, @@ -227,7 +228,7 @@ function restAfterFirstLine(text: string | undefined): string | undefined { return rest.length > 0 ? rest : undefined; } -function titleForCode(code: CliError.Code, fallback: string): string { +function titleForCode(code: CliError.Code): string { switch (code) { case "VALIDATION_ERROR": return "Validation failed"; @@ -253,8 +254,10 @@ function titleForCode(code: CliError.Code, fallback: string): string { return "Version mismatch"; case "USER_ERROR": return "Invalid usage"; + case "INTERNAL_ERROR": + return "Internal error"; default: - return fallback; + assertNever(code); } } @@ -315,7 +318,7 @@ function buildErrorEnvelope(error: unknown, options: RenderErrorOptions): ErrorE if (error instanceof SourcedValidationError) { base.code = error.code; - base.message = error.message || titleForCode(error.code, "Validation failed"); + base.message = error.message || titleForCode(error.code); if (error.hint != null) { base.hint = error.hint; } @@ -334,7 +337,7 @@ function buildErrorEnvelope(error: unknown, options: RenderErrorOptions): ErrorE }); } else if (error instanceof ValidationError) { base.code = error.code; - base.message = error.message || titleForCode(error.code, "Validation failed"); + base.message = error.message || titleForCode(error.code); if (error.hint != null) { base.hint = error.hint; } @@ -356,7 +359,7 @@ function buildErrorEnvelope(error: unknown, options: RenderErrorOptions): ErrorE }); } else if (error instanceof CliError) { base.code = error.code; - base.message = error.message || titleForCode(error.code, "Command failed"); + base.message = error.message || titleForCode(error.code); if (error.hint != null) { base.hint = error.hint; }