Skip to content

feat(cli-v2): map CliError.Code to sysexits-style exit codes#16041

Open
iamnamananand996 wants to merge 3 commits into
devin/1779374495-cli-v2-error-rendererfrom
devin/1779374994-cli-v2-exit-codes
Open

feat(cli-v2): map CliError.Code to sysexits-style exit codes#16041
iamnamananand996 wants to merge 3 commits into
devin/1779374495-cli-v2-error-rendererfrom
devin/1779374994-cli-v2-exit-codes

Conversation

@iamnamananand996
Copy link
Copy Markdown
Contributor

Description

Linear ticket: Refs FER-10016 — wave-2, recommendation R5.

Stacked on top of #16040 (wave-1). This PR targets the wave-1 branch so reviewers see only the wave-2 delta; once #16040 merges I'll re-target this to main.

Today every cli-v2 failure exits with 1, regardless of whether the user mistyped a flag, hit an auth wall, fed in a malformed fern.config.json, or tripped an internal bug. Shell scripts and CI pipelines have to grep stderr to tell the difference. This PR maps each CliError.Code to a distinct process exit code following the BSD sysexits.h convention (the same one git, curl, and most well-behaved Unix tools use), so callers can branch on failure type without parsing strings.

USER_ERROR                                   → 2   (usage)
VALIDATION / PARSE / IR_CONVERSION / REF     → 65  (EX_DATAERR)
RESOLUTION_ERROR                             → 66  (EX_NOINPUT)
INTERNAL_ERROR                               → 70  (EX_SOFTWARE)
AUTH_ERROR                                   → 77  (EX_NOPERM)
CONFIG_ERROR / VERSION_ERROR                 → 78  (EX_CONFIG)
NETWORK / CONTAINER / ENVIRONMENT_ERROR      → 1   (generic — preserves today's behavior for retryable failures)
SIGINT  → 130    SIGTERM → 143

Both the central handleError boundary in withContext.ts and the shared yargs .fail handler in yargsFailHandler.ts go through the same mapping, so fern foo --no-such-flag and throw new CliError({ code: UserError }) both exit with 2.

Changes Made

  • Add packages/cli/cli-v2/src/errors/exitCode.ts exporting ExitCode, exitCodeForError(), and exitCodeForCliErrorCode(). The switch uses assertNever from @fern-api/core-utils, so adding a new CliError.Code variant without picking an exit code will fail compilation.
  • withContext.ts: route the failure-path exit through exitCodeForError(error) instead of hardcoded 1. Success path uses the new ExitCode.Success constant. Signal handlers now reference ExitCode.Sigint / ExitCode.Sigterm rather than literal 128 + signal math.
  • yargsFailHandler.ts: drop the previous "stick with 1 today" workaround and route through exitCodeForError(error) so the synthesized USER_ERROR exits 2.
  • Add packages/cli/cli-v2/src/__test__/exitCode.test.ts (12 tests) covering every CliError.Code variant, TaskAbortSignal, generic Error/TypeError, non-Error throwables (string/number/undefined/null), and the canonical 128+signal exit codes.
  • Updated README.md generator (n/a)

Behavior change & migration

Existing CI scripts that currently do fern check; if [ $? -eq 1 ] will still work for NETWORK_ERROR / CONTAINER_ERROR / ENVIRONMENT_ERROR and unhandled exceptions (still 1), but other failures now exit with a more specific code. Scripts that branched on "exit 1 = anything went wrong" should update to if [ $? -ne 0 ].

This is shipping as feat (minor bump) because it changes shell-visible behavior, even though every value is more correct than before.

Testing

  • Unit tests added: packages/cli/cli-v2/src/__test__/exitCode.test.ts (12 tests, all CliError.Code variants covered).
  • Full cli-v2 test suite still passes (726 tests).
  • pnpm format, pnpm lint:biome, pnpm check all green.
  • Manual end-to-end against the prod CLI build:
    • fern beta auth whoami (no token) → exit 77
    • fern beta auth whoamii (typo, yargs .fail) → exit 2
    • fern beta check outside any project → exit 78
    • fern beta whatever (top-level unknown command) → exit 2

Link to Devin session: https://app.devin.ai/sessions/c00fe336eaa44387a47db9083451e93c
Requested by: @iamnamananand996

Adds exitCodeForError() / exitCodeForCliErrorCode() that map every
CliError.Code variant to a distinct process exit code following the
BSD sysexits.h convention:

- USER_ERROR                        -> 2   (usage)
- VALIDATION/PARSE/IR/REFERENCE     -> 65  (data err)
- RESOLUTION_ERROR                  -> 66  (no input)
- INTERNAL_ERROR                    -> 70  (software)
- AUTH_ERROR                        -> 77  (no perm)
- CONFIG_ERROR / VERSION_ERROR      -> 78  (config)
- NETWORK / CONTAINER / ENVIRONMENT -> 1   (generic)
- SIGINT  -> 130
- SIGTERM -> 143

Both the central handleError boundary in withContext.ts and the shared
yargs .fail handler in yargsFailHandler.ts go through the same mapping,
so shell scripts and CI pipelines can branch on failure type without
parsing stderr.

assertNever in the switch guarantees a compile error if a new
CliError.Code variant is added without picking an exit code.
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@iamnamananand996 iamnamananand996 marked this pull request as ready for review May 21, 2026 15:10
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

iamnamananand996 and others added 2 commits May 21, 2026 15:16
Update three v2 ete tests that asserted on the old generic exit code 1 to match the new sysexits mapping introduced in this PR:

- `fern check` on an invalid fern.yml → 65 (EX_DATAERR for validation failures)
- `fern check --api <missing>` → 78 (EX_CONFIG for user-config failures)
- `fern sdk generate --target <not-configured>` → 78 (EX_CONFIG for user-config failures)

Caught by test-ete on the first CI run for this PR.
Add ExitCode.TempFail (75) for transient failures and remap NETWORK/CONTAINER/ENVIRONMENT errors to it so these cases are treated as retryable. TaskAbortSignal now carries an optional CliError.Code; failAndThrow forwards the code when throwing, and exitCodeForError maps the signal's code (or falls back to Generic if absent). Tests and changelog updated accordingly, and withContext signal handler was simplified to reference ExitCode directly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants