diff --git a/packages/cli/cli-logger/src/logErrorMessage.ts b/packages/cli/cli-logger/src/logErrorMessage.ts index 87df79f8796f..03b7f64fab27 100644 --- a/packages/cli/cli-logger/src/logErrorMessage.ts +++ b/packages/cli/cli-logger/src/logErrorMessage.ts @@ -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, @@ -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; diff --git a/packages/cli/cli-v2/src/__test__/compile.test.ts b/packages/cli/cli-v2/src/__test__/compile.test.ts index 637cd3f324b2..eb8b13c01137 100644 --- a/packages/cli/cli-v2/src/__test__/compile.test.ts +++ b/packages/cli/cli-v2/src/__test__/compile.test.ts @@ -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")); diff --git a/packages/cli/cli-v2/src/auth/errors/KeyringUnavailableError.ts b/packages/cli/cli-v2/src/auth/errors/KeyringUnavailableError.ts index b6405805c086..2cd328baf3d3 100644 --- a/packages/cli/cli-v2/src/auth/errors/KeyringUnavailableError.ts +++ b/packages/cli/cli-v2/src/auth/errors/KeyringUnavailableError.ts @@ -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; } diff --git a/packages/cli/cli-v2/src/commands/api/check/command.ts b/packages/cli/cli-v2/src/commands/api/check/command.ts index a68ac12ae556..801ef080a650 100644 --- a/packages/cli/cli-v2/src/commands/api/check/command.ts +++ b/packages/cli/cli-v2/src/commands/api/check/command.ts @@ -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"; @@ -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 }); } @@ -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; } @@ -56,7 +58,7 @@ export class CheckCommand { } if (hasErrors) { - throw CliError.exit(); + throw CliError.validationError(); } if (result.warningCount > 0) { diff --git a/packages/cli/cli-v2/src/commands/api/compile/command.ts b/packages/cli/cli-v2/src/commands/api/compile/command.ts index 4e28645d3e16..a2a904d4ced4 100644 --- a/packages/cli/cli-v2/src/commands/api/compile/command.ts +++ b/packages/cli/cli-v2/src/commands/api/compile/command.ts @@ -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"; @@ -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"; @@ -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 }; @@ -77,13 +79,15 @@ 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 }; @@ -91,7 +95,8 @@ export class CompileCommand { 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 }); } @@ -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`); } } diff --git a/packages/cli/cli-v2/src/commands/api/merge/command.ts b/packages/cli/cli-v2/src/commands/api/merge/command.ts index 5b4abfb1ff0d..34fc426cbc82 100644 --- a/packages/cli/cli-v2/src/commands/api/merge/command.ts +++ b/packages/cli/cli-v2/src/commands/api/merge/command.ts @@ -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"; @@ -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"; @@ -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 }); @@ -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 }); diff --git a/packages/cli/cli-v2/src/commands/api/split/command.ts b/packages/cli/cli-v2/src/commands/api/split/command.ts index a6a9b9a9e8e8..013354e4f699 100644 --- a/packages/cli/cli-v2/src/commands/api/split/command.ts +++ b/packages/cli/cli-v2/src/commands/api/split/command.ts @@ -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"; @@ -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"; @@ -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 }); @@ -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 }); @@ -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 }); } } @@ -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; } diff --git a/packages/cli/cli-v2/src/commands/api/utils/loadSpec.ts b/packages/cli/cli-v2/src/commands/api/utils/loadSpec.ts index 52b6d17c48bb..e26e2854dd30 100644 --- a/packages/cli/cli-v2/src/commands/api/utils/loadSpec.ts +++ b/packages/cli/cli-v2/src/commands/api/utils/loadSpec.ts @@ -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 @@ -17,9 +18,9 @@ export async function loadSpec(filepath: AbsoluteFilePath): Promise { 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); } @@ -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 + }); } } } diff --git a/packages/cli/cli-v2/src/commands/auth/login/command.ts b/packages/cli/cli-v2/src/commands/auth/login/command.ts index 18696780acc9..e2ba589e0a68 100644 --- a/packages/cli/cli-v2/src/commands/auth/login/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/login/command.ts @@ -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"; @@ -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); diff --git a/packages/cli/cli-v2/src/commands/auth/logout/command.ts b/packages/cli/cli-v2/src/commands/auth/logout/command.ts index dec455fb6dc3..2dcc7a5998a7 100644 --- a/packages/cli/cli-v2/src/commands/auth/logout/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/logout/command.ts @@ -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"; @@ -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 }>([ @@ -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)}`); @@ -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) => ({ diff --git a/packages/cli/cli-v2/src/commands/auth/switch/command.ts b/packages/cli/cli-v2/src/commands/auth/switch/command.ts index 8368add4ed85..8efa13f140f6 100644 --- a/packages/cli/cli-v2/src/commands/auth/switch/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/switch/command.ts @@ -1,10 +1,9 @@ +import { CliError } from "@fern-api/task-context"; import chalk from "chalk"; import inquirer from "inquirer"; import type { Argv } from "yargs"; - 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"; @@ -21,14 +20,16 @@ export class SwitchCommand { 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 CliError.exit(); + throw new CliError({ + code: CliError.Code.ConfigError + }); } if (accounts.length === 1) { const account = accounts[0]; if (account == null) { context.stdout.error(`${Icons.error} Internal error; no accounts found`); - throw CliError.exit(); + throw CliError.internalError(); } context.stdout.warn( @@ -60,7 +61,9 @@ export class SwitchCommand { ): Promise { if (!context.isTTY) { context.stdout.error(`${Icons.error} Use --user to specify account in non-interactive mode`); - throw CliError.exit(); + throw new CliError({ + code: CliError.Code.EnvironmentError + }); } const choices = accounts.map((account) => ({ @@ -85,7 +88,9 @@ export class SwitchCommand { const success = await context.tokenService.switchAccount(user); if (!success) { context.stdout.error(`${Icons.error} Account not found: ${user}`); - throw CliError.exit(); + throw new CliError({ + code: CliError.Code.EnvironmentError + }); } context.stdout.info(`${Icons.success} Switched to ${chalk.bold(user)}`); } diff --git a/packages/cli/cli-v2/src/commands/auth/token/command.ts b/packages/cli/cli-v2/src/commands/auth/token/command.ts index 2f0ccd391091..36f619fefb1d 100644 --- a/packages/cli/cli-v2/src/commands/auth/token/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/token/command.ts @@ -1,11 +1,10 @@ import { createOrganizationIfDoesNotExist } from "@fern-api/auth"; import { createVenusService } from "@fern-api/core"; +import { CliError } from "@fern-api/task-context"; 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"; @@ -42,24 +41,32 @@ export class TokenCommand { response.error._visit({ organizationNotFoundError: () => { process.stderr.write(`${Icons.error} Organization "${orgId}" was not found.\n`); - throw CliError.exit(); + throw new CliError({ + code: CliError.Code.ConfigError + }); }, unauthorizedError: () => { process.stderr.write(`${Icons.error} You do not have access to organization "${orgId}".\n`); - throw CliError.exit(); + throw new CliError({ + code: CliError.Code.AuthError + }); }, missingOrgPermissionsError: () => { process.stderr.write( `${Icons.error} You do not have the required permissions in organization "${orgId}".\n` ); - throw CliError.exit(); + throw new CliError({ + code: CliError.Code.AuthError + }); }, _other: () => { process.stderr.write( `${Icons.error} Failed to generate token.\n` + `\n Please contact support@buildwithfern.com for assistance.\n` ); - throw CliError.exit(); + throw new CliError({ + code: CliError.Code.InternalError + }); } }); } @@ -76,7 +83,7 @@ export class TokenCommand { `${Icons.error} No organization specified.\n` + `\n Run fern init or specify an organization with --org, then run this command again.\n` ); - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ConfigError }); } } } 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 c19642f1db46..958a63edc5bc 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 { CliError } from "../../../errors/CliError.js"; import { command } from "../../_internal/command.js"; export declare namespace WhoamiCommand { @@ -18,7 +18,7 @@ export class WhoamiCommand { 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 CliError.exit(); + throw new CliError({ code: CliError.Code.AuthError }); } 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 2c29a8cac109..c973d7cfe915 100644 --- a/packages/cli/cli-v2/src/commands/check/command.ts +++ b/packages/cli/cli-v2/src/commands/check/command.ts @@ -1,10 +1,11 @@ +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 { DocsChecker } from "../../docs/checker/DocsChecker.js"; -import { CliError } from "../../errors/CliError.js"; import { SdkChecker } from "../../sdk/checker/SdkChecker.js"; import { Icons } from "../../ui/format.js"; import type { Workspace } from "../../workspace/Workspace.js"; @@ -45,14 +46,14 @@ export class CheckCommand { const response = this.buildJsonResponse({ apiCheckResult, sdkCheckResult, docsCheckResult, hasErrors }); context.stdout.info(JSON.stringify(response, null, 2)); if (hasErrors) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } return; } // Fail if there are errors, or if strict mode and there are warnings. if (hasErrors) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } if (totalWarnings > 0) { @@ -158,7 +159,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 }); } } diff --git a/packages/cli/cli-v2/src/commands/config/migrate/command.ts b/packages/cli/cli-v2/src/commands/config/migrate/command.ts index 5eef759bec0d..13bfb11406ae 100644 --- a/packages/cli/cli-v2/src/commands/config/migrate/command.ts +++ b/packages/cli/cli-v2/src/commands/config/migrate/command.ts @@ -1,7 +1,8 @@ +import { CliError } from "@fern-api/task-context"; + import type { Argv } from "yargs"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; -import { CliError } from "../../../errors/CliError.js"; import { Migrator } from "../../../migrator/index.js"; import { command } from "../../_internal/command.js"; @@ -50,7 +51,7 @@ export class MigrateCommand { return; } - throw new CliError({ message: "Migration failed" }); + throw new CliError({ message: "Migration failed", code: CliError.Code.ConfigError }); } } diff --git a/packages/cli/cli-v2/src/commands/docs/check/command.ts b/packages/cli/cli-v2/src/commands/docs/check/command.ts index b6fdaa39440d..5178e499086d 100644 --- a/packages/cli/cli-v2/src/commands/docs/check/command.ts +++ b/packages/cli/cli-v2/src/commands/docs/check/command.ts @@ -1,9 +1,10 @@ +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 { DocsChecker } from "../../../docs/checker/DocsChecker.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"; @@ -25,7 +26,8 @@ export class CheckCommand { throw new CliError({ message: "No docs configuration found in fern.yml.\n\n" + - " Add a 'docs:' section to your fern.yml to get started." + " Add a 'docs:' section to your fern.yml to get started.", + code: CliError.Code.ConfigError }); } @@ -38,7 +40,7 @@ export class CheckCommand { const response = this.buildJsonResponse({ result, hasErrors }); context.stdout.info(JSON.stringify(response, null, 2)); if (hasErrors) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } return; } @@ -51,7 +53,7 @@ export class CheckCommand { } if (hasErrors) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } if (result.warningCount > 0) { diff --git a/packages/cli/cli-v2/src/commands/docs/dev/command.ts b/packages/cli/cli-v2/src/commands/docs/dev/command.ts index 8720d9a47b8a..5e84de1862ba 100644 --- a/packages/cli/cli-v2/src/commands/docs/dev/command.ts +++ b/packages/cli/cli-v2/src/commands/docs/dev/command.ts @@ -1,10 +1,11 @@ import { LogLevel } from "@fern-api/logger"; +import { CliError } from "@fern-api/task-context"; + import getPort from "get-port"; import type { Argv } from "yargs"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; import { LegacyDevServer } from "../../../docs/server/LegacyDevServer.js"; -import { CliError } from "../../../errors/CliError.js"; import { command } from "../../_internal/command.js"; export declare namespace DevCommand { @@ -24,7 +25,8 @@ export class DevCommand { throw new CliError({ message: "No docs configuration found in fern.yml.\n\n" + - " Add a 'docs:' section to your fern.yml to get started." + " Add a 'docs:' section to your fern.yml to get started.", + code: CliError.Code.ConfigError }); } diff --git a/packages/cli/cli-v2/src/commands/docs/preview/command.ts b/packages/cli/cli-v2/src/commands/docs/preview/command.ts index f4d291deabae..48f9ea91d237 100644 --- a/packages/cli/cli-v2/src/commands/docs/preview/command.ts +++ b/packages/cli/cli-v2/src/commands/docs/preview/command.ts @@ -1,8 +1,9 @@ +import { CliError } from "@fern-api/task-context"; + import type { Argv } from "yargs"; import { GENERATE_COMMAND_TIMEOUT_MS } from "../../../constants.js"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; -import { CliError } from "../../../errors/CliError.js"; import { commandWithSubcommands } from "../../_internal/commandWithSubcommands.js"; import { PublishCommand } from "../publish/command.js"; import { addDeleteCommand } from "./delete/index.js"; @@ -38,7 +39,13 @@ export function addPreviewCommand(cli: Argv): void { async (context, args) => { const timeout = new Promise((_, reject) => { setTimeout( - () => reject(new CliError({ message: "Docs preview timed out after 10 minutes." })), + () => + reject( + new CliError({ + message: "Docs preview timed out after 10 minutes.", + code: CliError.Code.NetworkError + }) + ), GENERATE_COMMAND_TIMEOUT_MS ).unref(); }); diff --git a/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts b/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts index 58079f7ab3d3..b759ec874bee 100644 --- a/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts +++ b/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts @@ -1,9 +1,10 @@ import { createFdrService } from "@fern-api/core"; +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 { CliError } from "../../../../errors/CliError.js"; import { command } from "../../../_internal/command.js"; /** @@ -23,7 +24,8 @@ export class DeleteCommand { throw new CliError({ message: `Invalid preview URL: ${args.url}\n` + - ` Preview URLs follow the pattern: {org}-preview-{hash}.docs.buildwithfern.com` + ` Preview URLs follow the pattern: {org}-preview-{hash}.docs.buildwithfern.com`, + code: CliError.Code.ConfigError }); } @@ -45,7 +47,8 @@ export class DeleteCommand { throw CliError.notFound(`Preview site not found: ${args.url}`); default: throw new CliError({ - message: `Failed to delete preview site: ${args.url}` + message: `Failed to delete preview site: ${args.url}`, + code: CliError.Code.InternalError }); } } diff --git a/packages/cli/cli-v2/src/commands/docs/publish/command.ts b/packages/cli/cli-v2/src/commands/docs/publish/command.ts index 77a98d017a84..758cee499d24 100644 --- a/packages/cli/cli-v2/src/commands/docs/publish/command.ts +++ b/packages/cli/cli-v2/src/commands/docs/publish/command.ts @@ -1,6 +1,8 @@ import type { FernToken } from "@fern-api/auth"; import { extractErrorMessage } from "@fern-api/core-utils"; import { filterOssWorkspaces } from "@fern-api/docs-resolver"; +import { CliError } from "@fern-api/task-context"; + import chalk from "chalk"; import inquirer from "inquirer"; import type { Argv } from "yargs"; @@ -12,7 +14,6 @@ import { DocsChecker } from "../../../docs/checker/DocsChecker.js"; import { LegacyDocsPublisher } from "../../../docs/publisher/LegacyDocsPublisher.js"; import type { DocsStageOverrides } from "../../../docs/task/DocsTaskGroup.js"; import { DocsTaskGroup } from "../../../docs/task/DocsTaskGroup.js"; -import { CliError } from "../../../errors/CliError.js"; import { ValidationError } from "../../../errors/ValidationError.js"; import { command } from "../../_internal/command.js"; export declare namespace PublishCommand { @@ -58,7 +59,8 @@ export class PublishCommand { throw new CliError({ message: "No docs configuration found in fern.yml.\n\n" + - " Add a 'docs:' section to your fern.yml to get started." + " Add a 'docs:' section to your fern.yml to get started.", + code: CliError.Code.ConfigError }); } @@ -85,7 +87,8 @@ export class PublishCommand { throw new CliError({ message: "No docs configuration found in fern.yml.\n\n" + - " Add a 'docs:' section to your fern.yml to get started." + " Add a 'docs:' section to your fern.yml to get started.", + code: CliError.Code.ConfigError }); } @@ -102,7 +105,10 @@ export class PublishCommand { }); const docsTask = taskGroup.getTask("publish"); if (docsTask == null) { - throw new CliError({ message: "Internal error; task 'publish' not found" }); + throw new CliError({ + message: "Internal error; task 'publish' not found", + code: CliError.Code.InternalError + }); } docsTask.start(); @@ -155,7 +161,7 @@ export class PublishCommand { }); if (summary.failedCount > 0) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ContainerError }); } } @@ -168,7 +174,8 @@ export class PublishCommand { }): string { if (instances.length === 0) { throw new CliError({ - message: "No docs instances configured.\n\n Add an instance to the 'docs:' section of your fern.yml." + message: "No docs instances configured.\n\n Add an instance to the 'docs:' section of your fern.yml.", + code: CliError.Code.ConfigError }); } @@ -180,7 +187,8 @@ export class PublishCommand { message: `No docs instance found with URL '${instance}'.\n\n` + `Available instances:\n${available}\n\n` + - ` Use --instance with one of the URLs above.` + ` Use --instance with one of the URLs above.`, + code: CliError.Code.ConfigError }); } return match.url; @@ -192,14 +200,16 @@ export class PublishCommand { message: `Multiple docs instances configured. Please specify which instance to publish.\n\n` + `Available instances:\n${available}\n\n` + - ` Use --instance to select one.` + ` Use --instance to select one.`, + code: CliError.Code.ConfigError }); } const first = instances[0]; if (first == null) { throw new CliError({ - message: "No docs instances configured.\n\n Add an instance to the 'docs:' section of your fern.yml." + message: "No docs instances configured.\n\n Add an instance to the 'docs:' section of your fern.yml.", + code: CliError.Code.ConfigError }); } return first.url; @@ -248,7 +258,8 @@ export class PublishCommand { const fernToken = process.env["FERN_TOKEN"]; if (fernToken == null) { throw new CliError({ - message: "No organization token found. Please set the FERN_TOKEN environment variable." + message: "No organization token found. Please set the FERN_TOKEN environment variable.", + code: CliError.Code.AuthError }); } return Promise.resolve({ type: "organization", value: fernToken }); @@ -266,7 +277,13 @@ export function addPublishCommand(cli: Argv): void { async (context, args) => { const timeout = new Promise((_, reject) => { setTimeout( - () => reject(new CliError({ message: "Docs publish timed out after 10 minutes." })), + () => + reject( + new CliError({ + message: "Docs publish timed out after 10 minutes.", + code: CliError.Code.NetworkError + }) + ), GENERATE_COMMAND_TIMEOUT_MS ).unref(); }); diff --git a/packages/cli/cli-v2/src/commands/init/command.ts b/packages/cli/cli-v2/src/commands/init/command.ts index e23c6f2cd731..935e222a69a3 100644 --- a/packages/cli/cli-v2/src/commands/init/command.ts +++ b/packages/cli/cli-v2/src/commands/init/command.ts @@ -1,5 +1,7 @@ import { assertNever } from "@fern-api/core-utils"; import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; + import chalk from "chalk"; import { mkdir, writeFile } from "fs/promises"; import path from "path"; @@ -8,7 +10,6 @@ import { FERN_YML_FILENAME } from "../../config/fern-yml/constants"; import { FernYmlBuilder } from "../../config/fern-yml/FernYmlBuilder"; import type { Context } from "../../context/Context"; import type { GlobalArgs } from "../../context/GlobalArgs"; -import { CliError } from "../../errors/CliError"; import { PETSTORE_OPENAPI_YML } from "../../init/templates/openapi.yml"; import { Wizard } from "../../init/Wizard"; import { Icons } from "../../ui/format"; @@ -142,7 +143,8 @@ export class InitCommand { }): Promise { if (await doesPathExist(fernYmlPath)) { throw new CliError({ - message: `A ${FERN_YML_FILENAME} file already exists at ${fernYmlPath}` + message: `A ${FERN_YML_FILENAME} file already exists at ${fernYmlPath}`, + code: CliError.Code.ConfigError }); } if (args.api != null) { @@ -150,13 +152,14 @@ export class InitCommand { if (!api.startsWith("http://") && !api.startsWith("https://")) { const resolved = path.resolve(context.cwd, api); if (!(await doesPathExist(AbsoluteFilePath.of(resolved)))) { - throw new CliError({ message: `File not found: ${api}` }); + throw new CliError({ message: `File not found: ${api}`, code: CliError.Code.ConfigError }); } } } if (!context.isTTY && !args.yes) { throw new CliError({ - message: "Cannot run interactive init in non-TTY environment. Use --yes for defaults." + message: "Cannot run interactive init in non-TTY environment. Use --yes for defaults.", + code: CliError.Code.ConfigError }); } } diff --git a/packages/cli/cli-v2/src/commands/org/create/command.ts b/packages/cli/cli-v2/src/commands/org/create/command.ts index 8f851dde0d48..09c714385abf 100644 --- a/packages/cli/cli-v2/src/commands/org/create/command.ts +++ b/packages/cli/cli-v2/src/commands/org/create/command.ts @@ -1,10 +1,10 @@ import { createOrganizationIfDoesNotExist, getOrganizationNameValidationError } from "@fern-api/auth"; -import type { Argv } from "yargs"; +import { CliError } from "@fern-api/task-context"; +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 { withSpinner } from "../../../ui/withSpinner.js"; import { command } from "../../_internal/command.js"; @@ -23,7 +23,7 @@ export class CreateCommand { context.stderr.error( `${Icons.error} Organization tokens cannot create organizations. Unset the FERN_TOKEN environment variable and run 'fern auth login' to create an organization.` ); - throw CliError.exit(); + throw new CliError({ code: CliError.Code.AuthError }); } const validationError = getOrganizationNameValidationError(args.name); diff --git a/packages/cli/cli-v2/src/commands/org/list/command.ts b/packages/cli/cli-v2/src/commands/org/list/command.ts index a14e96c4af20..64eb4d95347a 100644 --- a/packages/cli/cli-v2/src/commands/org/list/command.ts +++ b/packages/cli/cli-v2/src/commands/org/list/command.ts @@ -1,11 +1,11 @@ import { createVenusService } from "@fern-api/core"; +import { CliError } from "@fern-api/task-context"; + import type { FernVenusApiClient } from "@fern-api/venus-api-sdk"; import { spawn } from "child_process"; import type { Argv } from "yargs"; - 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"; @@ -21,7 +21,7 @@ export class ListCommand { context.stderr.error( `${Icons.error} Organization tokens cannot list organizations. Unset the FERN_TOKEN environment variable and run 'fern auth login' to list your organizations.` ); - throw CliError.exit(); + throw new CliError({ code: CliError.Code.AuthError }); } const venus = createVenusService({ token: token.value }); 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 1644f73413d9..e791487aeb74 100644 --- a/packages/cli/cli-v2/src/commands/sdk/add/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/add/command.ts @@ -1,6 +1,8 @@ import { schemas } from "@fern-api/config"; import { getLatestGeneratorVersion } from "@fern-api/configuration-loader"; import type { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; + import chalk from "chalk"; import inquirer from "inquirer"; import type { Argv } from "yargs"; @@ -8,7 +10,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 { 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,14 +46,15 @@ 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.` + message: `No ${FERN_YML_FILENAME} found. Run 'fern init' to initialize a project.`, + code: CliError.Code.ConfigError }); } const sdkChecker = new SdkChecker({ context }); const sdkCheckResult = await sdkChecker.check({ workspace }); if (sdkCheckResult.errorCount > 0) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } const existingTargets = workspace.sdks?.targets ?? []; @@ -77,7 +79,8 @@ export class AddCommand { }): Promise { if (args.target == null) { throw new CliError({ - message: `Missing required flags:\n\n --target SDK language (e.g. typescript, python, go)` + message: `Missing required flags:\n\n --target SDK language (e.g. typescript, python, go)`, + code: CliError.Code.ConfigError }); } @@ -145,7 +148,8 @@ export class AddCommand { message: `"${value}" looks like a remote reference but is not a recognized git URL.\n\n` + ` Please specify a local path (e.g. ./sdks/my-sdk) or a git URL:\n` + - ` https://github.com/owner/repo` + ` https://github.com/owner/repo`, + code: CliError.Code.ConfigError }); } @@ -178,7 +182,8 @@ export class AddCommand { private checkForDuplicate({ existingTargets, language }: { existingTargets: Target[]; language: Language }): void { if (existingTargets.some((t) => t.name === language)) { throw new CliError({ - message: `Target '${language}' already exists in ${FERN_YML_FILENAME}.` + message: `Target '${language}' already exists in ${FERN_YML_FILENAME}.`, + code: CliError.Code.ConfigError }); } } @@ -247,7 +252,8 @@ export class AddCommand { return lang; } throw new CliError({ - message: `"${target}" is not a supported language. Supported: ${LANGUAGES.join(", ")}` + message: `"${target}" is not a supported language. Supported: ${LANGUAGES.join(", ")}`, + code: CliError.Code.ConfigError }); } } diff --git a/packages/cli/cli-v2/src/commands/sdk/check/command.ts b/packages/cli/cli-v2/src/commands/sdk/check/command.ts index 4922e21d15b3..41bdd88e534f 100644 --- a/packages/cli/cli-v2/src/commands/sdk/check/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/check/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 { CliError } from "../../../errors/CliError.js"; import { SdkChecker } from "../../../sdk/checker/SdkChecker.js"; import { Icons } from "../../../ui/format.js"; import { command } from "../../_internal/command.js"; @@ -30,7 +30,7 @@ export class CheckCommand { const response = this.buildJsonResponse({ sdkCheckResult: result, hasErrors }); context.stdout.info(JSON.stringify(response, null, 2)); if (hasErrors) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } return; } @@ -43,7 +43,7 @@ export class CheckCommand { } if (hasErrors) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } if (result.warningCount > 0) { 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 b49a3baad15f..f01cd374e9b1 100644 --- a/packages/cli/cli-v2/src/commands/sdk/generate/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/generate/command.ts @@ -3,6 +3,7 @@ import type { Audiences } from "@fern-api/configuration"; import type { ContainerRunner } from "@fern-api/core-utils"; import { assertNever } from "@fern-api/core-utils"; import { AbsoluteFilePath, doesPathExist, resolve } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; import { ValidationIssue } from "@fern-api/yaml-loader"; import chalk from "chalk"; import { readdir } from "fs/promises"; @@ -15,7 +16,6 @@ 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 { CliError } from "../../../errors/CliError.js"; import { SourcedValidationError } from "../../../errors/SourcedValidationError.js"; import { SdkChecker } from "../../../sdk/checker/SdkChecker.js"; import { LANGUAGES, type Language } from "../../../sdk/config/Language.js"; @@ -150,7 +150,8 @@ export class GenerateCommand { throw new CliError({ message: `No fern.yml found, either run 'fern init' or specify all of the required flags:\n\n` + - missingFlags.map((flag) => ` ${flag}`).join("\n") + missingFlags.map((flag) => ` ${flag}`).join("\n"), + code: CliError.Code.ConfigError }); } @@ -225,13 +226,13 @@ export class GenerateCommand { } } if (sdkCheckResult.errorCount > 0) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } } const validTargets = targets.filter((t) => checkResult.validApis.has(t.api)); if (validTargets.length === 0) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } const pipeline = new GeneratorPipeline({ @@ -277,7 +278,7 @@ export class GenerateCommand { if (outputPath != null) { const { shouldProceed } = await this.checkOutputDirectory({ context, args, outputPath }); if (!shouldProceed) { - throw new CliError({ message: "Generation cancelled." }); + throw new CliError({ message: "Generation cancelled.", code: CliError.Code.ConfigError }); } } } @@ -301,7 +302,10 @@ export class GenerateCommand { const task = taskGroup.getTask(target.name); if (task == null) { // This should be unreachable. - throw new CliError({ message: `Internal error; task '${target.name}' not found` }); + throw new CliError({ + message: `Internal error; task '${target.name}' not found`, + code: CliError.Code.InternalError + }); } task.start(); @@ -353,7 +357,7 @@ export class GenerateCommand { }); if (summary.failedCount > 0) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ContainerError }); } } @@ -367,17 +371,27 @@ 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" }); + throw new CliError({ + message: "The --container-engine flag can only be used with --local", + code: CliError.Code.ConfigError + }); } if (args.group != null && args.target != null) { - throw new CliError({ message: "The --group and --target flags cannot be used together" }); + throw new CliError({ + message: "The --group and --target flags cannot be used together", + code: CliError.Code.ConfigError + }); } if (targets.length > 1 && args.output != null) { - throw new CliError({ message: "The --output flag can only be used when generating a single target" }); + throw new CliError({ + message: "The --output flag can only be used when generating a single target", + code: CliError.Code.ConfigError + }); } if (args["skip-fernignore"] && args.fernignore != null) { throw new CliError({ - message: "The --skip-fernignore and --fernignore flags cannot be used together." + message: "The --skip-fernignore and --fernignore flags cannot be used together.", + code: CliError.Code.ConfigError }); } const issues: ValidationIssue[] = []; @@ -440,7 +454,8 @@ export class GenerateCommand { throw new CliError({ message: `Remote generation is not supported with a git URL for --output\n\n` + - ` Use --local or specify a local filesystem path for --output` + ` Use --local or specify a local filesystem path for --output`, + code: CliError.Code.ConfigError }); } const token = process.env.GITHUB_TOKEN ?? process.env.GIT_TOKEN; @@ -451,7 +466,8 @@ export class GenerateCommand { ` Set GITHUB_TOKEN or GIT_TOKEN:\n` + ` export GITHUB_TOKEN=ghp_xxx\n\n` + ` Or use a local path:\n` + - ` --output ./my-sdk` + ` --output ./my-sdk`, + code: CliError.Code.AuthError }); } return { @@ -609,7 +625,13 @@ export function addGenerateCommand(cli: Argv): void { async (context, args) => { const timeout = new Promise((_, reject) => { setTimeout( - () => reject(new CliError({ message: "Generation timed out after 10 minutes." })), + () => + reject( + new CliError({ + message: "Generation timed out after 10 minutes.", + code: CliError.Code.NetworkError + }) + ), GENERATE_COMMAND_TIMEOUT_MS ).unref(); }); diff --git a/packages/cli/cli-v2/src/commands/sdk/preview/command.ts b/packages/cli/cli-v2/src/commands/sdk/preview/command.ts index 9636090d4cd9..17ba77c867d5 100644 --- a/packages/cli/cli-v2/src/commands/sdk/preview/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/preview/command.ts @@ -1,8 +1,9 @@ +import { CliError } from "@fern-api/task-context"; + import type { Argv } from "yargs"; import { GENERATE_COMMAND_TIMEOUT_MS } from "../../../constants.js"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; -import { CliError } from "../../../errors/CliError.js"; import { command } from "../../_internal/command.js"; import { GenerateCommand } from "../generate/command.js"; @@ -30,7 +31,13 @@ export function addPreviewCommand(cli: Argv): void { async (context, args) => { const timeout = new Promise((_, reject) => { setTimeout( - () => reject(new CliError({ message: "Preview generation timed out after 10 minutes." })), + () => + reject( + new CliError({ + message: "Preview generation timed out after 10 minutes.", + code: CliError.Code.NetworkError + }) + ), GENERATE_COMMAND_TIMEOUT_MS ).unref(); }); diff --git a/packages/cli/cli-v2/src/commands/sdk/update/command.ts b/packages/cli/cli-v2/src/commands/sdk/update/command.ts index 6082e6d6c726..d8fd0ef5cb78 100644 --- a/packages/cli/cli-v2/src/commands/sdk/update/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/update/command.ts @@ -1,4 +1,6 @@ import { extractErrorMessage } from "@fern-api/core-utils"; +import { CliError } from "@fern-api/task-context"; + import chalk from "chalk"; import inquirer from "inquirer"; import type { Argv } from "yargs"; @@ -6,7 +8,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 { SdkChecker } from "../../../sdk/checker/SdkChecker.js"; import { GeneratorMigrator } from "../../../sdk/updater/GeneratorMigrator.js"; import { SdkUpdater } from "../../../sdk/updater/SdkUpdater.js"; @@ -35,14 +36,15 @@ export class UpdateCommand { 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 sdkChecker = new SdkChecker({ context }); const sdkCheckResult = await sdkChecker.check({ workspace }); if (sdkCheckResult.errorCount > 0) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.ValidationError }); } const targets = workspace.sdks?.targets; @@ -62,7 +64,8 @@ export class UpdateCommand { // If no target matched the filter, it's an error. if (updates.length === 0 && upToDate.length === 0 && args.target != null) { throw new CliError({ - message: `Target '${args.target}' not found in ${FERN_YML_FILENAME}.` + message: `Target '${args.target}' not found in ${FERN_YML_FILENAME}.`, + code: CliError.Code.ConfigError }); } diff --git a/packages/cli/cli-v2/src/config/fern-yml/FernYmlEditor.ts b/packages/cli/cli-v2/src/config/fern-yml/FernYmlEditor.ts index b71a8a633162..43f566697e6a 100644 --- a/packages/cli/cli-v2/src/config/fern-yml/FernYmlEditor.ts +++ b/packages/cli/cli-v2/src/config/fern-yml/FernYmlEditor.ts @@ -1,9 +1,10 @@ import type { schemas } from "@fern-api/config"; import { AbsoluteFilePath, dirname, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; + import { readFile, writeFile } from "fs/promises"; import path from "path"; import { type Document, parseDocument } from "yaml"; -import { CliError } from "../../errors/CliError.js"; import { FERN_YML_FILENAME, REF_KEY } from "./constants.js"; export interface OverrideEdit { @@ -79,7 +80,8 @@ export class FernYmlEditor { if (doc == null || typeof doc !== "object") { throw new CliError({ - message: `Invalid ${FERN_YML_FILENAME}: expected a YAML object; run 'fern init' to initialize a new file.` + message: `Invalid ${FERN_YML_FILENAME}: expected a YAML object; run 'fern init' to initialize a new file.`, + code: CliError.Code.ParseError }); } @@ -317,7 +319,8 @@ export class FernYmlEditor { const resolvedPath = join(dirname(this.rootFilePath), RelativeFilePath.of(refPath)); if (!(await doesPathExist(resolvedPath))) { throw new CliError({ - message: `Referenced file '${refPath}' in ${FERN_YML_FILENAME} does not exist.` + message: `Referenced file '${refPath}' in ${FERN_YML_FILENAME} does not exist.`, + code: CliError.Code.ConfigError }); } const refContent = await readFile(resolvedPath, "utf-8"); @@ -346,7 +349,8 @@ export class FernYmlEditor { const existing = section.document.getIn([...section.basePath, name]); if (existing == null) { throw new CliError({ - message: `Target '${name}' not found in SDK configuration.` + message: `Target '${name}' not found in SDK configuration.`, + code: CliError.Code.ConfigError }); } } diff --git a/packages/cli/cli-v2/src/context/Context.ts b/packages/cli/cli-v2/src/context/Context.ts index a0d9b0fe87fb..3a2a694a91a6 100644 --- a/packages/cli/cli-v2/src/context/Context.ts +++ b/packages/cli/cli-v2/src/context/Context.ts @@ -10,12 +10,13 @@ import { schemas } from "@fern-api/config"; import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; import { createLogger, LOG_LEVELS, Logger, LogLevel } from "@fern-api/logger"; import { getTokenFromAuth0 } from "@fern-api/login"; +import { CliError } from "@fern-api/task-context"; + import chalk from "chalk"; import inquirer from "inquirer"; import { CredentialStore, TokenService } from "../auth/index.js"; import { Cache } from "../cache/index.js"; import { FernYmlSchemaLoader } from "../config/fern-yml/FernYmlSchemaLoader.js"; -import { CliError } from "../errors/CliError.js"; import { Target } from "../sdk/config/Target.js"; import { TelemetryClient } from "../telemetry/index.js"; import { Icons } from "../ui/format.js"; @@ -30,6 +31,7 @@ export class Context { private isShuttingDown = false; private logFilePathPrinted = false; + public readonly createdAt: number = Date.now(); public readonly cwd: AbsoluteFilePath; public readonly logLevel: LogLevel; public readonly info: CommandInfo; @@ -144,7 +146,7 @@ export class Context { this.stderr.info( chalk.dim(" To authenticate, run: 'fern auth login' or set the FERN_TOKEN environment variable") ); - throw CliError.exit(); + throw new CliError({ code: CliError.Code.AuthError }); } return await this.promptAndLogin(); } @@ -157,7 +159,7 @@ export class Context { this.stderr.info( chalk.dim(" To authenticate, run: 'fern auth login' or set the FERN_TOKEN environment variable") ); - throw CliError.exit(); + throw new CliError({ code: CliError.Code.AuthError }); } return await this.promptAndLogin(); } @@ -176,7 +178,7 @@ export class Context { ]); if (!confirm) { - throw CliError.exit(); + throw new CliError({ code: CliError.Code.AuthError }); } this.stderr.info(`${Icons.info} Opening browser to log in to Fern...`); @@ -191,13 +193,13 @@ export class Context { const payload = await verifyAndDecodeJwt(idToken); if (payload == null) { this.stderr.error(`${Icons.error} Internal error; could not verify ID token`); - throw CliError.exit(); + throw new CliError({ code: CliError.Code.InternalError }); } const email = payload.email; if (email == null) { this.stderr.error(`${Icons.error} Internal error; ID token does not contain email claim`); - throw CliError.exit(); + throw new CliError({ code: CliError.Code.InternalError }); } await this.tokenService.login(email, accessToken); diff --git a/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts b/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts index 087f95acc9ba..867cab0d5883 100644 --- a/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts +++ b/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts @@ -1,15 +1,20 @@ import { createLogger, LOG_LEVELS, Logger, LogLevel } from "@fern-api/logger"; -import type { - CreateInteractiveTaskParams, - Finishable, - InteractiveTaskContext, - PosthogEvent, - Startable, - TaskContext +import { + type CliError, + type CreateInteractiveTaskParams, + type Finishable, + type InteractiveTaskContext, + type PosthogEvent, + resolveErrorCode, + type Startable, + TaskAbortSignal, + type TaskContext, + TaskResult } from "@fern-api/task-context"; -import { TaskAbortSignal, TaskResult } from "@fern-api/task-context"; + import type { Task } from "../../ui/Task.js"; import type { Context } from "../Context.js"; +import { reportError } from "../withContext.js"; import { TaskContextLogger } from "./TaskContextLogger.js"; /** @@ -28,10 +33,12 @@ import { TaskContextLogger } from "./TaskContextLogger.js"; */ export class TaskContextAdapter implements TaskContext { private result: TaskResult = TaskResult.Success; + private readonly context: Context; public readonly logger: Logger; constructor({ context, task, logLevel = LogLevel.Warn }: { context: Context; task?: Task; logLevel?: LogLevel }) { + this.context = context; if (task != null) { this.logger = new TaskContextLogger({ context, task, logLevel }); } else { @@ -48,17 +55,27 @@ export class TaskContextAdapter implements TaskContext { await run(); } - public failAndThrow(message?: string, error?: unknown): never { - this.failWithoutThrowing(message, error); + public failAndThrow(message?: string, error?: unknown, options?: { code?: CliError.Code }): never { + this.failWithoutThrowing(message, error, options); throw new TaskAbortSignal(); } - public failWithoutThrowing(message?: string, error?: unknown): void { + public failWithoutThrowing(message?: string, error?: unknown, options?: { code?: CliError.Code }): void { + this.result = TaskResult.Failure; + if (error instanceof TaskAbortSignal) { + return; + } const fullMessage = this.getFullErrorMessage(message, error); if (fullMessage != null) { this.logger.error(fullMessage); } - this.result = TaskResult.Failure; + + reportError(this.context, error, { ...options, message }); + } + + public captureException(error: unknown, code?: CliError.Code): void { + const errorCode = resolveErrorCode(error, code); + this.context.telemetry.captureException(error, { errorCode }); } private getFullErrorMessage(message?: string, error?: unknown): string | undefined { @@ -82,6 +99,7 @@ export class TaskContextAdapter implements TaskContext { takeOverTerminal: this.takeOverTerminal.bind(this), failAndThrow: this.failAndThrow.bind(this), failWithoutThrowing: this.failWithoutThrowing.bind(this), + captureException: this.captureException.bind(this), getResult: () => this.result, addInteractiveTask: this.addInteractiveTask.bind(this), runInteractiveTask: this.runInteractiveTask.bind(this), diff --git a/packages/cli/cli-v2/src/context/withContext.ts b/packages/cli/cli-v2/src/context/withContext.ts index 272f468dc5a7..951d074454cb 100644 --- a/packages/cli/cli-v2/src/context/withContext.ts +++ b/packages/cli/cli-v2/src/context/withContext.ts @@ -1,8 +1,8 @@ import { LogLevel } from "@fern-api/logger"; -import { TaskAbortSignal } from "@fern-api/task-context"; +import { CliError, resolveErrorCode, shouldReportToSentry, TaskAbortSignal } from "@fern-api/task-context"; + import chalk from "chalk"; import { KeyringUnavailableError } from "../auth/errors/KeyringUnavailableError.js"; -import { CliError } from "../errors/CliError.js"; import { SourcedValidationError } from "../errors/SourcedValidationError.js"; import { ValidationError } from "../errors/ValidationError.js"; import { Icons } from "../ui/format.js"; @@ -26,7 +26,6 @@ export function withContext( ): (args: T) => Promise { return async (args: T) => { const context = await createContext(args); - const startTime = Date.now(); setupSignalHandler(context); try { @@ -34,21 +33,13 @@ export function withContext( context.telemetry.sendLifecycleEvent({ command: context.info.command, status: "success", - durationMs: Date.now() - startTime + durationMs: Date.now() - context.createdAt }); await context.telemetry.flush(); context.finish(); await exitGracefully(0); } catch (error) { - if (shouldReportToSentry(error)) { - context.telemetry.captureException(error); - } - context.telemetry.sendLifecycleEvent({ - command: context.info.command, - status: "error", - durationMs: Date.now() - startTime, - errorCode: extractErrorCode(error) - }); + reportError(context, error); await context.telemetry.flush(); handleError(context, error); context.finish(); @@ -95,7 +86,7 @@ function handleError(context: Context, error: unknown): void { } if (error instanceof CliError) { - if (error.message.length > 0) { + if (error.message && error.message.length > 0) { process.stderr.write(`${chalk.red(error.message)}\n`); } return; @@ -113,39 +104,31 @@ function handleError(context: Context, error: unknown): void { } /** - * Determines whether an error should be reported to Sentry. - * - * Only unexpected/internal errors are reported. User-facing errors - * (validation, auth, CLI usage) are not bugs and should not be tracked. + * Reports an error to Sentry (conditionally) and PostHog. + * Called from the top-level catch in withContext and from + * TaskContextAdapter.failWithoutThrowing. */ -function shouldReportToSentry(error: unknown): boolean { +export function reportError( + context: Context, + error: unknown, + options?: { message?: string; code?: CliError.Code } +): void { if (error instanceof TaskAbortSignal) { - return false; - } - if (error instanceof CliError) { - return error.code === "INTERNAL_ERROR"; - } - if ( - error instanceof ValidationError || - error instanceof SourcedValidationError || - error instanceof KeyringUnavailableError - ) { - return false; - } - return true; -} - -function extractErrorCode(error: unknown): CliError.Code { - if (error instanceof CliError && error.code != null) { - return error.code; - } - if (error instanceof ValidationError || error instanceof SourcedValidationError) { - return "VALIDATION_ERROR"; + return; } - if (error instanceof KeyringUnavailableError) { - return "UNAUTHORIZED_ERROR"; + const code = resolveErrorCode(error, options?.code); + const capturable = error ?? new CliError({ message: options?.message ?? "", code }); + if (shouldReportToSentry(code)) { + context.telemetry.captureException(capturable, { + errorCode: code + }); } - return "INTERNAL_ERROR"; + context.telemetry.sendLifecycleEvent({ + status: "error", + command: context.info.command, + durationMs: Date.now() - context.createdAt, + errorCode: code + }); } function setupSignalHandler(context: Context): void { diff --git a/packages/cli/cli-v2/src/docs/checker/DocsChecker.ts b/packages/cli/cli-v2/src/docs/checker/DocsChecker.ts index 5485d2228eb1..ceca125b341d 100644 --- a/packages/cli/cli-v2/src/docs/checker/DocsChecker.ts +++ b/packages/cli/cli-v2/src/docs/checker/DocsChecker.ts @@ -2,8 +2,9 @@ import { assertNever } from "@fern-api/core-utils"; import { filterOssWorkspaces } from "@fern-api/docs-resolver"; import type { ValidationViolation } from "@fern-api/docs-validator"; import { Rules } from "@fern-api/docs-validator"; +import { CliError } from "@fern-api/task-context"; + import type { Context } from "../../context/Context.js"; -import { CliError } from "../../errors/CliError.js"; import type { Task } from "../../ui/Task.js"; import type { Workspace } from "../../workspace/Workspace.js"; import { LegacyProjectAdapter } from "../adapter/LegacyProjectAdapter.js"; @@ -89,7 +90,8 @@ export class DocsChecker { throw new CliError({ message: "No docs configuration found in fern.yml.\n\n" + - " Add a 'docs:' section to your fern.yml to get started." + " Add a 'docs:' section to your fern.yml to get started.", + code: CliError.Code.ConfigError }); } diff --git a/packages/cli/cli-v2/src/errors/CliError.ts b/packages/cli/cli-v2/src/errors/CliError.ts deleted file mode 100644 index 196eb6279532..000000000000 --- a/packages/cli/cli-v2/src/errors/CliError.ts +++ /dev/null @@ -1,92 +0,0 @@ -export declare namespace CliError { - /** - * These codes allow for programmatic error handling and can be used to - * provide consistent error messages and exit codes. - */ - export type Code = - | "AUTH_REQUIRED" - | "EXIT" - | "GENERATION_FAILED" - | "BAD_REQUEST_ERROR" - | "NOT_FOUND_ERROR" - | "UNAUTHORIZED_ERROR" - | "VALIDATION_ERROR" - | "INTERNAL_ERROR"; -} - -/** - * A CLI error with an optional error code for programmatic handling. - * - * When a CliError has a code, the error handler can provide consistent - * formatting and potentially different exit codes for different error types. - * Errors without a code are displayed as-is. - */ -export class CliError extends Error { - public readonly code?: CliError.Code; - public readonly docsLink?: string; - - constructor({ - message, - code, - docsLink - }: { - message: string; - code?: CliError.Code; - docsLink?: string; - }) { - super(message); - this.code = code; - this.docsLink = docsLink; - } - - 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: "AUTH_REQUIRED" - }); - } - - public static generationFailed(message?: string): CliError { - return new CliError({ - message: message ?? "Generation failed. Please check the logs for more information.", - code: "GENERATION_FAILED" - }); - } - - public static badRequest(message: string): CliError { - return new CliError({ message, code: "BAD_REQUEST_ERROR" }); - } - - public static notFound(message: string): CliError { - return new CliError({ message, code: "NOT_FOUND_ERROR" }); - } - - 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: "UNAUTHORIZED_ERROR" - }); - } - - public static validationError(message: string): CliError { - return new CliError({ message, code: "VALIDATION_ERROR" }); - } - - public static internalError(message: string): CliError { - return new CliError({ message, code: "INTERNAL_ERROR" }); - } - - /** - * A sentinel error that causes the CLI to exit with a non-zero exit code, but no message. This - * is useful when a command handles the failure message itself. - */ - public static exit(): CliError { - return new CliError({ - message: "", - code: "EXIT" - }); - } -} diff --git a/packages/cli/cli-v2/src/errors/SourcedValidationError.ts b/packages/cli/cli-v2/src/errors/SourcedValidationError.ts index e013a3e4dd01..c67fc068bd87 100644 --- a/packages/cli/cli-v2/src/errors/SourcedValidationError.ts +++ b/packages/cli/cli-v2/src/errors/SourcedValidationError.ts @@ -1,3 +1,4 @@ +import { CliError } from "@fern-api/task-context"; import { ValidationIssue } from "@fern-api/yaml-loader"; /** @@ -6,11 +7,15 @@ import { ValidationIssue } from "@fern-api/yaml-loader"; * Used for fern.yml schema validation where each issue has a precise SourceLocation. * When displayed, each issue is shown on its own line with file:line:col prefix. */ -export class SourcedValidationError extends Error { +export class SourcedValidationError extends CliError { public readonly issues: ValidationIssue[]; constructor(issues: ValidationIssue[]) { - super(issues.map((issue) => issue.toString()).join("\n")); + super({ + message: issues.map((issue) => issue.toString()).join("\n"), + code: CliError.Code.ValidationError + }); + Object.setPrototypeOf(this, SourcedValidationError.prototype); this.issues = issues; } } diff --git a/packages/cli/cli-v2/src/errors/ValidationError.ts b/packages/cli/cli-v2/src/errors/ValidationError.ts index 3b918df2df6f..cf5e858fd191 100644 --- a/packages/cli/cli-v2/src/errors/ValidationError.ts +++ b/packages/cli/cli-v2/src/errors/ValidationError.ts @@ -1,3 +1,5 @@ +import { CliError } from "@fern-api/task-context"; + import type { ValidationViolation } from "./ValidationViolation.js"; /** @@ -6,11 +8,15 @@ import type { ValidationViolation } from "./ValidationViolation.js"; * When displayed, each violation is shown on its own line with filepath prefix * and severity-appropriate coloring. */ -export class ValidationError extends Error { +export class ValidationError extends CliError { public readonly violations: ValidationViolation[]; constructor(violations: ValidationViolation[]) { - super(violations.map((v) => `${v.relativeFilepath}: ${v.message}`).join("\n")); + super({ + message: violations.map((v) => `${v.relativeFilepath}: ${v.message}`).join("\n"), + code: CliError.Code.ValidationError + }); + Object.setPrototypeOf(this, ValidationError.prototype); this.violations = violations; } } diff --git a/packages/cli/cli-v2/src/sdk/generator/GeneratorPipeline.ts b/packages/cli/cli-v2/src/sdk/generator/GeneratorPipeline.ts index 441ebaacdf50..eb9764aff29c 100644 --- a/packages/cli/cli-v2/src/sdk/generator/GeneratorPipeline.ts +++ b/packages/cli/cli-v2/src/sdk/generator/GeneratorPipeline.ts @@ -4,10 +4,11 @@ import type { Audiences } from "@fern-api/configuration"; import type { ContainerRunner } from "@fern-api/core-utils"; import { extractErrorMessage } from "@fern-api/core-utils"; import type { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; + import type { AiConfig } from "../../ai/config/AiConfig.js"; import type { ApiDefinition } from "../../api/config/ApiDefinition.js"; import type { Context } from "../../context/Context.js"; -import { CliError } from "../../errors/CliError.js"; import type { Task } from "../../ui/Task.js"; import type { Target } from "../config/Target.js"; import { LegacyLocalGenerationRunner } from "./LegacyLocalGenerationRunner.js"; @@ -122,7 +123,8 @@ export class GeneratorPipeline { throw new CliError({ message: `Custom image configurations are only supported with local generation (--local). ` + - `Target "${args.target.name}" uses a custom image registry.` + `Target "${args.target.name}" uses a custom image registry.`, + code: CliError.Code.ConfigError }); } return await this.runRemoteGeneration(args); diff --git a/packages/cli/cli-v2/src/telemetry/LifecycleEvent.ts b/packages/cli/cli-v2/src/telemetry/LifecycleEvent.ts index 16778b004dae..e0e76f8f4d9c 100644 --- a/packages/cli/cli-v2/src/telemetry/LifecycleEvent.ts +++ b/packages/cli/cli-v2/src/telemetry/LifecycleEvent.ts @@ -1,5 +1,3 @@ -import type { CliError } from "../errors/CliError.js"; - export interface LifecycleEvent { /** The command that was run */ command: string; @@ -8,5 +6,5 @@ export interface LifecycleEvent { /** The duration of the command in milliseconds */ durationMs: number; /** The error code of the command, if it failed */ - errorCode?: CliError.Code; + errorCode?: string; } diff --git a/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts b/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts index 9475937af108..9c777d94a533 100644 --- a/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts +++ b/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts @@ -107,7 +107,7 @@ export class TelemetryClient { * The caller is responsible for deciding which errors are worth reporting * (see `shouldReportToSentry` in withContext.ts). */ - public captureException(error: unknown): void { + public captureException(error: unknown, { errorCode }: { errorCode: string }): void { if (this.sentry === undefined) { return; } @@ -115,7 +115,7 @@ export class TelemetryClient { this.sentry.captureException(error, { captureContext: { user: { id: this.distinctId }, - tags: { ...this.baseTags, ...this.accumulatedTags } + tags: { ...this.baseTags, ...this.accumulatedTags, "error.code": errorCode } } }); } catch { diff --git a/packages/cli/cli/src/__test__/errorReporting.test.ts b/packages/cli/cli/src/__test__/errorReporting.test.ts new file mode 100644 index 000000000000..5d63cb8e66e3 --- /dev/null +++ b/packages/cli/cli/src/__test__/errorReporting.test.ts @@ -0,0 +1,49 @@ +import { CliError, TaskAbortSignal } from "@fern-api/task-context"; +import { describe, expect, it, vi } from "vitest"; + +import { reportError } from "../telemetry/reportError.js"; +import { createMockCliContext } from "./mockCliContext.js"; + +vi.mock("../telemetry/reportError.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + reportError: vi.fn(original.reportError) + }; +}); + +const reportErrorSpy = vi.mocked(reportError); + +describe("error reporting", () => { + it("task failure via failAndThrow reports exactly once through the full chain", async () => { + reportErrorSpy.mockClear(); + const cliContext = await createMockCliContext(); + + try { + await cliContext.runTask(async (context) => { + context.failAndThrow("bad", undefined, { code: CliError.Code.ConfigError }); + }); + } catch (error) { + expect(error).toBeInstanceOf(TaskAbortSignal); + cliContext.failWithoutThrowing(undefined, error); + } + + expect(reportErrorSpy).toHaveBeenCalledOnce(); + }); + + it("uncaught task error reports exactly once through the full chain", async () => { + reportErrorSpy.mockClear(); + const cliContext = await createMockCliContext(); + + try { + await cliContext.runTask(async () => { + throw new CliError({ message: "something broke", code: CliError.Code.InternalError }); + }); + } catch (error) { + expect(error).toBeInstanceOf(TaskAbortSignal); + cliContext.failWithoutThrowing(undefined, error); + } + + expect(reportErrorSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/cli/src/cli-context/CliContext.ts b/packages/cli/cli/src/cli-context/CliContext.ts index 1ebfdbcf7a9f..de5f271ef024 100644 --- a/packages/cli/cli/src/cli-context/CliContext.ts +++ b/packages/cli/cli/src/cli-context/CliContext.ts @@ -3,11 +3,21 @@ import { createLogger, LOG_LEVELS, LogLevel } from "@fern-api/logger"; import { getPosthogManager, PosthogManager } from "@fern-api/posthog-manager"; import { Project } from "@fern-api/project-loader"; import { isVersionAhead } from "@fern-api/semver-utils"; -import { Finishable, PosthogEvent, Startable, TaskAbortSignal, TaskContext, TaskResult } from "@fern-api/task-context"; +import { + CliError, + Finishable, + PosthogEvent, + Startable, + TaskAbortSignal, + TaskContext, + TaskResult +} from "@fern-api/task-context"; + import { Workspace } from "@fern-api/workspace-loader"; import { input, select } from "@inquirer/prompts"; import chalk from "chalk"; import { maxBy } from "lodash-es"; +import { reportError } from "../telemetry/reportError.js"; import { SentryClient } from "../telemetry/SentryClient.js"; import { CliEnvironment } from "./CliEnvironment.js"; import { StdoutRedirector } from "./StdoutRedirector.js"; @@ -108,14 +118,18 @@ export class CliContext { ); } - public failAndThrow(message?: string, error?: unknown): never { - this.failWithoutThrowing(message, error); + public failAndThrow(message?: string, error?: unknown, options?: { code?: CliError.Code }): never { + this.failWithoutThrowing(message, error, options); throw new TaskAbortSignal(); } - public failWithoutThrowing(message?: string, error?: unknown): void { + public failWithoutThrowing(message?: string, error?: unknown, options?: { code?: CliError.Code }): void { this.didSucceed = false; + if (error instanceof TaskAbortSignal) { + return; + } logErrorMessage({ message, error, logger: this.logger }); + reportError(this, error, { ...options, message }); } /** @@ -241,16 +255,9 @@ export class CliContext { try { result = await run(context); } catch (error) { - if (error instanceof TaskAbortSignal) { - // thrower is responsible for logging, so we generally don't need to log here. - throw error; - } - if ((error as Error).message.includes("globalThis")) { - context.logger.error(this.USE_NODE_18_OR_ABOVE_MESSAGE); - context.failWithoutThrowing(); - } else { - context.failWithoutThrowing(undefined, error); - } + context.failWithoutThrowing(undefined, error); + + // We need to throw a TaskAbortSignal to stop execution. throw new TaskAbortSignal(); } finally { context.finish(); @@ -264,8 +271,8 @@ export class CliContext { } } - public async captureException(error: unknown): Promise { - await this.sentryClient.captureException(error); + public captureException(error: unknown, code?: CliError.Code): void { + this.sentryClient.captureException(error, code); } public readonly logger = createLogger((level, ...args) => this.log(level, ...args)); @@ -308,7 +315,10 @@ export class CliContext { instrumentPostHogEvent: (event) => { this.instrumentPostHogEvent(event); }, - shouldBufferLogs: false + shouldBufferLogs: false, + captureException: (error, code) => { + this.sentryClient.captureException(error, code); + } }; } diff --git a/packages/cli/cli/src/cli-context/TaskContextImpl.ts b/packages/cli/cli/src/cli-context/TaskContextImpl.ts index c6d77650871a..180b5c006f41 100644 --- a/packages/cli/cli/src/cli-context/TaskContextImpl.ts +++ b/packages/cli/cli/src/cli-context/TaskContextImpl.ts @@ -2,6 +2,7 @@ import { Log, logErrorMessage } from "@fern-api/cli-logger"; import { addPrefixToString } from "@fern-api/core-utils"; import { createLogger, LogLevel } from "@fern-api/logger"; import { + CliError, CreateInteractiveTaskParams, Finishable, InteractiveTaskContext, @@ -13,6 +14,8 @@ import { } from "@fern-api/task-context"; import chalk from "chalk"; +import { reportError } from "../telemetry/reportError.js"; + export declare namespace TaskContextImpl { export interface Init { logImmediately: (logs: Log[]) => void; @@ -24,6 +27,7 @@ export declare namespace TaskContextImpl { onResult?: (result: TaskResult) => void; shouldBufferLogs: boolean; instrumentPostHogEvent: (event: PosthogEvent) => void; + captureException?: (error: unknown, code?: CliError.Code) => void; } } @@ -37,13 +41,15 @@ export class TaskContextImpl implements Startable, Finishable, Task protected status: "notStarted" | "running" | "finished" = "notStarted"; private onResult: ((result: TaskResult) => void) | undefined; private instrumentPostHogEventImpl: (event: PosthogEvent) => void; + private captureExceptionImpl?: (error: unknown, code?: CliError.Code) => void; public constructor({ logImmediately, logPrefix, takeOverTerminal, onResult, shouldBufferLogs, - instrumentPostHogEvent + instrumentPostHogEvent, + captureException }: TaskContextImpl.Init) { this.logImmediately = logImmediately; this.logPrefix = logPrefix ?? ""; @@ -51,6 +57,7 @@ export class TaskContextImpl implements Startable, Finishable, Task this.onResult = onResult; this.shouldBufferLogs = shouldBufferLogs; this.instrumentPostHogEventImpl = instrumentPostHogEvent; + this.captureExceptionImpl = captureException; } public start(): Finishable & TaskContext { @@ -74,15 +81,23 @@ export class TaskContextImpl implements Startable, Finishable, Task public takeOverTerminal: (run: () => void | Promise) => Promise; - public failAndThrow(message?: string, error?: unknown): never { - this.failWithoutThrowing(message, error); + public failAndThrow(message?: string, error?: unknown, options?: { code?: CliError.Code }): never { + this.failWithoutThrowing(message, error, options); this.finish(); throw new TaskAbortSignal(); } - public failWithoutThrowing(message?: string, error?: unknown): void { - logErrorMessage({ message, error, logger: this.logger }); + public failWithoutThrowing(message?: string, error?: unknown, options?: { code?: CliError.Code }): void { this.result = TaskResult.Failure; + if (error instanceof TaskAbortSignal) { + return; + } + logErrorMessage({ message, error, logger: this.logger }); + reportError(this, error, { ...options, message }); + } + + public captureException(error: unknown, code?: CliError.Code): void { + this.captureExceptionImpl?.(error, code); } public getResult(): TaskResult { @@ -132,7 +147,8 @@ export class TaskContextImpl implements Startable, Finishable, Task takeOverTerminal: this.takeOverTerminal, onResult: this.onResult, shouldBufferLogs: this.shouldBufferLogs, - instrumentPostHogEvent: (event) => this.instrumentPostHogEventImpl(event) + instrumentPostHogEvent: (event) => this.instrumentPostHogEventImpl(event), + captureException: this.captureExceptionImpl }); this.subtasks.push(subtask); return subtask; diff --git a/packages/cli/cli/src/cli.ts b/packages/cli/cli/src/cli.ts index 9dbbf3e11595..bf8fb1b3be99 100644 --- a/packages/cli/cli/src/cli.ts +++ b/packages/cli/cli/src/cli.ts @@ -34,7 +34,6 @@ import { import { LOG_LEVELS, LogLevel } from "@fern-api/logger"; import { askToLogin, login, logout } from "@fern-api/login"; import { protocGenFern } from "@fern-api/protoc-gen-fern"; -import { LoggableFernCliError, TaskAbortSignal } from "@fern-api/task-context"; import getPort from "get-port"; import { Argv } from "yargs"; import { hideBin } from "yargs/helpers"; @@ -89,8 +88,6 @@ import { RUNTIME } from "./runtime.js"; void runCli(); -const USE_NODE_18_OR_ABOVE_MESSAGE = "The Fern CLI requires Node 18+ or above."; - async function runCli() { getOrCreateFernRunId(); @@ -135,28 +132,7 @@ async function runCli() { }); } } catch (error) { - cliContext.instrumentPostHogEvent({ - command: process.argv.join(" "), - properties: { - failed: true, - error - } - }); - if (error instanceof TaskAbortSignal) { - // thrower is responsible for logging, so we generally don't need to log here. - cliContext.failWithoutThrowing(); - } else if ((error as Error)?.message.includes("globalThis")) { - cliContext.logger.error(USE_NODE_18_OR_ABOVE_MESSAGE); - cliContext.failWithoutThrowing(); - } else if (error instanceof LoggableFernCliError) { - cliContext.logger.error(`Failed. ${error.log}`); - } else { - // TODO: This is intentionally broad for initial rollout. - // We likely capture more than intended; narrow reporting with - // explicit error classification once we collect real-world signal. - await cliContext.captureException(error); - cliContext.failWithoutThrowing("Failed.", error); - } + cliContext.failWithoutThrowing(undefined, error); } await exit(); diff --git a/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts b/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts index 72ef654929fd..43f032e3fb27 100644 --- a/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts +++ b/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts @@ -24,13 +24,14 @@ function createMockContext(): TaskContext { throw new Error(message ?? "Task failed"); }, failWithoutThrowing: noop, + captureException: noop, getResult: () => TaskResult.Success, addInteractiveTask: () => { throw new Error("Not implemented in mock"); }, runInteractiveTask: async () => false, instrumentPostHogEvent: () => { - return; + // no-op } }; } diff --git a/packages/cli/cli/src/telemetry/SentryClient.ts b/packages/cli/cli/src/telemetry/SentryClient.ts index 072795878a4d..bee71a2e99e8 100644 --- a/packages/cli/cli/src/telemetry/SentryClient.ts +++ b/packages/cli/cli/src/telemetry/SentryClient.ts @@ -24,12 +24,15 @@ export class SentryClient { } } - public async captureException(error: unknown): Promise { + public captureException(error: unknown, code?: string): void { if (this.sentry == null) { return; } try { - this.sentry.captureException(error); + this.sentry.captureException( + error, + code != null ? { captureContext: { tags: { "error.code": code } } } : undefined + ); } catch { // no-op } diff --git a/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts b/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts index 03b873a291f5..93ecd8e7d184 100644 --- a/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts +++ b/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts @@ -17,6 +17,7 @@ vi.mock("@sentry/node", () => ({ setTag: vi.fn() })); +import { CliError } from "@fern-api/task-context"; import { SentryClient } from "../SentryClient.js"; describe("SentryClient (cli-v1)", () => { @@ -51,6 +52,18 @@ describe("SentryClient (cli-v1)", () => { expect(mockSentryInit).toHaveBeenCalledOnce(); expect(mockSentryCaptureException).toHaveBeenCalledOnce(); + expect(mockSentryCaptureException).toHaveBeenCalledWith(expect.any(Error), undefined); + }); + + it("passes error.code tag via captureContext when code is provided", () => { + const client = new SentryClient({ release: "cli@1.2.3" }); + const error = new Error("something broke"); + + client.captureException(error, CliError.Code.InternalError); + + expect(mockSentryCaptureException).toHaveBeenCalledWith(error, { + captureContext: { tags: { "error.code": CliError.Code.InternalError } } + }); }); it("flushes the Sentry client", async () => { diff --git a/packages/cli/cli/src/telemetry/reportError.ts b/packages/cli/cli/src/telemetry/reportError.ts new file mode 100644 index 000000000000..5912bf3bc62a --- /dev/null +++ b/packages/cli/cli/src/telemetry/reportError.ts @@ -0,0 +1,28 @@ +import type { PosthogEvent } from "@fern-api/task-context"; +import { CliError, resolveErrorCode, shouldReportToSentry, TaskAbortSignal } from "@fern-api/task-context"; + +export interface ErrorReporter { + instrumentPostHogEvent: (event: PosthogEvent) => void; + captureException: (error: unknown, code?: CliError.Code) => void; +} + +export function reportError( + reporter: ErrorReporter, + error: unknown, + options?: { message?: string; code?: CliError.Code } +): void { + if (error instanceof TaskAbortSignal) { + return; + } + const code = resolveErrorCode(error, options?.code); + reporter.instrumentPostHogEvent({ + command: process.argv.join(" "), + properties: { + failed: true, + errorCode: code + } + }); + if (shouldReportToSentry(code)) { + reporter.captureException(error ?? new CliError({ message: options?.message ?? "", code }), code); + } +} diff --git a/packages/cli/generation/ir-generator/src/extended-properties/addExtendedPropertiesToIr.ts b/packages/cli/generation/ir-generator/src/extended-properties/addExtendedPropertiesToIr.ts index d3e5aab62b63..f6f3f2e0f0c8 100644 --- a/packages/cli/generation/ir-generator/src/extended-properties/addExtendedPropertiesToIr.ts +++ b/packages/cli/generation/ir-generator/src/extended-properties/addExtendedPropertiesToIr.ts @@ -6,7 +6,7 @@ import { ObjectTypeDeclaration, TypeDeclaration } from "@fern-api/ir-sdk"; -import { LoggableFernCliError } from "@fern-api/task-context"; +import { CliError } from "@fern-api/task-context"; import { getTypeDeclaration } from "../utils/getTypeDeclaration.js"; @@ -96,9 +96,7 @@ function getObjectTypeDeclarationFromTypeId(typeId: string, ir: TypesAndServices } } - throw new LoggableFernCliError( - `Unexpected error: ${typeId} is extended but has shape ${typeDeclaration.shape.type}` - ); + throw CliError.internalError(`Unexpected error: ${typeId} is extended but has shape ${typeDeclaration.shape.type}`); } function getAllPropertiesForObject({ diff --git a/packages/cli/generation/ir-generator/src/utils/getTypeDeclaration.ts b/packages/cli/generation/ir-generator/src/utils/getTypeDeclaration.ts index 0c1f8561c99e..970230df2e07 100644 --- a/packages/cli/generation/ir-generator/src/utils/getTypeDeclaration.ts +++ b/packages/cli/generation/ir-generator/src/utils/getTypeDeclaration.ts @@ -1,10 +1,10 @@ import { TypeDeclaration } from "@fern-api/ir-sdk"; -import { LoggableFernCliError } from "@fern-api/task-context"; +import { CliError } from "@fern-api/task-context"; export function getTypeDeclaration(typeId: string, types: Record): TypeDeclaration { const maybeTypeDeclaration = types[typeId]; if (maybeTypeDeclaration == null) { - throw new LoggableFernCliError(`Illegal Error: Failed to load type declaration for type ${typeId}`); + throw CliError.internalError(`Failed to load type declaration for type ${typeId}`); } return maybeTypeDeclaration; } diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/GenerationRunner.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/GenerationRunner.ts index 0b26a75da478..5eba9937743f 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/GenerationRunner.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/GenerationRunner.ts @@ -4,7 +4,7 @@ import { generatorsYml, SNIPPET_JSON_FILENAME } from "@fern-api/configuration"; import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; import { generateIntermediateRepresentation } from "@fern-api/ir-generator"; import { IntermediateRepresentation } from "@fern-api/ir-sdk"; -import { LoggableFernCliError, TaskAbortSignal, TaskContext } from "@fern-api/task-context"; +import { TaskAbortSignal, TaskContext } from "@fern-api/task-context"; import { FernGeneratorExec } from "@fern-fern/generator-exec-sdk"; import chalk from "chalk"; import { generateDynamicSnippetTests } from "./dynamic-snippets/generateDynamicSnippetTests.js"; @@ -101,8 +101,6 @@ export class GenerationRunner { } catch (error) { if (error instanceof TaskAbortSignal) { // already logged by failAndThrow, nothing to do - } else if (error instanceof LoggableFernCliError) { - interactiveTaskContext.failWithoutThrowing(`Generation failed: ${error.log}`, error); } else { interactiveTaskContext.failWithoutThrowing( `Generation failed: ${error instanceof Error ? error.message : "Unknown error"}`, diff --git a/packages/cli/task-context/src/CliError.ts b/packages/cli/task-context/src/CliError.ts new file mode 100644 index 000000000000..13c2b7c6d6a7 --- /dev/null +++ b/packages/cli/task-context/src/CliError.ts @@ -0,0 +1,112 @@ +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 }) { + super(message); + Object.setPrototypeOf(this, CliError.prototype); + this.code = code; + this.docsLink = docsLink; + } + + 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 + }); + } + + 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 + }); + } + + public static notFound(message?: string): CliError { + return new CliError({ message, code: CliError.Code.ConfigError }); + } + + public static badRequest(message?: string): CliError { + return new CliError({ message, code: CliError.Code.NetworkError }); + } + + public static validationError(message?: string): CliError { + return new CliError({ message, code: CliError.Code.ValidationError }); + } + + public static internalError(message?: string): CliError { + return new CliError({ message, code: CliError.Code.InternalError }); + } +} + +export namespace CliError { + export type Code = (typeof Code)[keyof typeof Code]; + export const Code = { + InternalError: "INTERNAL_ERROR", + ResolutionError: "RESOLUTION_ERROR", + IrConversionError: "IR_CONVERSION_ERROR", + ContainerError: "CONTAINER_ERROR", + VersionError: "VERSION_ERROR", + ParseError: "PARSE_ERROR", + EnvironmentError: "ENVIRONMENT_ERROR", + ReferenceError: "REFERENCE_ERROR", + ValidationError: "VALIDATION_ERROR", + NetworkError: "NETWORK_ERROR", + AuthError: "AUTH_ERROR", + ConfigError: "CONFIG_ERROR" + } as const; +} + +const SENTRY_REPORTABLE: Record = { + [CliError.Code.InternalError]: true, + [CliError.Code.ResolutionError]: true, + [CliError.Code.IrConversionError]: true, + [CliError.Code.ContainerError]: true, + [CliError.Code.VersionError]: true, + [CliError.Code.ParseError]: false, + [CliError.Code.EnvironmentError]: false, + [CliError.Code.ReferenceError]: false, + [CliError.Code.ValidationError]: false, + [CliError.Code.NetworkError]: false, + [CliError.Code.AuthError]: false, + [CliError.Code.ConfigError]: false +}; + +export function shouldReportToSentry(code: CliError.Code): boolean { + return SENTRY_REPORTABLE[code]; +} + +function isSchemaValidationError(error: unknown): boolean { + return ( + error instanceof Error && (error.constructor.name === "ParseError" || error.constructor.name === "JsonError") + ); +} + +function isNodeVersionError(error: unknown): boolean { + return error instanceof Error && error.message.includes("globalThis"); +} + +/** + * Resolves the effective error code: explicit override wins, + * then auto-detects from known error types, + * and falls back to INTERNAL_ERROR for truly unknown errors. + */ +export function resolveErrorCode(error: unknown, explicitCode?: CliError.Code): CliError.Code { + if (explicitCode != null) { + return explicitCode; + } + if (error instanceof CliError) { + return error.code; + } + if (isSchemaValidationError(error)) { + return CliError.Code.ParseError; + } + if (isNodeVersionError(error)) { + return CliError.Code.EnvironmentError; + } + return CliError.Code.InternalError; +} diff --git a/packages/cli/task-context/src/LoggableFernCliError.ts b/packages/cli/task-context/src/LoggableFernCliError.ts deleted file mode 100644 index 28fbb47db1a8..000000000000 --- a/packages/cli/task-context/src/LoggableFernCliError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class LoggableFernCliError extends Error { - constructor(public readonly log: string) { - super(); - Object.setPrototypeOf(this, LoggableFernCliError.prototype); - } -} diff --git a/packages/cli/task-context/src/MockTaskContext.ts b/packages/cli/task-context/src/MockTaskContext.ts index 936e6c661b72..bcb0afcde027 100644 --- a/packages/cli/task-context/src/MockTaskContext.ts +++ b/packages/cli/task-context/src/MockTaskContext.ts @@ -1,15 +1,16 @@ import { CONSOLE_LOGGER, Logger } from "@fern-api/logger"; +import { type CliError } from "./CliError.js"; import { TaskAbortSignal } from "./TaskAbortSignal.js"; import { TaskContext, TaskResult } from "./TaskContext.js"; export function createMockTaskContext({ logger = CONSOLE_LOGGER }: { logger?: Logger } = {}): TaskContext { - const context = { + const context: TaskContext = { logger, takeOverTerminal: () => { throw new Error("Not implemented"); }, - failAndThrow: (message?: string, error?: unknown) => { + failAndThrow: (message?: string, error?: unknown, _options?: { code?: CliError.Code }) => { const parts = []; if (message != null) { parts.push(message); @@ -22,10 +23,12 @@ export function createMockTaskContext({ logger = CONSOLE_LOGGER }: { logger?: Lo } throw new TaskAbortSignal(); }, - failWithoutThrowing: (message?: string, error?: unknown) => { - // in mock contexts, any failures should throw + failWithoutThrowing: (message?: string, error?: unknown, _options?: { code?: CliError.Code }) => { context.failAndThrow(message, error); }, + captureException: () => { + // no-op in mock context + }, getResult: () => TaskResult.Success, addInteractiveTask: () => { throw new Error("Not implemented"); diff --git a/packages/cli/task-context/src/TaskContext.ts b/packages/cli/task-context/src/TaskContext.ts index 79825f13ff73..c84b0e364f67 100644 --- a/packages/cli/task-context/src/TaskContext.ts +++ b/packages/cli/task-context/src/TaskContext.ts @@ -1,10 +1,13 @@ import { Logger } from "@fern-api/logger"; +import { type CliError } from "./CliError.js"; + export interface TaskContext { logger: Logger; takeOverTerminal: (run: () => void | Promise) => Promise; - failAndThrow: (message?: string, error?: unknown) => never; - failWithoutThrowing: (message?: string, error?: unknown) => void; + failAndThrow: (message?: string, error?: unknown, options?: { code?: CliError.Code }) => never; + failWithoutThrowing: (message?: string, error?: unknown, options?: { code?: CliError.Code }) => void; + captureException: (error: unknown, code?: CliError.Code) => void; getResult: () => TaskResult; addInteractiveTask: (params: CreateInteractiveTaskParams) => Startable; runInteractiveTask: ( diff --git a/packages/cli/task-context/src/index.ts b/packages/cli/task-context/src/index.ts index 02162026b6f0..5b624f867c47 100644 --- a/packages/cli/task-context/src/index.ts +++ b/packages/cli/task-context/src/index.ts @@ -1,4 +1,4 @@ -export { LoggableFernCliError } from "./LoggableFernCliError.js"; +export { CliError, resolveErrorCode, shouldReportToSentry } from "./CliError.js"; export { createMockTaskContext } from "./MockTaskContext.js"; export { TaskAbortSignal } from "./TaskAbortSignal.js"; export { diff --git a/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts b/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts index b8644b4f7684..50825ff24e93 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts @@ -20,6 +20,7 @@ export function createMockTaskContext(): TaskContext { throw new Error(message ?? "Task failed"); }, failWithoutThrowing: noop, + captureException: noop, getResult: () => TaskResult.Success, addInteractiveTask: () => { throw new Error("Not implemented in mock"); diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtobufOpenAPIGeneratorResolution.test.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtobufOpenAPIGeneratorResolution.test.ts index 14454db3bcd6..68de5255708a 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtobufOpenAPIGeneratorResolution.test.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtobufOpenAPIGeneratorResolution.test.ts @@ -61,6 +61,7 @@ function createMockTaskContext(): TaskContext { throw new Error(message ?? "Task failed"); }) as unknown as TaskContext["failAndThrow"], failWithoutThrowing: vi.fn(), + captureException: vi.fn(), getResult: () => TaskResult.Success, addInteractiveTask: () => { throw new Error("Not implemented in mock"); diff --git a/packages/seed/src/TaskContextImpl.ts b/packages/seed/src/TaskContextImpl.ts index 7f8416b355a7..8c42a0849e64 100644 --- a/packages/seed/src/TaskContextImpl.ts +++ b/packages/seed/src/TaskContextImpl.ts @@ -3,6 +3,7 @@ import { addPrefixToString } from "@fern-api/core-utils"; import { createLogger, LogLevel } from "@fern-api/logger"; import { + CliError, CreateInteractiveTaskParams, Finishable, InteractiveTaskContext, @@ -78,17 +79,21 @@ export class TaskContextImpl implements Startable, Finishable, Task public takeOverTerminal: (run: () => void | Promise) => Promise; - public failAndThrow(message?: string, error?: unknown): never { + public failAndThrow(message?: string, error?: unknown, _options?: { code?: CliError.Code }): never { this.failWithoutThrowing(message, error); this.finish(); throw new TaskAbortSignal(); } - public failWithoutThrowing(message?: string, error?: unknown): void { + public failWithoutThrowing(message?: string, error?: unknown, _options?: { code?: CliError.Code }): void { logErrorMessage({ message, error, logger: this.logger }); this.result = TaskResult.Failure; } + public captureException(_error: unknown, _code?: CliError.Code): void { + // no-op in seed context + } + public getResult(): TaskResult { if (this.result === TaskResult.Failure) { return TaskResult.Failure; diff --git a/packages/snippets/core/src/utils/createTaskContext.ts b/packages/snippets/core/src/utils/createTaskContext.ts index 9eea87cf191f..5537eac2bf95 100644 --- a/packages/snippets/core/src/utils/createTaskContext.ts +++ b/packages/snippets/core/src/utils/createTaskContext.ts @@ -19,6 +19,9 @@ export function createTaskContext(): TaskContext { failWithoutThrowing: (_message?: string, _error?: unknown) => { // no-op }, + captureException: (_error: unknown) => { + // no-op + }, getResult: () => TaskResult.Success, addInteractiveTask: () => { throw new Error("unimplemented");