diff --git a/packages/cli/cli-v2/src/__test__/wellKnownErrors.test.ts b/packages/cli/cli-v2/src/__test__/wellKnownErrors.test.ts new file mode 100644 index 000000000000..60037d380c5b --- /dev/null +++ b/packages/cli/cli-v2/src/__test__/wellKnownErrors.test.ts @@ -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 ", "--org "] + }); + expect(err.code).toBe(CliError.Code.ConfigError); + expect(err.message).toContain(" --target "); + expect(err.message).toContain(" --org "); + 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); + } + }); +}); diff --git a/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts b/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts index e99a6078f082..c38a550a3bc0 100644 --- a/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts +++ b/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts @@ -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"; @@ -53,10 +54,7 @@ export class ApiSpecResolver { private async resolveStdin({ stdin }: { stdin?: Readable }): Promise { 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-")); @@ -95,10 +93,7 @@ export class ApiSpecResolver { private async resolveLocal({ reference }: { reference: string }): Promise { 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"); @@ -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") ?? ""; 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 8e4bb7d16c0c..0c772dcaf3c2 100644 --- a/packages/cli/cli-v2/src/commands/auth/whoami/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/whoami/command.ts @@ -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 { @@ -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) { diff --git a/packages/cli/cli-v2/src/commands/sdk/add/command.ts b/packages/cli/cli-v2/src/commands/sdk/add/command.ts index e791487aeb74..4ecd3f84a33f 100644 --- a/packages/cli/cli-v2/src/commands/sdk/add/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/add/command.ts @@ -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"; @@ -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 }); @@ -78,9 +76,8 @@ export class AddCommand { existingTargets: Target[]; }): Promise { if (args.target == null) { - throw new CliError({ - message: `Missing required flags:\n\n --target SDK language (e.g. typescript, python, go)`, - code: CliError.Code.ConfigError + throw FernCliErrors.MissingRequiredFlags({ + missing: ["--target SDK language (e.g. typescript, python, go)"] }); } @@ -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\`.` }); } } @@ -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 }); } } 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 7049b530aa53..2af5ddb41ae1 100644 --- a/packages/cli/cli-v2/src/commands/sdk/generate/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/generate/command.ts @@ -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"; @@ -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) { diff --git a/packages/cli/cli-v2/src/errors/wellKnown/CliErrors.ts b/packages/cli/cli-v2/src/errors/wellKnown/CliErrors.ts new file mode 100644 index 000000000000..14b4ce5c25ec --- /dev/null +++ b/packages/cli/cli-v2/src/errors/wellKnown/CliErrors.ts @@ -0,0 +1,184 @@ +import { CliError } from "@fern-api/task-context"; + +/** + * Registry of well-known CLI errors. + * + * Each entry is a tiny factory that returns a {@link CliError} with a fixed + * code, a templated message, an actionable hint, and (when appropriate) a + * docs link. This is the cli-v2 equivalent of `MdxErrorCode` for the rest of + * the CLI surface: adding a new well-known failure is a 5-line PR here + * (template + test + call-site swap), and every consumer of that template + * gets identical UX automatically. + * + * Conventions: + * + * - Keep messages **declarative and single-sentence** ("X not found.", not + * "Could not find X."). The renderer adds the `error[CODE]:` prefix. + * - Keep hints **imperative and copy-pasteable** ("Run `fern init`.", not + * "Try running fern init"). The renderer adds the `hint:` prefix. + * - Only set `docsLink` when the corresponding learn page exists today. + * Shipping a 404 is worse than no link at all. + * - One template per distinct failure mode. If two templates collapse into + * the same message + hint, merge them. + */ +export const FernCliErrors = { + /** + * The user invoked a command that requires authentication, but no token + * is available. Use this for the "you must log in" path, NOT for "your + * token was rejected by the server" — that's {@link Unauthorized}. + * + * `message` is optional so call sites can phrase the failure naturally + * for their context (e.g. `whoami` wants "You are not logged in.", but + * the hint and docs link stay consistent across the CLI). + */ + AuthRequired({ message }: { message?: string } = {}): CliError { + return new CliError({ + message: message ?? "Authentication required.", + code: CliError.Code.AuthError, + hint: "Run `fern auth login`, or set the FERN_TOKEN environment variable.", + docsLink: "https://buildwithfern.com/learn/cli/auth" + }); + }, + + /** + * The user is logged in but their token was rejected (expired or revoked). + */ + Unauthorized(): CliError { + return new CliError({ + message: "Your Fern token was rejected.", + code: CliError.Code.AuthError, + hint: "Run `fern auth login` to refresh, or set FERN_TOKEN to a valid token.", + docsLink: "https://buildwithfern.com/learn/cli/auth" + }); + }, + + /** + * The command requires a `fern.yml` in (or above) the current directory + * and there isn't one. + */ + FernYmlNotFound({ cwd }: { cwd: string }): CliError { + return new CliError({ + message: `No fern.yml found in ${cwd} or any parent directory.`, + code: CliError.Code.ConfigError, + hint: "Run `fern init` to initialize a project here." + }); + }, + + /** + * Two flags were passed that cannot be used together (mutually exclusive). + * Example: `--group` and `--target` on `sdk generate`. + */ + FlagsMutex({ a, b }: { a: string; b: string }): CliError { + return new CliError({ + message: `The ${a} and ${b} flags cannot be used together.`, + code: CliError.Code.ConfigError, + hint: `Pass either ${a} or ${b}, not both.` + }); + }, + + /** + * A flag was passed that requires another flag to be set, and the other + * flag was not provided. Example: `--container-engine` without `--local`. + */ + FlagRequires({ flag, requires }: { flag: string; requires: string }): CliError { + return new CliError({ + message: `The ${flag} flag can only be used with ${requires}.`, + code: CliError.Code.ConfigError, + hint: `Add ${requires} to your command, or remove ${flag}.` + }); + }, + + /** + * A required flag was not provided. `missing` should be a one-line + * description of the flag, e.g. `--target SDK language`. + */ + MissingRequiredFlags({ missing }: { missing: readonly string[] }): CliError { + const list = missing.map((line) => ` ${line}`).join("\n"); + return new CliError({ + message: `Missing required flags:\n\n${list}`, + code: CliError.Code.ConfigError, + hint: "See `--help` for the full list of options." + }); + }, + + /** + * A path to a local file the user passed does not exist on disk. + */ + FileNotFound({ path }: { path: string }): CliError { + return new CliError({ + message: `File not found: ${path}`, + code: CliError.Code.ConfigError, + hint: "Check the path for typos and that the file exists relative to the current directory." + }); + }, + + /** + * A value (a language, a spec type, a target name, etc.) was passed that + * is not in the supported set. + */ + UnsupportedValue({ + what, + value, + supported + }: { + what: string; + value: string; + supported: readonly string[]; + }): CliError { + return new CliError({ + message: `"${value}" is not a supported ${what}.`, + code: CliError.Code.ConfigError, + hint: `Supported ${what} values: ${supported.join(", ")}.` + }); + }, + + /** + * A remote HTTP fetch failed with a non-2xx status. Use this for + * downloads, registry calls, and other "we tried to read a URL" failures. + */ + HttpFetchFailed({ url, status, statusText }: { url: string; status: number; statusText: string }): CliError { + const statusDescription = statusText.length > 0 ? ` ${statusText}` : ""; + return new CliError({ + message: `Failed to fetch "${url}": HTTP ${status}${statusDescription}.`, + code: CliError.Code.NetworkError, + hint: "Check your network connection and verify the URL is correct." + }); + }, + + /** + * `--input` was set to stdin (`-`) but no bytes were piped in. + */ + EmptyStdin(): CliError { + return new CliError({ + message: 'No input received on stdin (--api "-").', + code: CliError.Code.ConfigError, + hint: "Pipe a spec into the command, e.g. `cat openapi.yml | fern --api -`." + }); + }, + + /** + * A user-visible "validation failed" error with no extra message. The + * detail body is expected to have been printed already by the caller's + * violation printer. Used when the renderer would otherwise see an empty + * CliError and fall back to the per-code title alone. + */ + ValidationFailed(): CliError { + return new CliError({ + code: CliError.Code.ValidationError, + hint: "Fix the issues listed above and re-run." + }); + }, + + /** + * Fern reached a state it never should have. Strongly signals a bug — the + * `docsLink` points to the bug report flow. + */ + InternalError({ details }: { details: string }): CliError { + return new CliError({ + message: `Internal error: ${details}`, + code: CliError.Code.InternalError, + hint: "This is a Fern bug. Re-run with `--debug` and file an issue with the output.", + docsLink: "https://github.com/fern-api/fern/issues/new" + }); + } +}; diff --git a/packages/cli/cli/changes/unreleased/cli-v2-well-known-errors.yml b/packages/cli/cli/changes/unreleased/cli-v2-well-known-errors.yml new file mode 100644 index 000000000000..12ec390fe08a --- /dev/null +++ b/packages/cli/cli/changes/unreleased/cli-v2-well-known-errors.yml @@ -0,0 +1,17 @@ +- summary: | + Add a registry of well-known cli-v2 errors at + `packages/cli/cli-v2/src/errors/wellKnown/CliErrors.ts`. + + Each entry is a tiny factory that returns a `CliError` with a fixed + code, a templated message, an actionable hint, and (where appropriate) + a docs link — modeled on `MdxErrorCode` for the rest of the CLI + surface. Adding a new well-known failure is a 5-line PR there + (template + test + call-site swap) and every consumer gets identical + UX automatically. + + Initial templates: `AuthRequired`, `Unauthorized`, `FernYmlNotFound`, + `FlagsMutex`, `FlagRequires`, `MissingRequiredFlags`, `FileNotFound`, + `UnsupportedValue`, `HttpFetchFailed`, `EmptyStdin`, `ValidationFailed`, + `InternalError`. Migrates representative call sites in `sdk generate`, + `sdk add`, `auth whoami`, and `ApiSpecResolver` to use them. + type: feat