Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
71 changes: 71 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,71 @@
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 Generic (1)", () => {
expect(exitCodeForCliErrorCode(CliError.Code.NetworkError)).toBe(ExitCode.Generic);
expect(exitCodeForCliErrorCode(CliError.Code.ContainerError)).toBe(ExitCode.Generic);
expect(exitCodeForCliErrorCode(CliError.Code.EnvironmentError)).toBe(ExitCode.Generic);
});

it("maps internal errors to Software (70)", () => {
expect(exitCodeForCliErrorCode(CliError.Code.InternalError)).toBe(ExitCode.Software);
});
});

describe("exitCodeForError", () => {
it("returns Sigint for TaskAbortSignal", () => {
expect(exitCodeForError(new TaskAbortSignal())).toBe(ExitCode.Sigint);
});

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));
};
}
10 changes: 5 additions & 5 deletions packages/cli/cli-v2/src/context/withContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ 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;
const SIGINT_EXIT_CODE = ExitCode.Sigint;
const SIGTERM_EXIT_CODE = ExitCode.Sigterm;

/**
* Wraps a command handler with context creation and error handling.
Expand All @@ -35,13 +35,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
93 changes: 93 additions & 0 deletions packages/cli/cli-v2/src/errors/exitCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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,
/** 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} → {@link ExitCode.Sigint} (treated as user
* cancellation; `setupSignalHandler` handles real SIGINT/SIGTERM
* separately).
* - {@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 ExitCode.Sigint;
}
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.Generic;
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` → 1 (generic)
- SIGINT → 130, SIGTERM → 143
Both the central error boundary and the shared yargs `.fail` handler go through the same mapping.
type: feat
Loading