Skip to content

fix(cli): add top-level error handler for command rejections - #56

Merged
ManningWorks merged 1 commit into
mainfrom
wt/t_2068a233
Oct 18, 2026
Merged

ManningWorks merged 1 commit into
mainfrom
wt/t_2068a233

Conversation

@ManningWorks

Copy link
Copy Markdown
Owner

Summary

Fixes #52. packages/core/src/cli.ts called program.parse() (which does not await async action handlers) with no top-level error handling, so any command rejection surfaced as an unhandled rejection — Node printed a raw internal stack trace and exited 1.

This wraps program.parseAsync() in a single top-level try/catch that prints a chalk-formatted ✖ error (matching the existing message style) and sets a non-zero exit code via process.exitCode (not process.exit, since an immediate exit can cut off piped output). The one catch covers all five commands and any future ones.

Why not the alternatives

  • No process.on('unhandledRejection') — it would mask genuine future bugs, and the handler would still have to exit or the process keeps running.
  • No per-action .catch() chains — a single catch covers every command and stays correct as commands are added.
  • Commander usage errors need no special handling — under parseAsync, an unknown option prints error: unknown option ... and calls process.exit(1) internally, so it never reaches this catch.

What now formats instead of stack-tracing

Rejections that escaped as raw stack traces now print the formatted error:

  • add theme-minimal in a dir without package.json (ENOENT)
  • add theme-minimal with styles/ present as a regular file (ENOTDIR)
  • init hitting a non-ENOENT error on the config existence check (e.g. ELOOP)

The intentional non-ENOENT rethrows in add.ts (theme existence check) and init.ts (config existence check) are unchanged — they still surface; the top-level handler now formats them.

Test plan

  • New vitest coverage (packages/core/src/__tests__/cli.test.ts) drives the real add path to a genuine ENOENT rejection and asserts the formatted ✖ message, a non-zero process.exitCode, and that process.exit is not used.
  • pnpm install && pnpm build && pnpm typecheck && pnpm lint && pnpm test — all green (1053 tests).
  • Three crash cases reproduced before/after (below); unknown-option behavior unchanged.

Before (raw stack, exit 1)

$ projex add theme-minimal        # dir without package.json
Error: ENOENT: no such file or directory, open '.../package.json'
    at async ensureProjexInstalled (.../dist/commands/add.js:184:21)
    at async addTheme (.../dist/commands/add.js:240:5)
    ...
Node.js v26.7.0

After (formatted, exit 1)

$ projex add theme-minimal        # dir without package.json
🎨 Adding theme-minimal...
✖ Command failed
  ENOENT: no such file or directory, open '.../package.json'

Unknown option (unchanged — commander default, exit 1, no double-print)

$ projex add theme-minimal --nope
error: unknown option '--nope'
(Did you mean --note?)

Closes #52

Commander's `.parse()` does not await async action handlers, so a
handler rejection (e.g. `add`'s ensureProjexInstalled hitting ENOENT,
or the non-ENOENT rethrows in the theme/config existence checks)
escaped as an unhandled rejection: Node printed a raw internal stack
trace and exited 1.

Wrap `program.parseAsync()` in a single top-level try/catch that prints
a chalk-formatted `✖` error (matching the existing message style) and
sets a non-zero exit code via process.exitCode rather than
process.exit, since an immediate exit may prevent piped output from
fully flushing. The one catch covers all five commands and future
ones; commander usage errors (unknown option) still go through
commander's own handler and never reach it.

Entry point is now detected via a realpath comparison so `main(argv)`
can be imported and driven by the test suite without auto-running
parse on import.

Closes #52
@vercel

vercel Bot commented Oct 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
projex-docs Ready Ready Preview Oct 18, 2026 8:48pm UTC

@coderabbitai

coderabbitai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 00a27cfc-97e5-4ec4-8dfb-3b45a63c1d9a
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ManningWorks

Copy link
Copy Markdown
Owner Author

Review: APPROVE

Independent review of ada80ff vs origin/main (e53d066) — 1 commit, 3 files, +115/−3. Both axes walked: Standards (repo conventions + coding baseline) and Spec (issue #52). Zero findings at any severity (no CRITICAL/HIGH/MEDIUM/LOW).

Acceptance criteria — verified on the branch (not taken on report)

  1. Three crash cases → formatted error, no stack trace, exit 1 — reproduced live against the built dist/cli.js:
    • add theme-minimal, dir without package.json (ENOENT): ✖ Command failed + gray ENOENT: no such file or directory, open '.../package.json', exit 1, 0 stack lines
    • add theme-minimal, styles as a regular file (ENOTDIR): formatted, exit 1, 0 stack lines
    • init with projex.config.ts self-symlink (ELOOP on access(F_OK)): formatted, exit 1, 0 stack lines
  2. Unknown option unchanged — add theme-minimal --nope → error: unknown option '--nope' / (Did you mean --note?), exit 1, zero Command failed lines (no double-print).
  3. New vitest coverage — packages/core/src/__tests__/cli.test.ts runs green in isolation (1/1); it drives the REAL add path to a genuine ENOENT (no command mocks), asserts the ✖ message, process.exitCode === 1, and that process.exit is never called.
  4. Full verifier green — pnpm install && pnpm build && pnpm typecheck && pnpm lint && pnpm test: build ok, typecheck ok, lint ok under --max-warnings 0, 65 files / 1053 tests passed.
  5. No dev servers run.
  6. PR body carries Closes #52; issue add/init: theme-path existence-check rethrows surface as unhandled rejections at CLI level #52 was OPEN at filing (still open, closedAt: null).

Standards

  • Error formatting is byte-for-byte the existing house style — identical two-line pattern to add.ts:170-172 (red ✖ title + gray ${error instanceof Error ? error.message : 'Unknown error'}).
  • Comments carry the why (parseAsync vs parse, exitCode vs exit truncation on piped POSIX output) rather than restating the code.
  • Entry-point guard is robust where it counts: symlinked bins resolve via realpathSync on both sides (verified: a .bin-style symlink runs the CLI; --version → 1.6.1), missing process.argv[1] (REPL/-e) returns false, and a deleted file falls back to the raw path. Verified the guard is discriminating: importing dist/cli.js from another entry point with argv add theme-minimal does not auto-run (pre-PR code would have fired the command at import time). No in-repo importer relies on the old import-time side effect (only the new test imports cli.js, for main).
  • eslint delta is the minimum required (src/cli.ts added to the no-console: off block that src/commands/** already uses) — required because lint runs with --max-warnings 0.
  • Test follows existing vitest conventions; chdir is restored in afterEach; temp dir cleaned up.

Spec

  • Issue add/init: theme-path existence-check rethrows surface as unhandled rejections at CLI level #52 asked for a mechanism (top-level unhandledRejection or per-action .catch) that prints a chalk-formatted error and exits non-zero. The implemented top-level try/catch around program.parseAsync() is a justified superset of both — one boundary covering all five commands (init/add/edit/remove/list) and future ones, without the masking risk of a global unhandledRejection handler. The PR body documents the rejected alternatives with correct reasoning.
  • All three rejections named in the issue/PR now format instead of stack-trace (verified above); the intentional non-ENOENT rethrows in add.ts/init.ts are untouched (diff touches exactly the three stated files).
  • PR description matches HEAD exactly — no drift.

Scope guard: 1 commit, 3 files, all on-purpose. No scope creep.

Verdict: APPROVE — ready to merge.

@ManningWorks
ManningWorks merged commit bf2ebf0 into main Oct 18, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
Preview — ada80ff9 Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

add/init: theme-path existence-check rethrows surface as unhandled rejections at CLI level

1 participant