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
82 changes: 82 additions & 0 deletions packages/cli/cli-v2/src/__test__/printViolations.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
145 changes: 145 additions & 0 deletions packages/cli/cli-v2/src/__test__/renderError.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
18 changes: 7 additions & 11 deletions packages/cli/cli-v2/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -114,6 +115,11 @@ function createCliV2(argv?: string[]): Argv<GlobalArgs> {
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"
Expand All @@ -122,17 +128,7 @@ function createCliV2(argv?: string[]): Argv<GlobalArgs> {
.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);
Expand Down
14 changes: 3 additions & 11 deletions packages/cli/cli-v2/src/commands/_internal/commandGroup.ts
Original file line number Diff line number Diff line change
@@ -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<GlobalArgs>) => void;

Expand Down Expand Up @@ -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) {
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts
Original file line number Diff line number Diff line change
@@ -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<GlobalArgs>) => 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);
};
}
8 changes: 5 additions & 3 deletions packages/cli/cli-v2/src/commands/auth/status/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
14 changes: 10 additions & 4 deletions packages/cli/cli-v2/src/commands/auth/whoami/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ export class WhoamiCommand {
public async handle(context: Context, args: WhoamiCommand.Args): Promise<void> {
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) {
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/cli-v2/src/commands/check/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`);
}
}

Expand Down
Loading