Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
150 changes: 150 additions & 0 deletions packages/cli/cli-v2/src/__test__/wellKnownErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { CliError } from "@fern-api/task-context";
import { describe, expect, it } from "vitest";

import { FernCliErrors } from "../errors/wellKnown/CliErrors.js";

describe("FernCliErrors", () => {
describe("AuthRequired", () => {
it("uses the default message when no override is supplied", () => {
const err = FernCliErrors.AuthRequired();
expect(err).toBeInstanceOf(CliError);
expect(err.code).toBe(CliError.Code.AuthError);
expect(err.message).toBe("Authentication required.");
expect(err.hint).toContain("fern auth login");
expect(err.docsLink).toBe("https://buildwithfern.com/learn/cli/auth");
});

it("respects a caller-supplied message but keeps the shared hint and docs link", () => {
const err = FernCliErrors.AuthRequired({ message: "You are not logged in to Fern." });
expect(err.message).toBe("You are not logged in to Fern.");
expect(err.hint).toContain("fern auth login");
expect(err.docsLink).toBe("https://buildwithfern.com/learn/cli/auth");
});
});

it("Unauthorized maps to AUTH_ERROR with a refresh hint", () => {
const err = FernCliErrors.Unauthorized();
expect(err.code).toBe(CliError.Code.AuthError);
expect(err.message).toMatch(/rejected/i);
expect(err.hint).toContain("fern auth login");
});

it("FernYmlNotFound includes the cwd in the message and points at `fern init`", () => {
const err = FernCliErrors.FernYmlNotFound({ cwd: "/tmp/proj" });
expect(err.code).toBe(CliError.Code.ConfigError);
expect(err.message).toContain("/tmp/proj");
expect(err.hint).toContain("fern init");
});

it("FlagsMutex names both flags symmetrically", () => {
const err = FernCliErrors.FlagsMutex({ a: "--group", b: "--target" });
expect(err.code).toBe(CliError.Code.ConfigError);
expect(err.message).toContain("--group");
expect(err.message).toContain("--target");
expect(err.hint).toContain("--group");
expect(err.hint).toContain("--target");
});

it("FlagRequires names both the dependent and required flag", () => {
const err = FernCliErrors.FlagRequires({ flag: "--container-engine", requires: "--local" });
expect(err.code).toBe(CliError.Code.ConfigError);
expect(err.message).toContain("--container-engine");
expect(err.message).toContain("--local");
expect(err.hint).toContain("--local");
expect(err.hint).toContain("--container-engine");
});

it("MissingRequiredFlags renders the flags as an indented bullet list", () => {
const err = FernCliErrors.MissingRequiredFlags({
missing: ["--target <language>", "--org <name>"]
});
expect(err.code).toBe(CliError.Code.ConfigError);
expect(err.message).toContain(" --target <language>");
expect(err.message).toContain(" --org <name>");
expect(err.hint).toContain("--help");
});

it("FileNotFound surfaces the path the user passed and provides a hint", () => {
const err = FernCliErrors.FileNotFound({ path: "./openapi.yml" });
expect(err.code).toBe(CliError.Code.ConfigError);
expect(err.message).toContain("./openapi.yml");
expect(err.hint).toContain("typos");
});

it("UnsupportedValue lists the supported set in the hint", () => {
const err = FernCliErrors.UnsupportedValue({
what: "language",
value: "cobol",
supported: ["typescript", "python", "go"]
});
expect(err.code).toBe(CliError.Code.ConfigError);
expect(err.message).toContain("cobol");
expect(err.hint).toContain("language values");
expect(err.hint).toContain("typescript");
expect(err.hint).toContain("python");
expect(err.hint).toContain("go");
});

it("HttpFetchFailed includes URL, status code, and status text", () => {
const err = FernCliErrors.HttpFetchFailed({
url: "https://example.com/spec",
status: 503,
statusText: "Service Unavailable"
});
expect(err.code).toBe(CliError.Code.NetworkError);
expect(err.message).toContain("https://example.com/spec");
expect(err.message).toContain("503");
expect(err.message).toContain("Service Unavailable");
});

it("HttpFetchFailed omits status text when the server returns an empty reason phrase", () => {
const err = FernCliErrors.HttpFetchFailed({ url: "https://example.com/spec", status: 500, statusText: "" });
expect(err.message).toContain("500");
expect(err.message).not.toContain("500 .");
expect(err.message).toMatch(/HTTP 500\.$/);
});

it("EmptyStdin points the user at piping", () => {
const err = FernCliErrors.EmptyStdin();
expect(err.code).toBe(CliError.Code.ConfigError);
expect(err.message).toMatch(/stdin/i);
expect(err.hint).toContain("|");
});

it("ValidationFailed has no message (caller has already printed violations) but carries a hint", () => {
const err = FernCliErrors.ValidationFailed();
expect(err.code).toBe(CliError.Code.ValidationError);
expect(err.message).toBe("");
expect(err.hint).toBeDefined();
});

it("InternalError links to the bug-report flow", () => {
const err = FernCliErrors.InternalError({ details: "task 'foo' not found" });
expect(err.code).toBe(CliError.Code.InternalError);
expect(err.message).toContain("task 'foo' not found");
expect(err.docsLink).toBe("https://github.com/fern-api/fern/issues/new");
});

it("every entry returns a CliError with a non-empty hint or docsLink", () => {
// Smoke test: every template must give the user *something* to act on
// (or, for ValidationFailed, an empty message but still a hint).
const samples: CliError[] = [
FernCliErrors.AuthRequired(),
FernCliErrors.Unauthorized(),
FernCliErrors.FernYmlNotFound({ cwd: "/" }),
FernCliErrors.FlagsMutex({ a: "--a", b: "--b" }),
FernCliErrors.FlagRequires({ flag: "--a", requires: "--b" }),
FernCliErrors.MissingRequiredFlags({ missing: ["x"] }),
FernCliErrors.FileNotFound({ path: "x" }),
FernCliErrors.UnsupportedValue({ what: "x", value: "y", supported: ["z"] }),
FernCliErrors.HttpFetchFailed({ url: "x", status: 500, statusText: "" }),
FernCliErrors.EmptyStdin(),
FernCliErrors.ValidationFailed(),
FernCliErrors.InternalError({ details: "x" })
];
for (const err of samples) {
expect(err).toBeInstanceOf(CliError);
expect(err.hint != null || err.docsLink != null).toBe(true);
}
});
});
18 changes: 7 additions & 11 deletions packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "path";
import { Readable } from "stream";
import { FETCH_API_SPEC_REQUEST_TIMEOUT_MS } from "../../constants.js";
import type { Context } from "../../context/Context.js";
import { FernCliErrors } from "../../errors/wellKnown/CliErrors.js";
import { isStdioMarker, readInput, STDIO_MARKER } from "../../io/stdio.js";
import type { ApiSpec, ApiSpecType } from "../config/ApiSpec.js";
import { ApiSpecDetector } from "./ApiSpecDetector.js";
Expand Down Expand Up @@ -53,10 +54,7 @@ export class ApiSpecResolver {
private async resolveStdin({ stdin }: { stdin?: Readable }): Promise<ApiSpecResolver.Result> {
const content = await readInput(STDIO_MARKER, { stdin });
if (content.trim().length === 0) {
throw new CliError({
message: 'No input received on stdin (--api "-").',
code: CliError.Code.ConfigError
});
throw FernCliErrors.EmptyStdin();
}
const extension = this.inferExtensionFromContent(content);
const tempDir = await mkdtemp(path.join(tmpdir(), "fern-"));
Expand Down Expand Up @@ -95,10 +93,7 @@ export class ApiSpecResolver {
private async resolveLocal({ reference }: { reference: string }): Promise<ApiSpecResolver.Result> {
const absoluteFilePath = resolve(this.context.cwd, reference);
if (!(await doesPathExist(absoluteFilePath))) {
throw new CliError({
message: `API spec file does not exist: ${reference}`,
code: CliError.Code.ConfigError
});
throw FernCliErrors.FileNotFound({ path: reference });
}

const content = await readFile(absoluteFilePath, "utf-8");
Expand All @@ -113,9 +108,10 @@ export class ApiSpecResolver {
private async fetchContent({ url }: { url: string }): Promise<{ content: string; contentType: string }> {
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_API_SPEC_REQUEST_TIMEOUT_MS) });
if (!response.ok) {
throw new CliError({
message: `Failed to fetch "${url}": HTTP ${response.status} ${response.statusText}`,
code: CliError.Code.NetworkError
throw FernCliErrors.HttpFetchFailed({
url,
status: response.status,
statusText: response.statusText
});
}
const contentType = response.headers.get("content-type") ?? "";
Expand Down
11 changes: 3 additions & 8 deletions packages/cli/cli-v2/src/commands/auth/whoami/command.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import type { Argv } from "yargs";
import type { Context } from "../../../context/Context.js";
import type { GlobalArgs } from "../../../context/GlobalArgs.js";
import { FernCliErrors } from "../../../errors/wellKnown/CliErrors.js";
import { command } from "../../_internal/command.js";

export declare namespace WhoamiCommand {
Expand All @@ -16,15 +16,10 @@ export class WhoamiCommand {
const activeAccount = await context.tokenService.getActiveAccountInfo();
if (activeAccount == null) {
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));
return;
}
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."
});
throw FernCliErrors.AuthRequired({ message: "You are not logged in to Fern." });
}

if (args.json) {
Expand Down
19 changes: 7 additions & 12 deletions packages/cli/cli-v2/src/commands/sdk/add/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FERN_YML_FILENAME } from "../../../config/fern-yml/constants.js";
import { FernYmlEditor } from "../../../config/fern-yml/FernYmlEditor.js";
import type { Context } from "../../../context/Context.js";
import type { GlobalArgs } from "../../../context/GlobalArgs.js";
import { FernCliErrors } from "../../../errors/wellKnown/CliErrors.js";
import { SdkChecker } from "../../../sdk/checker/SdkChecker.js";
import { LANGUAGE_TO_DOCKER_IMAGE } from "../../../sdk/config/converter/constants.js";
import { LANGUAGE_DISPLAY_NAMES, LANGUAGE_ORDER, LANGUAGES, type Language } from "../../../sdk/config/Language.js";
Expand Down Expand Up @@ -45,10 +46,7 @@ export class AddCommand {

const fernYmlPath = workspace.absoluteFilePath;
if (fernYmlPath == null) {
throw new CliError({
message: `No ${FERN_YML_FILENAME} found. Run 'fern init' to initialize a project.`,
code: CliError.Code.ConfigError
});
throw FernCliErrors.FernYmlNotFound({ cwd: context.cwd });
}

const sdkChecker = new SdkChecker({ context });
Expand Down Expand Up @@ -78,9 +76,8 @@ export class AddCommand {
existingTargets: Target[];
}): Promise<void> {
if (args.target == null) {
throw new CliError({
message: `Missing required flags:\n\n --target <language> SDK language (e.g. typescript, python, go)`,
code: CliError.Code.ConfigError
throw FernCliErrors.MissingRequiredFlags({
missing: ["--target <language> SDK language (e.g. typescript, python, go)"]
});
}

Expand Down Expand Up @@ -183,7 +180,8 @@ export class AddCommand {
if (existingTargets.some((t) => t.name === language)) {
throw new CliError({
message: `Target '${language}' already exists in ${FERN_YML_FILENAME}.`,
code: CliError.Code.ConfigError
code: CliError.Code.ConfigError,
hint: `Remove the existing '${language}' target before re-adding, or use \`fern sdk update\`.`
});
}
}
Expand Down Expand Up @@ -251,10 +249,7 @@ export class AddCommand {
if (LANGUAGES.includes(lang)) {
return lang;
}
throw new CliError({
message: `"${target}" is not a supported language. Supported: ${LANGUAGES.join(", ")}`,
code: CliError.Code.ConfigError
});
throw FernCliErrors.UnsupportedValue({ what: "language", value: target, supported: LANGUAGES });
}
}

Expand Down
21 changes: 7 additions & 14 deletions packages/cli/cli-v2/src/commands/sdk/generate/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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 { FernCliErrors } from "../../../errors/wellKnown/CliErrors.js";
import { isStdioMarker, StdioMarkerGuard } from "../../../io/stdio.js";
import { SdkChecker } from "../../../sdk/checker/SdkChecker.js";
import { LANGUAGES, type Language } from "../../../sdk/config/Language.js";
Expand Down Expand Up @@ -384,28 +385,20 @@ export class GenerateCommand {
targets: Target[];
}): void {
if (args["container-engine"] != null && !args.local) {
throw new CliError({
message: "The --container-engine flag can only be used with --local",
code: CliError.Code.ConfigError
});
throw FernCliErrors.FlagRequires({ flag: "--container-engine", requires: "--local" });
}
if (args.group != null && args.target != null) {
throw new CliError({
message: "The --group and --target flags cannot be used together",
code: CliError.Code.ConfigError
});
throw FernCliErrors.FlagsMutex({ a: "--group", b: "--target" });
}
if (targets.length > 1 && args.output != null) {
throw new CliError({
message: "The --output flag can only be used when generating a single target",
code: CliError.Code.ConfigError
message: "The --output flag can only be used when generating a single target.",
code: CliError.Code.ConfigError,
hint: "Narrow your selection with --group or --target, or omit --output to write per-target."
});
}
if (args["skip-fernignore"] && args.fernignore != null) {
throw new CliError({
message: "The --skip-fernignore and --fernignore flags cannot be used together.",
code: CliError.Code.ConfigError
});
throw FernCliErrors.FlagsMutex({ a: "--skip-fernignore", b: "--fernignore" });
}
const issues: ValidationIssue[] = [];
if (args.local) {
Expand Down
Loading