Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/cli/cli-v2/src/__test__/exitCode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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));
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 5 additions & 10 deletions packages/cli/cli-v2/src/context/withContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -35,13 +30,13 @@ export function withContext<T extends GlobalArgs>(
});
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));
}
};
}
Expand Down Expand Up @@ -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));
}

/**
Expand Down
100 changes: 100 additions & 0 deletions packages/cli/cli-v2/src/errors/exitCode.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 12 additions & 0 deletions packages/cli/cli/changes/unreleased/cli-v2-exit-codes.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion packages/cli/ete-tests/src/tests/v2/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
6 changes: 4 additions & 2 deletions packages/cli/ete-tests/src/tests/v2/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/task-context/src/TaskAbortSignal.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}