Skip to content
6 changes: 6 additions & 0 deletions packages/cli/cli-logger/src/logErrorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Logger, LogLevel } from "@fern-api/logger";
import { TaskAbortSignal } from "@fern-api/task-context";
import chalk from "chalk";

const USE_NODE_18_OR_ABOVE_MESSAGE = "The Fern CLI requires Node 18+ or above.";

export function logErrorMessage({
message,
error,
Expand Down Expand Up @@ -42,6 +44,10 @@ function convertErrorToString(error: unknown): string | undefined {
return error;
}
if (error instanceof Error) {
if ((error as Error)?.message?.includes("globalThis")) {
return USE_NODE_18_OR_ABOVE_MESSAGE;
}

return error.message;
}
return undefined;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/cli-v2/src/__test__/compile.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { AbsoluteFilePath, doesPathExist } from "@fern-api/fs-utils";
import { CliError } from "@fern-api/task-context";

import { randomUUID } from "crypto";
import { readFile, rm } from "fs/promises";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { CompileCommand } from "../commands/api/compile/command.js";
import { CliError } from "../errors/CliError.js";
import { createTestContextWithCapture } from "./utils/createTestContext.js";

const FIXTURES_DIR = AbsoluteFilePath.of(join(__dirname, "fixtures"));
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/cli-v2/src/auth/errors/KeyringUnavailableError.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { CliError } from "@fern-api/task-context";

/**
* Error thrown when the system keyring is unavailable.
*/
export class KeyringUnavailableError extends Error {
export class KeyringUnavailableError extends CliError {
public readonly platform: NodeJS.Platform;
public readonly cause?: Error;
public override readonly cause?: Error;

constructor(platform: NodeJS.Platform, cause?: Error) {
super(getKeyringErrorMessage(platform));
super({
message: getKeyringErrorMessage(platform),
code: CliError.Code.AuthError
});
Object.setPrototypeOf(this, KeyringUnavailableError.prototype);
this.platform = platform;
this.cause = cause;
}
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/cli-v2/src/commands/api/check/command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { CliError } from "@fern-api/task-context";

import chalk from "chalk";
import type { Argv } from "yargs";
import { ApiChecker } from "../../../api/checker/ApiChecker.js";
import type { Context } from "../../../context/Context.js";
import type { GlobalArgs } from "../../../context/GlobalArgs.js";
import { CliError } from "../../../errors/CliError.js";
import { Icons } from "../../../ui/format.js";
import { command } from "../../_internal/command.js";
import { type JsonOutput, toJsonViolation } from "../../_internal/toJsonViolation.js";
Expand All @@ -26,7 +27,8 @@ export class CheckCommand {
if (args.api != null && workspace.apis[args.api] == null) {
const availableApis = Object.keys(workspace.apis).join(", ");
throw new CliError({
message: `API '${args.api}' not found. Available APIs: ${availableApis}`
message: `API '${args.api}' not found. Available APIs: ${availableApis}`,
code: CliError.Code.ConfigError
});
}

Expand All @@ -43,7 +45,7 @@ export class CheckCommand {
const response = this.buildJsonResponse({ apiCheckResult: result, hasErrors });
context.stdout.info(JSON.stringify(response, null, 2));
if (hasErrors) {
throw CliError.exit();
throw CliError.validationError();
}
return;
}
Expand All @@ -56,7 +58,7 @@ export class CheckCommand {
}

if (hasErrors) {
throw CliError.exit();
throw CliError.validationError();
}

if (result.warningCount > 0) {
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/cli-v2/src/commands/api/compile/command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Audiences } from "@fern-api/configuration";
import { streamObjectToFile } from "@fern-api/fs-utils";
import { CliError } from "@fern-api/task-context";

import chalk from "chalk";
import { JsonStreamStringify } from "json-stream-stringify";
import type { Argv } from "yargs";
Expand All @@ -8,7 +10,6 @@ import { IrCompiler } from "../../../api/compiler/IrCompiler.js";
import type { ApiDefinition } from "../../../api/config/ApiDefinition.js";
import type { Context } from "../../../context/Context.js";
import type { GlobalArgs } from "../../../context/GlobalArgs.js";
import { CliError } from "../../../errors/CliError.js";
import { LANGUAGES } from "../../../sdk/config/Language.js";
import type { Workspace } from "../../../workspace/Workspace.js";
import { command } from "../../_internal/command.js";
Expand Down Expand Up @@ -67,7 +68,8 @@ export class CompileCommand {
if (definition == null) {
const available = apiNames.join(", ");
throw new CliError({
message: `API '${args.api}' not found. Available APIs: ${available}`
message: `API '${args.api}' not found. Available APIs: ${available}`,
code: CliError.Code.ConfigError
});
}
return { apiName: args.api, definition };
Expand All @@ -77,21 +79,24 @@ export class CompileCommand {
const apiName = apiNames[0];
if (apiName == null) {
throw new CliError({
message: "Internal error; no APIs found in workspace"
message: "Internal error; no APIs found in workspace",
code: CliError.Code.InternalError
});
}
const definition = workspace.apis[apiName];
if (definition == null) {
throw new CliError({
message: `Internal error; API '${apiName}' not found in workspace`
message: `Internal error; API '${apiName}' not found in workspace`,
code: CliError.Code.InternalError
});
}
return { apiName, definition };
}

const available = apiNames.join(", ");
throw new CliError({
message: `Multiple APIs found: ${available}. Use --api to select one.`
message: `Multiple APIs found: ${available}. Use --api to select one.`,
code: CliError.Code.ConfigError
});
}

Expand All @@ -112,7 +117,7 @@ export class CompileCommand {
`${violation.displayRelativeFilepath}:${violation.line}:${violation.column}: ${violation.message}`
);
}
throw CliError.exit();
throw CliError.validationError(`API '${apiName}' has ${result.violations.length} validation errors`);
}
}

Expand Down
8 changes: 5 additions & 3 deletions packages/cli/cli-v2/src/commands/api/merge/command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { applyOpenAPIOverlay, mergeWithOverrides } from "@fern-api/core-utils";
import { CliError } from "@fern-api/task-context";

import chalk from "chalk";
import { unlink, writeFile } from "fs/promises";
import path from "path";
Expand All @@ -7,7 +9,6 @@ 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 { CliError } from "../../../errors/CliError.js";
import { Icons } from "../../../ui/format.js";
import { command } from "../../_internal/command.js";
import { type OverlayDocument, toOverlay } from "../split/diffSpecs.js";
Expand All @@ -27,7 +28,7 @@ export class MergeCommand {
const workspace = await context.loadWorkspaceOrThrow();

if (Object.keys(workspace.apis).length === 0) {
throw new CliError({ message: "No APIs found in workspace." });
throw new CliError({ message: "No APIs found in workspace.", code: CliError.Code.ConfigError });
}

const entries = filterSpecs(workspace, { api: args.api });
Expand All @@ -42,7 +43,8 @@ export class MergeCommand {
const fernYmlPath = workspace.absoluteFilePath;
if (fernYmlPath == null) {
throw new CliError({
message: `No ${FERN_YML_FILENAME} found. Run 'fern init' to initialize a project.`
message: `No ${FERN_YML_FILENAME} found. Run 'fern init' to initialize a project.`,
code: CliError.Code.ConfigError
});
}
editor = await FernYmlEditor.load({ fernYmlPath });
Expand Down
15 changes: 10 additions & 5 deletions packages/cli/cli-v2/src/commands/api/split/command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { extractErrorMessage, mergeWithOverrides } from "@fern-api/core-utils";
import type { AbsoluteFilePath } from "@fern-api/fs-utils";
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import { execFile } from "child_process";
import { readFile, writeFile } from "fs/promises";
Expand All @@ -10,7 +11,6 @@ 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 { CliError } from "../../../errors/CliError.js";
import { Icons } from "../../../ui/format.js";
import { command } from "../../_internal/command.js";
import type { SpecEntry } from "../utils/filterSpecs.js";
Expand Down Expand Up @@ -46,7 +46,7 @@ export class SplitCommand {
const workspace = await context.loadWorkspaceOrThrow();

if (Object.keys(workspace.apis).length === 0) {
throw new CliError({ message: "No APIs found in workspace." });
throw new CliError({ message: "No APIs found in workspace.", code: CliError.Code.ConfigError });
}

const entries = filterSpecs(workspace, { api: args.api });
Expand All @@ -60,7 +60,8 @@ export class SplitCommand {
const fernYmlPath = workspace.absoluteFilePath;
if (fernYmlPath == null) {
throw new CliError({
message: `No ${FERN_YML_FILENAME} found. Run 'fern init' to initialize a project.`
message: `No ${FERN_YML_FILENAME} found. Run 'fern init' to initialize a project.`,
code: CliError.Code.ConfigError
});
}
const editor = await FernYmlEditor.load({ fernYmlPath });
Expand Down Expand Up @@ -240,7 +241,8 @@ export class SplitCommand {
} catch (error: unknown) {
const detail = extractErrorMessage(error);
throw new CliError({
message: `Failed to get file from git HEAD: ${absolutePath}. Is the file tracked by git and has at least one commit?\n Cause: ${detail}`
message: `Failed to get file from git HEAD: ${absolutePath}. Is the file tracked by git and has at least one commit?\n Cause: ${detail}`,
code: CliError.Code.ParseError
});
}
}
Expand All @@ -257,7 +259,10 @@ export class SplitCommand {
function resolvePathOrThrow(context: Context, outputPath: string): AbsoluteFilePath {
const resolved = context.resolveOutputFilePath(outputPath);
if (resolved == null) {
throw new CliError({ message: `Could not resolve output path: ${outputPath}` });
throw new CliError({
message: `Could not resolve output path: ${outputPath}`,
code: CliError.Code.ConfigError
});
}
return resolved;
}
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/cli-v2/src/commands/api/utils/loadSpec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { AbsoluteFilePath } from "@fern-api/fs-utils";
import { CliError } from "@fern-api/task-context";

import { readFile } from "fs/promises";
import yaml from "js-yaml";
import { CliError } from "../../../errors/CliError.js";
import { isEnoentError } from "./isEnoentError.js";

// biome-ignore lint/suspicious/noExplicitAny: OpenAPI specs can have any shape
Expand All @@ -17,9 +18,9 @@ export async function loadSpec(filepath: AbsoluteFilePath): Promise<Spec> {
contents = await readFile(filepath, "utf8");
} catch (error) {
if (isEnoentError(error)) {
throw new CliError({ message: `File does not exist: ${filepath}` });
throw new CliError({ message: `File does not exist: ${filepath}`, code: CliError.Code.ConfigError });
}
throw new CliError({ message: `Failed to read file: ${filepath}` });
throw new CliError({ message: `Failed to read file: ${filepath}`, code: CliError.Code.ParseError });
}
return parseSpec(contents, filepath);
}
Expand All @@ -35,7 +36,10 @@ export function parseSpec(contents: string, filepath: string): Spec {
try {
return yaml.load(contents) as Spec;
} catch {
throw new CliError({ message: `Failed to parse file as JSON or YAML: ${filepath}` });
throw new CliError({
message: `Failed to parse file as JSON or YAML: ${filepath}`,
code: CliError.Code.ParseError
});
}
}
}
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/cli-v2/src/commands/auth/login/command.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { verifyAndDecodeJwt } from "@fern-api/auth";
import { LogLevel } from "@fern-api/logger";
import { type Auth0TokenResponse, getTokenFromAuth0 } from "@fern-api/login";
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import type { Argv } from "yargs";

import { TaskContextAdapter } from "../../../context/adapter/TaskContextAdapter.js";
import type { Context } from "../../../context/Context.js";
import type { GlobalArgs } from "../../../context/GlobalArgs.js";
import { CliError } from "../../../errors/CliError.js";
import { Icons } from "../../../ui/format.js";
import { command } from "../../_internal/command.js";

Expand Down Expand Up @@ -42,13 +41,13 @@ export class LoginCommand {
const payload = await verifyAndDecodeJwt(idToken);
if (payload == null) {
context.stdout.error(`${Icons.error} Internal error; could not verify ID token`);
throw CliError.exit();
throw CliError.internalError();
}

const email = payload.email;
if (email == null) {
context.stdout.error(`${Icons.error} Internal error; ID token does not contain email claim`);
throw CliError.exit();
throw CliError.internalError();
}

const { isNew, totalAccounts } = await context.tokenService.login(email, accessToken);
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/cli-v2/src/commands/auth/logout/command.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import inquirer from "inquirer";
import type { Argv } from "yargs";

import { TokenService } from "../../../auth/TokenService.js";
import type { Context } from "../../../context/Context.js";
import type { GlobalArgs } from "../../../context/GlobalArgs.js";
import { CliError } from "../../../errors/CliError.js";
import { Icons } from "../../../ui/format.js";
import { command } from "../../_internal/command.js";

Expand Down Expand Up @@ -65,7 +64,7 @@ export class LogoutCommand {

if (!context.isTTY) {
context.stdout.error(`${Icons.error} Use --force to skip confirmation in non-interactive mode`);
throw CliError.exit();
throw new CliError({ code: CliError.Code.ValidationError });
}

const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
Expand All @@ -91,7 +90,9 @@ export class LogoutCommand {

if (!removed) {
context.stdout.error(`${Icons.error} Account not found: ${user}`);
throw CliError.exit();
throw new CliError({
code: CliError.Code.EnvironmentError
});
}

context.stdout.info(`${Icons.success} Logged out of ${chalk.bold(user)}`);
Expand All @@ -106,7 +107,9 @@ export class LogoutCommand {
context.stdout.error(
`${Icons.error} Multiple accounts found. Use --user or --all in non-interactive mode.`
);
throw CliError.exit();
throw new CliError({
code: CliError.Code.EnvironmentError
});
}

const choices = accounts.map((account) => ({
Expand Down
Loading
Loading