diff --git a/packages/cli/cli-v2/src/__test__/printViolations.test.ts b/packages/cli/cli-v2/src/__test__/printViolations.test.ts new file mode 100644 index 000000000000..909695e759d4 --- /dev/null +++ b/packages/cli/cli-v2/src/__test__/printViolations.test.ts @@ -0,0 +1,82 @@ +import { AbsoluteFilePath, RelativeFilePath } from "@fern-api/fs-utils"; +import { SourceLocation } from "@fern-api/source"; +import { ValidationIssue } from "@fern-api/yaml-loader"; +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { formatIssues, formatViolations, toFormattableViolation } from "../errors/printViolations.js"; + +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape matching requires control characters. + return text.replace(/\u001b\[[0-9;]*m/g, ""); +} + +const ORIGINAL_CHALK_LEVEL = chalk.level; + +describe("formatViolations", () => { + beforeEach(() => { + chalk.level = 0; + }); + afterEach(() => { + chalk.level = ORIGINAL_CHALK_LEVEL; + }); + + it("returns an empty string when given no violations", () => { + expect(formatViolations([])).toBe(""); + }); + + it("renders file:line:col when line and column are present", () => { + const out = stripAnsi( + formatViolations([ + { + displayRelativeFilepath: "api/openapi.yml", + line: 12, + column: 4, + message: "operation missing summary", + severity: "error" + } + ]) + ); + expect(out).toBe("api/openapi.yml:12:4: operation missing summary"); + }); + + it("omits line/col when they are not provided", () => { + const out = stripAnsi( + formatViolations([ + toFormattableViolation({ + severity: "warning", + relativeFilepath: "fern.yml", + nodePath: [], + message: "deprecated key" + }) + ]) + ); + expect(out).toBe("fern.yml: deprecated key"); + }); +}); + +describe("formatIssues", () => { + beforeEach(() => { + chalk.level = 0; + }); + afterEach(() => { + chalk.level = ORIGINAL_CHALK_LEVEL; + }); + + it("returns an empty string when given no issues", () => { + expect(formatIssues([])).toBe(""); + }); + + it("renders each issue with its location and message", () => { + const location = new SourceLocation({ + absoluteFilePath: AbsoluteFilePath.of("/tmp/fern.yml"), + relativeFilePath: RelativeFilePath.of("fern.yml"), + line: 3, + column: 1 + }); + const issue = new ValidationIssue({ location, message: "org is required" }); + + const out = stripAnsi(formatIssues([issue])); + expect(out).toBe("fern.yml:3:1: org is required"); + }); +}); diff --git a/packages/cli/cli-v2/src/__test__/renderError.test.ts b/packages/cli/cli-v2/src/__test__/renderError.test.ts new file mode 100644 index 000000000000..c4098219d299 --- /dev/null +++ b/packages/cli/cli-v2/src/__test__/renderError.test.ts @@ -0,0 +1,145 @@ +import { AbsoluteFilePath, RelativeFilePath } from "@fern-api/fs-utils"; +import { SourceLocation } from "@fern-api/source"; +import { CliError, TaskAbortSignal } from "@fern-api/task-context"; +import { ValidationIssue } from "@fern-api/yaml-loader"; +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { KeyringUnavailableError } from "../auth/errors/KeyringUnavailableError.js"; +import { renderError } from "../errors/renderError.js"; +import { SourcedValidationError } from "../errors/SourcedValidationError.js"; +import { ValidationError } from "../errors/ValidationError.js"; + +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences requires the ESC control character. + return text.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function renderPlain(error: unknown, options: { debug?: boolean } = {}): string { + return stripAnsi(renderError(error, options) ?? ""); +} + +const ORIGINAL_CHALK_LEVEL = chalk.level; + +describe("renderError", () => { + beforeEach(() => { + chalk.level = 0; + }); + afterEach(() => { + chalk.level = ORIGINAL_CHALK_LEVEL; + }); + + it("returns null for TaskAbortSignal so the boundary stays silent on Ctrl+C", () => { + const result = renderError(new TaskAbortSignal()); + expect(result).toBeNull(); + }); + + it("renders a CliError with code, title, hint, and docs link", () => { + const error = new CliError({ + code: CliError.Code.AuthError, + message: "Unauthorized.", + hint: "Run `fern auth login`.", + docsLink: "https://buildwithfern.com/learn/cli/auth" + }); + + const out = renderPlain(error); + + expect(out).toContain("error[AUTH_ERROR]: Unauthorized."); + expect(out).toContain("hint: Run `fern auth login`."); + expect(out).toContain("see: https://buildwithfern.com/learn/cli/auth"); + }); + + it("falls back to a per-code title when the CliError has no message", () => { + const error = new CliError({ code: CliError.Code.ValidationError }); + const out = renderPlain(error); + expect(out).toContain("error[VALIDATION_ERROR]: Validation failed"); + }); + + it("uses subsequent lines of the message as the detail body", () => { + const error = new CliError({ + code: CliError.Code.ConfigError, + message: "API 'foo' not found.\nAvailable APIs: bar, baz" + }); + + const out = renderPlain(error); + + expect(out).toContain("error[CONFIG_ERROR]: API 'foo' not found."); + expect(out).toContain("Available APIs: bar, baz"); + }); + + it("renders ValidationError violations in the detail body", () => { + const error = new ValidationError([ + { + severity: "error", + relativeFilepath: "fern.yml", + message: "org is required", + nodePath: [] + }, + { + severity: "warning", + relativeFilepath: "fern.yml", + message: "consider setting `version`", + nodePath: [] + } + ]); + + const out = renderPlain(error); + + expect(out).toContain("error[VALIDATION_ERROR]: Validation failed"); + expect(out).toContain("fern.yml: org is required"); + expect(out).toContain("fern.yml: consider setting `version`"); + }); + + it("renders SourcedValidationError issues with file:line:col locations", () => { + 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 out = renderPlain(new SourcedValidationError([issue])); + + expect(out).toContain("error[VALIDATION_ERROR]: Validation failed"); + expect(out).toContain("fern.yml:7:13: sdks.targets.node.lang must be one of: csharp, go, java"); + }); + + it("renders a KeyringUnavailableError with its multi-line guidance", () => { + const error = new KeyringUnavailableError("linux", new Error("dbus not running")); + + const out = renderPlain(error); + expect(out).toContain("error[AUTH_ERROR]:"); + expect(out).toContain("Fern requires"); + }); + + it("falls back to the unknown-error envelope for non-Error throwables", () => { + const out = renderPlain("oops, a string was thrown"); + expect(out).toContain("error: oops, a string was thrown"); + }); + + it("renders a generic Error with no code prefix", () => { + const out = renderPlain(new Error("boom")); + expect(out).toContain("error: boom"); + expect(out).not.toContain("error["); + }); + + it("omits stack traces by default", () => { + const out = renderPlain(new CliError({ code: CliError.Code.InternalError, message: "boom" })); + expect(out).not.toMatch(/at\s+\w/); + }); + + it("includes the stack and cause chain 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 out = renderPlain(outer, { debug: true }); + + expect(out).toContain("error[INTERNAL_ERROR]: outer"); + expect(out).toContain("caused by: Error: inner"); + }); +}); diff --git a/packages/cli/cli-v2/src/cli.ts b/packages/cli/cli-v2/src/cli.ts index 5afe6c7acbc7..f3f6ff7de379 100644 --- a/packages/cli/cli-v2/src/cli.ts +++ b/packages/cli/cli-v2/src/cli.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "url"; import type { Argv } from "yargs"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; +import { makeYargsFailHandler } from "./commands/_internal/yargsFailHandler.js"; import { addApiCommand } from "./commands/api/index.js"; import { addAuthCommand } from "./commands/auth/index.js"; import { addCacheCommand } from "./commands/cache/index.js"; @@ -115,6 +116,11 @@ function createCliV2(argv?: string[]): Argv { choices: ["debug", "info", "warn", "error"] as const, default: "info" }) + .option("debug", { + type: "boolean", + description: "Show full stack traces and error cause chains (also: FERN_DEBUG=1)", + default: false + }) .option("env", { type: "string", description: "Path to a .env file to load environment variables from" @@ -123,17 +129,7 @@ function createCliV2(argv?: string[]): Argv { .strict() .demandCommand() .recommendCommands() - .fail((msg, err, y) => { - if (err != null) { - process.stderr.write(`${err.message}\n`); - process.exit(1); - } - if (msg != null) { - process.stderr.write(`Error: ${msg}\n\n`); - } - y.showHelp(); - process.exit(1); - }); + .fail(makeYargsFailHandler({ showHelp: true })); addApiCommand(cli); addAuthCommand(cli); diff --git a/packages/cli/cli-v2/src/commands/_internal/commandGroup.ts b/packages/cli/cli-v2/src/commands/_internal/commandGroup.ts index f4af51a69055..75fc3479561c 100644 --- a/packages/cli/cli-v2/src/commands/_internal/commandGroup.ts +++ b/packages/cli/cli-v2/src/commands/_internal/commandGroup.ts @@ -1,5 +1,6 @@ import type { Argv } from "yargs"; import type { GlobalArgs } from "../../context/GlobalArgs.js"; +import { makeYargsFailHandler } from "./yargsFailHandler.js"; type CommandAdder = (cli: Argv) => void; @@ -38,17 +39,8 @@ export function commandGroup({ return yargs .usage(usageText) .demandCommand(1) - .fail((msg, err, y) => { - if (err != null) { - process.stderr.write(`${err.message}\n`); - process.exit(1); - } - if (msg != null) { - process.stderr.write(`Error: ${msg}\n\n`); - } - y.showHelp(); - process.exit(1); - }); + .recommendCommands() + .fail(makeYargsFailHandler({ showHelp: true })); }; if (description == null) { diff --git a/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts b/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts new file mode 100644 index 000000000000..4a1d0d41673c --- /dev/null +++ b/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts @@ -0,0 +1,39 @@ +import { CliError } from "@fern-api/task-context"; +import type { Argv } from "yargs"; + +import type { GlobalArgs } from "../../context/GlobalArgs.js"; +import { renderError } from "../../errors/renderError.js"; + +/** + * Shared yargs `.fail` handler. + * + * Yargs invokes its `.fail` callback before our `withContext` boundary runs, + * so we render here directly and exit. To keep the user-visible output + * consistent across the CLI we go through {@link renderError} with a + * synthesized {@link CliError} of code `USER_ERROR`. + * + * Pass `showHelp=true` for command groups so the user gets the available + * subcommand list after the error. + */ +export function makeYargsFailHandler({ + showHelp +}: { + showHelp: boolean; +}): (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); + if (rendered != null) { + process.stderr.write(`${rendered}\n`); + } + if (showHelp) { + process.stderr.write("\n"); + y.showHelp("error"); + } + // Yargs runs `.fail` synchronously and we are outside our async + // `withContext` lifecycle, so we exit directly. Stick with 1 today + // to avoid behavior changes for downstream scripts; the exit-code + // mapping ships separately. + process.exit(1); + }; +} diff --git a/packages/cli/cli-v2/src/commands/auth/status/command.ts b/packages/cli/cli-v2/src/commands/auth/status/command.ts index 772c0aee6afc..7f75a558fe8d 100644 --- a/packages/cli/cli-v2/src/commands/auth/status/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/status/command.ts @@ -27,9 +27,11 @@ export class StatusCommand { if (args.json) { context.stdout.info(JSON.stringify({ accounts: [], activeAccount: null }, null, 2)); } else { - context.stdout.warn(`${chalk.yellow("⚠")} You are not logged in to Fern.`); - context.stdout.info(""); - context.stdout.info(chalk.dim(" To log in, run: fern auth login")); + // Route non-JSON "not logged in" output through stderr so we + // don't pollute stdout pipes (e.g. `fern auth status | jq`). + context.stderr.warn(`${chalk.yellow("⚠")} You are not logged in to Fern.`); + context.stderr.info(""); + context.stderr.info(chalk.dim(" To log in, run: fern auth login")); } return; } diff --git a/packages/cli/cli-v2/src/commands/auth/whoami/command.ts b/packages/cli/cli-v2/src/commands/auth/whoami/command.ts index 958a63edc5bc..8e4bb7d16c0c 100644 --- a/packages/cli/cli-v2/src/commands/auth/whoami/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/whoami/command.ts @@ -15,10 +15,16 @@ export class WhoamiCommand { public async handle(context: Context, args: WhoamiCommand.Args): Promise { const activeAccount = await context.tokenService.getActiveAccountInfo(); if (activeAccount == null) { - context.stdout.warn(`${chalk.yellow("⚠")} You are not logged in to Fern.`); - context.stdout.info(""); - context.stdout.info(chalk.dim(" To log in, run: fern auth login")); - throw new CliError({ code: CliError.Code.AuthError }); + if (args.json) { + // JSON consumers expect the error envelope on stdout; mirror + // the human render to stderr via the error boundary. + context.stdout.info(JSON.stringify({ user: null, loggedIn: false }, null, 2)); + } + throw new CliError({ + code: CliError.Code.AuthError, + message: "You are not logged in to Fern.", + hint: "Run `fern auth login`, or set the FERN_TOKEN environment variable." + }); } if (args.json) { diff --git a/packages/cli/cli-v2/src/commands/check/command.ts b/packages/cli/cli-v2/src/commands/check/command.ts index 56a803bf766e..44ac18ae7e8e 100644 --- a/packages/cli/cli-v2/src/commands/check/command.ts +++ b/packages/cli/cli-v2/src/commands/check/command.ts @@ -10,6 +10,7 @@ import { DocsChecker } from "../../docs/checker/DocsChecker.js"; import { applyMdxFixes } from "../../docs/fixer/applyMdxFixes.js"; import { DocsFixer } from "../../docs/fixer/DocsFixer.js"; import { offerAiFixes } from "../../docs/fixer/offerAiFixes.js"; +import { formatViolations } from "../../errors/printViolations.js"; import { SdkChecker } from "../../sdk/checker/SdkChecker.js"; import { SdkFixer } from "../../sdk/fixer/SdkFixer.js"; import { Icons } from "../../ui/format.js"; @@ -172,9 +173,9 @@ export class CheckCommand { severity: string; }> ): void { - for (const v of violations) { - const color = v.severity === "warning" ? chalk.yellow : chalk.red; - process.stderr.write(`${color(`${v.displayRelativeFilepath}:${v.line}:${v.column}: ${v.message}`)}\n`); + const formatted = formatViolations(violations); + if (formatted.length > 0) { + process.stderr.write(`${formatted}\n`); } } diff --git a/packages/cli/cli-v2/src/commands/sdk/generate/command.ts b/packages/cli/cli-v2/src/commands/sdk/generate/command.ts index b922fe23feab..7049b530aa53 100644 --- a/packages/cli/cli-v2/src/commands/sdk/generate/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/generate/command.ts @@ -5,7 +5,6 @@ import { assertNever } from "@fern-api/core-utils"; import { AbsoluteFilePath, doesPathExist, resolve } from "@fern-api/fs-utils"; import { CliError, TaskAbortSignal } from "@fern-api/task-context"; import { ValidationIssue } from "@fern-api/yaml-loader"; -import chalk from "chalk"; import { readdir } from "fs/promises"; import inquirer from "inquirer"; import yaml from "js-yaml"; @@ -16,6 +15,7 @@ import { ApiSpecResolver } from "../../../api/resolver/ApiSpecResolver.js"; import { GENERATE_COMMAND_TIMEOUT_MS } from "../../../constants.js"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; +import { formatViolations } from "../../../errors/printViolations.js"; import { SourcedValidationError } from "../../../errors/SourcedValidationError.js"; import { isStdioMarker, StdioMarkerGuard } from "../../../io/stdio.js"; import { SdkChecker } from "../../../sdk/checker/SdkChecker.js"; @@ -218,10 +218,9 @@ export class GenerateCommand { apiNames: apisToCheck }); if (checkResult.violations.length > 0) { - for (const v of checkResult.violations) { - process.stderr.write( - `${chalk.red(`${v.displayRelativeFilepath}:${v.line}:${v.column}: ${v.message}`)}\n` - ); + const formatted = formatViolations(checkResult.violations); + if (formatted.length > 0) { + process.stderr.write(`${formatted}\n`); } } @@ -230,10 +229,9 @@ export class GenerateCommand { const sdkChecker = new SdkChecker({ context }); const sdkCheckResult = await sdkChecker.check({ workspace }); if (sdkCheckResult.violations.length > 0) { - for (const v of sdkCheckResult.violations) { - process.stderr.write( - `${chalk.red(`${v.displayRelativeFilepath}:${v.line}:${v.column}: ${v.message}`)}\n` - ); + const formatted = formatViolations(sdkCheckResult.violations); + if (formatted.length > 0) { + process.stderr.write(`${formatted}\n`); } } if (sdkCheckResult.errorCount > 0) { diff --git a/packages/cli/cli-v2/src/context/GlobalArgs.ts b/packages/cli/cli-v2/src/context/GlobalArgs.ts index ab141f25195a..b97b1fbccb1a 100644 --- a/packages/cli/cli-v2/src/context/GlobalArgs.ts +++ b/packages/cli/cli-v2/src/context/GlobalArgs.ts @@ -1,4 +1,9 @@ export interface GlobalArgs { "log-level": string; env?: string; + /** + * When true, render full stack traces (and `error.cause` chains) on + * failure. Mirrors `FERN_DEBUG=1`. Implies `--log-level debug`. + */ + debug?: boolean; } diff --git a/packages/cli/cli-v2/src/context/withContext.ts b/packages/cli/cli-v2/src/context/withContext.ts index 48f452608125..1b7866fe918b 100644 --- a/packages/cli/cli-v2/src/context/withContext.ts +++ b/packages/cli/cli-v2/src/context/withContext.ts @@ -2,10 +2,7 @@ import { LogLevel } from "@fern-api/logger"; import { CliError, resolveErrorCode, shouldReportToSentry, TaskAbortSignal } from "@fern-api/task-context"; import chalk from "chalk"; -import { KeyringUnavailableError } from "../auth/errors/KeyringUnavailableError.js"; -import { SourcedValidationError } from "../errors/SourcedValidationError.js"; -import { ValidationError } from "../errors/ValidationError.js"; -import { Icons } from "../ui/format.js"; +import { renderError } from "../errors/renderError.js"; import { maybeNag as maybeNagForUpgrade } from "../update/UpgradeNagger.js"; import { Context } from "./Context.js"; import type { GlobalArgs } from "./GlobalArgs.js"; @@ -52,7 +49,8 @@ export function withContext( } async function createContext(options: GlobalArgs): Promise { - const logLevel = parseLogLevel(options["log-level"] ?? "info"); + const debugMode = options.debug === true || isFernDebugEnv(); + const logLevel = debugMode ? LogLevel.Debug : parseLogLevel(options["log-level"] ?? "info"); const logDebug = logLevel === LogLevel.Debug ? (msg: string) => process.stderr.write(`${chalk.dim("[debug]")} ${msg}\n`) @@ -65,50 +63,34 @@ async function createContext(options: GlobalArgs): Promise { }); } +function isFernDebugEnv(): boolean { + const value = process.env.FERN_DEBUG; + if (value == null) { + return false; + } + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + /** * Handles errors by writing appropriate output to stderr. + * + * Goes through {@link renderError} so every CLI failure has the same + * Rust-style envelope (`error[CODE]: ` / `hint:` / `see:`). + * Also prints the per-run debug log file path so users can find it + * on the most common failure path (we previously only did this on + * SIGINT and `TaskGroup` failures). */ function handleError(context: Context, error: unknown): void { - if (error instanceof SourcedValidationError) { - for (const issue of error.issues) { - process.stderr.write(`${chalk.red(issue.toString())}\n`); - } - return; - } - - if (error instanceof ValidationError) { - for (const violation of error.violations) { - const color = violation.severity === "warning" ? chalk.yellow : chalk.red; - process.stderr.write(`${color(`${violation.relativeFilepath}: ${violation.message}`)}\n`); - } - return; - } - - if (error instanceof KeyringUnavailableError) { - context.stdout.error(`${Icons.error} ${error.message}`); - return; - } - if (error instanceof TaskAbortSignal) { return; } - - if (error instanceof CliError) { - if (error.message && error.message.length > 0) { - process.stderr.write(`${chalk.red(error.message)}\n`); - } - return; + const debug = context.logLevel === LogLevel.Debug; + const rendered = renderError(error, { debug }); + if (rendered != null) { + process.stderr.write(`${rendered}\n`); } - - if (error instanceof Error) { - process.stderr.write(`${chalk.red(error.message)}\n`); - if (error.stack != null && context.logLevel === LogLevel.Debug) { - process.stderr.write(`${chalk.dim(error.stack)}\n`); - } - return; - } - - process.stderr.write(`${chalk.red(String(error))}\n`); + context.printLogFilePath(process.stderr); } /** diff --git a/packages/cli/cli-v2/src/errors/printViolations.ts b/packages/cli/cli-v2/src/errors/printViolations.ts new file mode 100644 index 000000000000..296408f0259b --- /dev/null +++ b/packages/cli/cli-v2/src/errors/printViolations.ts @@ -0,0 +1,68 @@ +import { ValidationIssue } from "@fern-api/yaml-loader"; +import chalk from "chalk"; + +import type { ValidationViolation } from "./ValidationViolation.js"; + +export interface FormattableViolation { + displayRelativeFilepath: string; + line?: number; + column?: number; + message: string; + severity: "fatal" | "error" | "warning" | string; +} + +/** + * Format a list of validation violations into a multi-line string suitable for + * writing to stderr. + * + * - Errors are red, warnings are yellow. + * - Each line is `file:line:col: message` (line/col are omitted when unknown). + * - Returns "" when given an empty list — callers can append unconditionally. + */ +export function formatViolations(violations: ReadonlyArray<FormattableViolation>): string { + if (violations.length === 0) { + return ""; + } + return violations.map(formatSingleViolation).join("\n"); +} + +function formatSingleViolation(v: FormattableViolation): string { + const color = v.severity === "warning" ? chalk.yellow : chalk.red; + const location = formatLocation(v.displayRelativeFilepath, v.line, v.column); + return color(`${location}: ${v.message}`); +} + +function formatLocation(filepath: string, line?: number, column?: number): string { + const parts: string[] = [filepath]; + if (line != null) { + parts.push(String(line)); + if (column != null) { + parts.push(String(column)); + } + } + return parts.join(":"); +} + +/** + * Format a list of {@link ValidationIssue}s (used for YAML/JSON-schema-style + * validation where location info comes from a parser). + */ +export function formatIssues(issues: ReadonlyArray<ValidationIssue>): string { + if (issues.length === 0) { + return ""; + } + return issues.map((issue) => chalk.red(issue.toString())).join("\n"); +} + +/** + * Project a {@link ValidationViolation} (from the legacy `relativeFilepath` + * shape) into the canonical {@link FormattableViolation} shape used by the + * shared printer. + */ +export function toFormattableViolation(violation: ValidationViolation): FormattableViolation { + return { + displayRelativeFilepath: violation.relativeFilepath, + message: violation.message, + severity: violation.severity + }; +} diff --git a/packages/cli/cli-v2/src/errors/renderError.ts b/packages/cli/cli-v2/src/errors/renderError.ts new file mode 100644 index 000000000000..9aa948fd871e --- /dev/null +++ b/packages/cli/cli-v2/src/errors/renderError.ts @@ -0,0 +1,257 @@ +import { assertNever } from "@fern-api/core-utils"; +import { CliError, TaskAbortSignal } from "@fern-api/task-context"; +import chalk from "chalk"; + +import { KeyringUnavailableError } from "../auth/errors/KeyringUnavailableError.js"; +import { formatIssues, formatViolations, toFormattableViolation } from "./printViolations.js"; +import { SourcedValidationError } from "./SourcedValidationError.js"; +import { ValidationError } from "./ValidationError.js"; + +export interface RenderErrorOptions { + /** + * Whether the user has opted into debug mode (`--debug` or `FERN_DEBUG=1`). + * Enables stack/cause-chain rendering for all error classes. + */ + debug?: boolean; +} + +/** + * Render any thrown value into a multi-line stderr-ready string. + * + * The rendering follows a Rust-style envelope so all CLI errors share the + * same visual shape regardless of which error class they came from: + * + * ``` + * error[CODE]: <title> + * + * <detail body, e.g. a violation list or source snippet> + * + * hint: <next-step affordance> + * see: <docs URL> + * ``` + * + * Returns `null` for {@link TaskAbortSignal} so the boundary can silently + * exit on clean shutdowns (Ctrl+C handled upstream). + */ +export function renderError(error: unknown, options: RenderErrorOptions = {}): string | null { + if (error instanceof TaskAbortSignal) { + return null; + } + + if (error instanceof SourcedValidationError) { + return renderEnvelope({ + code: error.code, + title: titleForCode(error.code), + detail: formatIssues(error.issues), + hint: error.hint, + docsLink: error.docsLink, + error, + debug: options.debug + }); + } + + if (error instanceof ValidationError) { + return renderEnvelope({ + code: error.code, + title: titleForCode(error.code), + detail: formatViolations(error.violations.map(toFormattableViolation)), + hint: error.hint, + docsLink: error.docsLink, + error, + debug: options.debug + }); + } + + if (error instanceof KeyringUnavailableError) { + return renderEnvelope({ + code: error.code, + title: firstLine(error.message) ?? "Cannot access system keyring", + detail: restAfterFirstLine(error.message), + hint: error.hint, + docsLink: error.docsLink, + error, + debug: options.debug + }); + } + + if (error instanceof CliError) { + return renderEnvelope({ + code: error.code, + title: firstLine(error.message) ?? titleForCode(error.code), + detail: restAfterFirstLine(error.message), + hint: error.hint, + docsLink: error.docsLink, + error, + debug: options.debug + }); + } + + if (error instanceof Error) { + return renderEnvelope({ + code: undefined, + title: firstLine(error.message) ?? "An unexpected error occurred", + detail: restAfterFirstLine(error.message), + hint: undefined, + docsLink: undefined, + error, + debug: options.debug + }); + } + + return renderEnvelope({ + code: undefined, + title: stringifyUnknown(error), + detail: undefined, + hint: undefined, + docsLink: undefined, + error: undefined, + debug: options.debug + }); +} + +interface RenderEnvelopeArgs { + code: CliError.Code | undefined; + title: string; + detail: string | undefined; + hint: string | undefined; + docsLink: string | undefined; + error: Error | undefined; + debug: boolean | undefined; +} + +function renderEnvelope({ code, title, detail, hint, docsLink, error, debug }: RenderEnvelopeArgs): string { + const lines: string[] = []; + lines.push(formatHeader(code, title)); + + const trimmedDetail = detail?.replace(/^\n+|\n+$/g, ""); + if (trimmedDetail != null && trimmedDetail.length > 0) { + lines.push(""); + lines.push(trimmedDetail); + } + + const trailer: string[] = []; + if (hint != null && hint.length > 0) { + for (const line of hint.split("\n")) { + trailer.push(`${chalk.cyan("hint:")} ${line}`); + } + } + if (docsLink != null && docsLink.length > 0) { + trailer.push(`${chalk.dim("see:")} ${chalk.blue.underline(docsLink)}`); + } + if (trailer.length > 0) { + lines.push(""); + lines.push(...trailer); + } + + if (debug === true && error != null) { + const debugLines = collectDebugLines(error); + if (debugLines.length > 0) { + lines.push(""); + lines.push(...debugLines.map((l) => chalk.dim(l))); + } + } + + return lines.join("\n"); +} + +function formatHeader(code: CliError.Code | undefined, title: string): string { + const prefix = code != null ? `${chalk.red.bold(`error[${code}]`)}${chalk.bold(":")}` : chalk.red.bold("error:"); + return `${prefix} ${chalk.bold(title)}`; +} + +function firstLine(text: string | undefined): string | undefined { + if (text == null || text.length === 0) { + return undefined; + } + const nl = text.indexOf("\n"); + return nl === -1 ? text : text.slice(0, nl); +} + +function restAfterFirstLine(text: string | undefined): string | undefined { + if (text == null) { + return undefined; + } + const nl = text.indexOf("\n"); + if (nl === -1) { + return undefined; + } + const rest = text.slice(nl + 1); + return rest.length > 0 ? rest : undefined; +} + +function titleForCode(code: CliError.Code): string { + switch (code) { + case "VALIDATION_ERROR": + return "Validation failed"; + case "AUTH_ERROR": + return "Authentication failed"; + case "CONFIG_ERROR": + return "Configuration error"; + case "NETWORK_ERROR": + return "Network error"; + case "ENVIRONMENT_ERROR": + return "Environment error"; + case "CONTAINER_ERROR": + return "Container error"; + case "PARSE_ERROR": + return "Parse error"; + case "REFERENCE_ERROR": + return "Reference error"; + case "RESOLUTION_ERROR": + return "Resolution failed"; + case "IR_CONVERSION_ERROR": + return "IR conversion failed"; + case "VERSION_ERROR": + return "Version mismatch"; + case "USER_ERROR": + return "Invalid usage"; + case "INTERNAL_ERROR": + return "Internal error"; + default: + assertNever(code); + } +} + +function stringifyUnknown(error: unknown): string { + if (error == null) { + return "An unknown error occurred"; + } + if (typeof error === "string") { + return error; + } + try { + const stringified = JSON.stringify(error); + if (stringified != null && stringified !== "{}") { + return stringified; + } + } catch { + // fall through + } + return String(error); +} + +function collectDebugLines(error: Error): string[] { + const out: string[] = []; + if (error.stack != null) { + out.push(error.stack); + } else { + out.push(`${error.name}: ${error.message}`); + } + let cause: unknown = (error as { cause?: unknown }).cause; + let depth = 0; + while (cause != null && depth < 5) { + out.push(""); + if (cause instanceof Error) { + out.push(`caused by: ${cause.name}: ${cause.message}`); + if (cause.stack != null) { + out.push(cause.stack); + } + cause = (cause as { cause?: unknown }).cause; + } else { + out.push(`caused by: ${stringifyUnknown(cause)}`); + cause = undefined; + } + depth += 1; + } + return out; +} diff --git a/packages/cli/cli/changes/unreleased/cli-v2-error-renderer.yml b/packages/cli/cli/changes/unreleased/cli-v2-error-renderer.yml new file mode 100644 index 000000000000..82740cf1c513 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/cli-v2-error-renderer.yml @@ -0,0 +1,13 @@ +- summary: | + Unify CLI error rendering. All `cli-v2` failures now print the same + Rust-style envelope on stderr (`error[CODE]: <title>` with optional + `hint:` and `see:` lines), the per-run debug log path is shown on every + failure, and the duplicated yargs `.fail` handlers route through the + same renderer. Also fixes `fern auth whoami` / `fern auth status` / + keyring failures printing to stdout instead of stderr. + type: feat +- summary: | + Add a top-level `--debug` flag (and `FERN_DEBUG=1` env var) that + renders the full stack trace and `error.cause` chain on failure for + any error class. Implies `--log-level debug`. + type: feat diff --git a/packages/cli/task-context/src/CliError.ts b/packages/cli/task-context/src/CliError.ts index 9eeecf60465b..e193120c56be 100644 --- a/packages/cli/task-context/src/CliError.ts +++ b/packages/cli/task-context/src/CliError.ts @@ -1,8 +1,23 @@ export class CliError extends Error { public readonly code: CliError.Code; public readonly docsLink?: string; - - constructor({ message, code, docsLink }: { code: CliError.Code; message?: string; docsLink?: string }) { + /** + * Optional actionable hint shown to the user underneath the error message + * (rendered as a `hint:` line, like rustc/cargo). Keep short and imperative. + */ + public readonly hint?: string; + + constructor({ + message, + code, + docsLink, + hint + }: { + code: CliError.Code; + message?: string; + docsLink?: string; + hint?: string; + }) { super(message); Object.setPrototypeOf(this, new.target.prototype); @@ -13,22 +28,22 @@ export class CliError extends Error { this.code = code; this.docsLink = docsLink; + this.hint = hint; } public static authRequired(message?: string): CliError { return new CliError({ - message: - message ?? - "Authentication required. Please run 'fern login' or set the FERN_TOKEN environment variable.", - code: CliError.Code.AuthError + message: message ?? "Authentication required.", + code: CliError.Code.AuthError, + hint: "Run `fern auth login`, or set the FERN_TOKEN environment variable." }); } public static unauthorized(message?: string): CliError { return new CliError({ - message: - message ?? "Unauthorized. Please run 'fern auth login' or set the FERN_TOKEN environment variable.", - code: CliError.Code.AuthError + message: message ?? "Unauthorized.", + code: CliError.Code.AuthError, + hint: "Run `fern auth login`, or set the FERN_TOKEN environment variable." }); }