diff --git a/packages/cli/cli-v2/src/__test__/exitCode.test.ts b/packages/cli/cli-v2/src/__test__/exitCode.test.ts new file mode 100644 index 000000000000..550d55d81e59 --- /dev/null +++ b/packages/cli/cli-v2/src/__test__/exitCode.test.ts @@ -0,0 +1,77 @@ +import { CliError, TaskAbortSignal } from "@fern-api/task-context"; +import { describe, expect, it } from "vitest"; + +import { ExitCode, exitCodeForCliErrorCode, exitCodeForError } from "../errors/exitCode.js"; + +describe("exitCodeForCliErrorCode", () => { + it("maps usage errors to ExitCode.Usage (sysexits-aligned 2)", () => { + expect(exitCodeForCliErrorCode(CliError.Code.UserError)).toBe(ExitCode.Usage); + }); + + it("maps validation / parse / IR / reference errors to DataErr (65)", () => { + expect(exitCodeForCliErrorCode(CliError.Code.ValidationError)).toBe(ExitCode.DataErr); + expect(exitCodeForCliErrorCode(CliError.Code.ParseError)).toBe(ExitCode.DataErr); + expect(exitCodeForCliErrorCode(CliError.Code.IrConversionError)).toBe(ExitCode.DataErr); + expect(exitCodeForCliErrorCode(CliError.Code.ReferenceError)).toBe(ExitCode.DataErr); + }); + + it("maps resolution errors to NoInput (66)", () => { + expect(exitCodeForCliErrorCode(CliError.Code.ResolutionError)).toBe(ExitCode.NoInput); + }); + + it("maps auth errors to NoPerm (77)", () => { + expect(exitCodeForCliErrorCode(CliError.Code.AuthError)).toBe(ExitCode.NoPerm); + }); + + it("maps config and version errors to Config (78)", () => { + expect(exitCodeForCliErrorCode(CliError.Code.ConfigError)).toBe(ExitCode.Config); + expect(exitCodeForCliErrorCode(CliError.Code.VersionError)).toBe(ExitCode.Config); + }); + + it("maps network / container / environment errors to TempFail (75)", () => { + expect(exitCodeForCliErrorCode(CliError.Code.NetworkError)).toBe(ExitCode.TempFail); + expect(exitCodeForCliErrorCode(CliError.Code.ContainerError)).toBe(ExitCode.TempFail); + expect(exitCodeForCliErrorCode(CliError.Code.EnvironmentError)).toBe(ExitCode.TempFail); + }); + + it("maps internal errors to Software (70)", () => { + expect(exitCodeForCliErrorCode(CliError.Code.InternalError)).toBe(ExitCode.Software); + }); +}); + +describe("exitCodeForError", () => { + it("returns Generic for TaskAbortSignal with no code (multi-task failure, error already logged)", () => { + expect(exitCodeForError(new TaskAbortSignal())).toBe(ExitCode.Generic); + }); + + it("delegates to exitCodeForCliErrorCode for TaskAbortSignal with a code", () => { + expect(exitCodeForError(new TaskAbortSignal(CliError.Code.AuthError))).toBe(ExitCode.NoPerm); + expect(exitCodeForError(new TaskAbortSignal(CliError.Code.ValidationError))).toBe(ExitCode.DataErr); + expect(exitCodeForError(new TaskAbortSignal(CliError.Code.ConfigError))).toBe(ExitCode.Config); + }); + + it("delegates to exitCodeForCliErrorCode for CliError instances", () => { + const authErr = new CliError({ code: CliError.Code.AuthError, message: "not logged in" }); + expect(exitCodeForError(authErr)).toBe(ExitCode.NoPerm); + + const usageErr = new CliError({ code: CliError.Code.UserError, message: "bad flag" }); + expect(exitCodeForError(usageErr)).toBe(ExitCode.Usage); + }); + + it("returns Software for unhandled Error subclasses", () => { + expect(exitCodeForError(new Error("kaboom"))).toBe(ExitCode.Software); + expect(exitCodeForError(new TypeError("nope"))).toBe(ExitCode.Software); + }); + + it("returns Software for non-Error throwables", () => { + expect(exitCodeForError("string thrown")).toBe(ExitCode.Software); + expect(exitCodeForError(42)).toBe(ExitCode.Software); + expect(exitCodeForError(undefined)).toBe(ExitCode.Software); + expect(exitCodeForError(null)).toBe(ExitCode.Software); + }); + + it("uses canonical signal exit codes (128 + signal number)", () => { + expect(ExitCode.Sigint).toBe(130); + expect(ExitCode.Sigterm).toBe(143); + }); +}); diff --git a/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts b/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts index 4a1d0d41673c..9dc2279284b4 100644 --- a/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts +++ b/packages/cli/cli-v2/src/commands/_internal/yargsFailHandler.ts @@ -2,6 +2,7 @@ import { CliError } from "@fern-api/task-context"; import type { Argv } from "yargs"; import type { GlobalArgs } from "../../context/GlobalArgs.js"; +import { exitCodeForError } from "../../errors/exitCode.js"; import { renderError } from "../../errors/renderError.js"; /** @@ -31,9 +32,9 @@ export function makeYargsFailHandler({ y.showHelp("error"); } // Yargs runs `.fail` synchronously and we are outside our async - // `withContext` lifecycle, so we exit directly. Stick with 1 today - // to avoid behavior changes for downstream scripts; the exit-code - // mapping ships separately. - process.exit(1); + // `withContext` lifecycle, so we exit directly. Use the same + // exit-code mapping as the main error boundary so shell scripts + // can branch on USER_ERROR vs the rest. + process.exit(exitCodeForError(error)); }; } diff --git a/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts b/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts index 902350d39c13..00f272e993f6 100644 --- a/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts +++ b/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts @@ -58,7 +58,7 @@ export class TaskContextAdapter implements TaskContext { public failAndThrow(message?: string, error?: unknown, options?: { code?: CliError.Code }): never { this.failWithoutThrowing(message, error, options); - throw new TaskAbortSignal(); + throw new TaskAbortSignal(options?.code); } public failWithoutThrowing(message?: string, error?: unknown, options?: { code?: CliError.Code }): void { diff --git a/packages/cli/cli-v2/src/context/withContext.ts b/packages/cli/cli-v2/src/context/withContext.ts index b8477529c77f..d636b1ac0eac 100644 --- a/packages/cli/cli-v2/src/context/withContext.ts +++ b/packages/cli/cli-v2/src/context/withContext.ts @@ -2,17 +2,12 @@ import { LogLevel } from "@fern-api/logger"; import { CliError, resolveErrorCode, shouldReportToSentry, TaskAbortSignal } from "@fern-api/task-context"; import chalk from "chalk"; +import { ExitCode, exitCodeForError } from "../errors/exitCode.js"; import { renderError } from "../errors/renderError.js"; import { Context } from "./Context.js"; import type { GlobalArgs } from "./GlobalArgs.js"; import { loadDotenvFile } from "./loadDotenvFile.js"; -// It's standard to use 128 as the base exit code for signals. -// https://en.wikipedia.org/wiki/Signal_(IPC) -const SIGNAL_EXIT_CODE_BASE = 128; -const SIGINT_EXIT_CODE = SIGNAL_EXIT_CODE_BASE + 2; -const SIGTERM_EXIT_CODE = SIGNAL_EXIT_CODE_BASE + 15; - /** * Wraps a command handler with context creation and error handling. * @@ -35,13 +30,13 @@ export function withContext( }); await context.telemetry.flush(); context.finish(); - await exitGracefully(0); + await exitGracefully(ExitCode.Success); } catch (error) { reportError(context, error); await context.telemetry.flush(); handleError(context, error); context.finish(); - await exitGracefully(1); + await exitGracefully(exitCodeForError(error)); } }; } @@ -127,8 +122,8 @@ function setupSignalHandler(context: Context): void { context.printLogFilePath(process.stderr); process.exit(exitCode); }; - process.on("SIGINT", () => onSignal(SIGINT_EXIT_CODE)); - process.on("SIGTERM", () => onSignal(SIGTERM_EXIT_CODE)); + process.on("SIGINT", () => onSignal(ExitCode.Sigint)); + process.on("SIGTERM", () => onSignal(ExitCode.Sigterm)); } /** diff --git a/packages/cli/cli-v2/src/errors/exitCode.ts b/packages/cli/cli-v2/src/errors/exitCode.ts new file mode 100644 index 000000000000..05e690483523 --- /dev/null +++ b/packages/cli/cli-v2/src/errors/exitCode.ts @@ -0,0 +1,100 @@ +import { assertNever } from "@fern-api/core-utils"; +import { CliError, TaskAbortSignal } from "@fern-api/task-context"; + +/** + * Process exit codes returned by the CLI on failure. + * + * Modeled on BSD `sysexits.h` (which is what most well-behaved Unix tools + * follow) so callers — shell scripts, CI pipelines, parent processes — can + * distinguish failure modes without parsing stderr. + * + * https://man.freebsd.org/cgi/man.cgi?query=sysexits + * + * Stability: these values are part of the CLI's public contract. Changing + * one is a breaking change for downstream scripts. + */ +export const ExitCode = { + Success: 0, + /** Catch-all for anything we don't have a more specific code for. */ + Generic: 1, + /** Usage error (bad flag, unknown command, missing required arg). */ + Usage: 2, + /** Input data was syntactically invalid (validation, parse, IR). */ + DataErr: 65, + /** A referenced input file or resource could not be found. */ + NoInput: 66, + /** Unhandled internal error / unexpected exception. */ + Software: 70, + /** + * Transient failure (network, container, environment). + * The operation failed for an external reason and may succeed on retry. + * Maps to EX_TEMPFAIL from sysexits.h. + */ + TempFail: 75, + /** Authentication or authorization failure. */ + NoPerm: 77, + /** The CLI's configuration is invalid. */ + Config: 78, + /** Command was interrupted by Ctrl-C (128 + SIGINT). */ + Sigint: 130, + /** Command was terminated by SIGTERM (128 + SIGTERM). */ + Sigterm: 143 +} as const; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; + +/** + * Resolve the process exit code for a thrown error. + * + * - {@link TaskAbortSignal} → mapped from `signal.code` if present (the + * originating {@link CliError.Code} stored by `failAndThrow`), otherwise + * {@link ExitCode.Generic}. Real SIGINT/SIGTERM are handled separately by + * `setupSignalHandler` which calls `process.exit` directly. + * - {@link CliError} → mapped from `error.code` via {@link exitCodeForCliErrorCode}. + * - Anything else (`Error`, thrown strings, etc.) → {@link ExitCode.Software} + * so it's distinguishable from a real `CliError` whose code maps to 1. + */ +export function exitCodeForError(error: unknown): ExitCode { + if (error instanceof TaskAbortSignal) { + return error.code != null ? exitCodeForCliErrorCode(error.code) : ExitCode.Generic; + } + if (error instanceof CliError) { + return exitCodeForCliErrorCode(error.code); + } + return ExitCode.Software; +} + +/** + * Map a {@link CliError.Code} to its process exit code. + * + * Every {@link CliError.Code} variant must be handled — `assertNever` at the + * end of the switch guarantees a compile error if a new variant is added + * without picking an exit code, so we never silently fall through to a + * generic 1. + */ +export function exitCodeForCliErrorCode(code: CliError.Code): ExitCode { + switch (code) { + case CliError.Code.UserError: + return ExitCode.Usage; + case CliError.Code.ValidationError: + case CliError.Code.ParseError: + case CliError.Code.IrConversionError: + case CliError.Code.ReferenceError: + return ExitCode.DataErr; + case CliError.Code.ResolutionError: + return ExitCode.NoInput; + case CliError.Code.AuthError: + return ExitCode.NoPerm; + case CliError.Code.ConfigError: + case CliError.Code.VersionError: + return ExitCode.Config; + case CliError.Code.NetworkError: + case CliError.Code.ContainerError: + case CliError.Code.EnvironmentError: + return ExitCode.TempFail; + case CliError.Code.InternalError: + return ExitCode.Software; + default: + assertNever(code); + } +} diff --git a/packages/cli/cli/changes/unreleased/cli-v2-exit-codes.yml b/packages/cli/cli/changes/unreleased/cli-v2-exit-codes.yml new file mode 100644 index 000000000000..f66c92a9f288 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/cli-v2-exit-codes.yml @@ -0,0 +1,12 @@ +- summary: | + Map `CliError.Code` to distinct sysexits-style process exit codes so shell scripts and CI pipelines can branch on failure type without parsing stderr: + - `USER_ERROR` → 2 (usage) + - `VALIDATION_ERROR` / `PARSE_ERROR` / `IR_CONVERSION_ERROR` / `REFERENCE_ERROR` → 65 (data err) + - `RESOLUTION_ERROR` → 66 (no input) + - `INTERNAL_ERROR` → 70 (software) + - `AUTH_ERROR` → 77 (no perm) + - `CONFIG_ERROR` / `VERSION_ERROR` → 78 (config) + - `NETWORK_ERROR` / `CONTAINER_ERROR` / `ENVIRONMENT_ERROR` → 75 (temp fail — transient, may succeed on retry) + - SIGINT → 130, SIGTERM → 143 + Both the central error boundary and the shared yargs `.fail` handler go through the same mapping. + type: feat diff --git a/packages/cli/ete-tests/src/tests/v2/generate.test.ts b/packages/cli/ete-tests/src/tests/v2/generate.test.ts index 25f0243a0ccf..77cb4bd8bf50 100644 --- a/packages/cli/ete-tests/src/tests/v2/generate.test.ts +++ b/packages/cli/ete-tests/src/tests/v2/generate.test.ts @@ -62,7 +62,8 @@ describe("fern sdk generate", () => { expectError: true, timeout: 60_000 }); - expect(result.exitCode).toBe(1); + // sysexits-style: user-config failures exit with EX_CONFIG=78. + expect(result.exitCode).toBe(78); }); }); diff --git a/packages/cli/ete-tests/src/tests/v2/v2.test.ts b/packages/cli/ete-tests/src/tests/v2/v2.test.ts index 1b495ff5bd14..5a7a0d81492a 100644 --- a/packages/cli/ete-tests/src/tests/v2/v2.test.ts +++ b/packages/cli/ete-tests/src/tests/v2/v2.test.ts @@ -67,7 +67,8 @@ describe("fern check", () => { cwd: fixture.path, expectError: true }); - expect(result.exitCode).toBe(1); + // sysexits-style: validation failures exit with EX_DATAERR=65. + expect(result.exitCode).toBe(65); expect(result.stdout).toBe(""); expect(result.stderr).toContain("fern.yml:1:6: org must be a string"); } finally { @@ -105,7 +106,8 @@ describe("fern check", () => { cwd: fixture.path, expectError: true }); - expect(result.exitCode).toBe(1); + // sysexits-style: user-config failures (unknown API) exit with EX_CONFIG=78. + expect(result.exitCode).toBe(78); expect(result.stderr).toContain("API 'nonexistent' not found"); expect(result.stderr).toContain("Available APIs:"); expect(result.stderr).toContain("api"); diff --git a/packages/cli/task-context/src/TaskAbortSignal.ts b/packages/cli/task-context/src/TaskAbortSignal.ts index 1a5c4927ac95..ed4e62ac86ac 100644 --- a/packages/cli/task-context/src/TaskAbortSignal.ts +++ b/packages/cli/task-context/src/TaskAbortSignal.ts @@ -1,10 +1,21 @@ +import { type CliError } from "./CliError.js"; + /** * Thrown by `failAndThrow` to unwind the call stack after an error has * already been logged and (if applicable) reported to Sentry. * - * This is NOT a real error — it carries no message, code, or stack trace. + * This is NOT a real error — it carries no message or stack trace. * Catch sites should silently swallow it or re-throw without logging. + * + * The optional `code` field carries the originating {@link CliError.Code} + * so the top-level exit-code mapper can produce a semantically correct + * exit code even when the error itself has already been consumed. */ export class TaskAbortSignal { readonly __brand = "TaskAbortSignal" as const; + readonly code: CliError.Code | undefined; + + constructor(code?: CliError.Code) { + this.code = code; + } }