Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
141 changes: 141 additions & 0 deletions packages/cli/cli-v2/src/__test__/renderError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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<string, unknown>;
}

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();
});
});
6 changes: 6 additions & 0 deletions packages/cli/cli-v2/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ function createCliV2(argv?: string[]): Argv<GlobalArgs> {
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"
Expand Down
28 changes: 26 additions & 2 deletions packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ export function makeYargsFailHandler({
}): (msg: string | null, err: Error | null, y: Argv<GlobalArgs>) => 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");
}
Expand All @@ -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;
}
10 changes: 10 additions & 0 deletions packages/cli/cli-v2/src/context/GlobalArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 9 additions & 4 deletions packages/cli/cli-v2/src/context/withContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function withContext<T extends GlobalArgs>(
} catch (error) {
reportError(context, error);
await context.telemetry.flush();
handleError(context, error);
handleError(context, error, { json: args.json === true });
context.finish();
await exitGracefully(1);
}
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
Loading