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..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"; @@ -12,6 +13,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,10 +83,14 @@ 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, - title: titleForCode(error.code, "Validation failed"), + title: titleForCode(error.code), detail: formatIssues(error.issues), hint: error.hint, docsLink: error.docsLink, @@ -52,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, @@ -76,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, @@ -178,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"; @@ -204,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); } } @@ -252,3 +304,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); + 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); + 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); + 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