Skip to content

fix: exit with the code a command set on process.exitCode instead of a hard 0 - #77

Merged
justinhelmer merged 1 commit into
mainfrom
claude/cli-75-exit-code
Aug 25, 2026
Merged

fix: exit with the code a command set on process.exitCode instead of a hard 0#77
justinhelmer merged 1 commit into
mainfrom
claude/cli-75-exit-code

Conversation

@justinhelmer

Copy link
Copy Markdown
Contributor

When integration connect or cloud connect timed out waiting for the browser, the CLI said so but still exited 0 and reported success in telemetry. This makes the exit code and the telemetry event carry the code the command actually set.

What & why

Fixes cli#75, found on the first fresh-user install check for nominal#1511: polylane integration connect --type github printed Timed out waiting — the connection has not shown up yet. and exited 0, so the installer recorded connect.github.accepted for a connect that never happened.

Both connect commands signal the timeout with process.exitCode = ExitCode.GENERAL and return (src/commands/integration/connect.ts, src/commands/cloud/connect.ts). src/main.ts then ran process.exit(0) unconditionally after command.execute(...), overriding it, and built the telemetry event with a hard-coded ExitCode.SUCCESS. Every soft failure that relies on process.exitCode became a success at the process boundary; only thrown CLIErrors ever exited non-zero.

Changes

  • src/exit-code.ts: settledExitCode()process.exitCode when a command set a number, else 0.
  • src/main.ts: after command.execute, read the settled code once, report it in the telemetry buildEvent (instead of ExitCode.SUCCESS), and process.exit(exitCode).
  • test/exit-code.test.ts: unit tests for settledExitCode, and a subprocess test that runs src/main.ts config show with process.exitCode preset by a preload (test/helpers/preset-exit-code.ts) — the exact state the connect commands leave — asserting the process exits with that code. Fails on main (expected 1, actual 0), passes here.

Decisions

  • Honor process.exitCode rather than throwing on timeout. Both connect commands already express "finished, but not successfully" through process.exitCode; throwing a CLIError would route a non-error through handleError and print it as an error. The bug was the boundary, not the signal.
  • Telemetry reports the settled code with error: null. A timeout is not an exception; the event keeps the success shape but with the truthful exitCode, so funnel queries can separate "connect finished" from "connect timed out" without a new category.
  • The installer no longer depends on thispolylanedotcom#191 re-checks the integration list after each connect. This PR makes the exit code truthful for every other caller: scripts, agents, and CI chaining on $?.
  • Preload-based subprocess test instead of refactoring run() for importability: main.ts executes on import, and a real node --import tsx src/main.ts run is the only thing that proves the process boundary.

Validation

# Criterion Receipt
1 settledExitCode() is 0 with nothing flagged, and the flagged number otherwise test/exit-code.test.ts unit cases ✅
2 A completed command with process.exitCode = 1 exits the process with 1 subprocess case ✅; same test on main fails with expected 1, actual 0 (run with src/main.ts stashed)
3 A completed command with nothing flagged still exits 0 subprocess case ✅
4 Telemetry event carries the settled code buildEvent({ exitCode }) wired from the same value (src/main.ts); covered by the existing buildEvent tests for the field shape
5 Suite / typecheck / lint / build as CI runs them npm run test 368/368 ✅ · npm run typecheck ✅ · npm run lint ✅ · npm run build
6 Human-gated, after release: polylane integration connect --type github, let the browser wait time out → echo $? prints 1 ⏳ next fresh-install pass on nominal#1511

🤖 Generated with Claude Code

…a hard 0

`integration connect` and `cloud connect` flag a browser-wait timeout with
`process.exitCode = ExitCode.GENERAL` and return, but main.ts followed every
completed command with `process.exit(0)`, discarding it — the CLI printed
"Timed out waiting" and exited 0, and the telemetry event reported SUCCESS.
The installer recorded a connect.github.accepted for a connect that never
happened (nominal#1511 N9).

main.ts now exits with the settled code and reports it in the telemetry
event. Tests: unit on settledExitCode, plus a subprocess run of main.ts with
process.exitCode preset (the shape the connect commands leave behind).

Closes #75

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM: correctly propagates a command's process.exitCode through main.ts, reproduced the bug on pre-fix main.ts and confirmed the fix; no regressions found; full local suite and CI green.

Verification performed

  • Checked out pr-77 (head d8951e1565d1c5f8c599d3eee5fab8bd02fd4027), confirmed via git rev-parse HEAD — exact match to this PR's head.sha.
  • True merge-base (git merge-base pr-77 origin/main) = bd40cf75def6de3df2a70e1fdee79dfb13e83c53, which equals the PR's stated base.sha (no staleness). git diff --stat against that merge-base matches the API's reported additions/deletions/changed_files exactly (64/2/4; src/exit-code.ts, src/main.ts, test/exit-code.test.ts, test/helpers/preset-exit-code.ts).

Code review

  • Re-read src/main.ts in full and grepped the whole src/ tree for every process.exit( and process.exitCode = call site. The only two places that ever set process.exitCode are integration/connect.ts:873 and cloud/connect.ts:844 (both pre-existing, untouched by this diff) — no double-set / race risk anywhere.
  • Important detail the checklist for this fix should call out explicitly: process.exit() was not removed here, only its argument changed (hardcoded 0settledExitCode()). main.ts still forces process exit unconditionally after command.execute(), so the "event loop won't drain / process hangs" risk that a naive fix (just deleting the process.exit call) would introduce does not apply to this implementation.
  • Telemetry dispatch() is still awaited before process.exit(exitCode) and has its own bounded abort timeout (telemetry/dispatch.ts), so no hang risk there either.
  • Manually reproduced the underlying bug end-to-end: with src/main.ts reverted to the merge-base and a preload setting process.exitCode = 1 before config show runs, the process exits 0 (the bug). With the PR's main.ts, the same setup exits 1. This matches the PR body's claimed repro exactly.
  • Test coverage genuinely exercises the process boundary, not just the isolated helper: test/exit-code.test.ts's subprocess cases spawn a real node --import tsx src/main.ts config show with process.exitCode preset via test/helpers/preset-exit-code.ts, asserting the actual exit code — I ran this manually too and it behaves as claimed.
  • The two pre-existing process.exit(0) calls in commands/helpers.ts:307 and commands/integration/slack-channels.ts:170 are SIGINT handlers for "stop waiting for the browser" — they short-circuit the whole process before command.execute() would return, so they don't interact with the new settledExitCode() logic.
  • Non-blocking, out of scope for this diff: cloud/connect.ts's AWS CloudFormation background-wait path (awsWait.settle() around lines 843-850) can return 'pending' without ever setting process.exitCode, e.g. if the user picks "Done" while the stack is still deploying — same class of "soft failure exits 0" issue this PR fixes for the timeout case, just not covered by it. Might be worth a follow-up issue, but it's pre-existing code untouched by this PR.

Local checks (Node 22, matching one of the 3 CI matrix legs)

  • npm ci clean
  • npm run codegennpm run typecheck: pass
  • npm run lint: pass (no output)
  • npm run test: 368/368 pass, 0 fail, 0 skip
  • npm run build: pass, dist/polylane.mjs (522.1 KB) written — validates the PR body's build claim too

CI (fresh query against head SHA d8951e1565d1c5f8c599d3eee5fab8bd02fd4027)

  • check-runs: 3 total — checks / Test & typecheck on 20.x, 22.x, 24.x — all completed / success
  • Combined status API returned total_count: 0 (this repo doesn't use the legacy statuses API; check-runs is authoritative and fully green)

Author note: PR authored by justinhelmer (human), not a bot — no self-approval pattern applies here.


Generated by Claude Code

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: Claude reviewed this PR and posted an LGTM verdict (see its review). A repo admin enabled this via the auto-approve workflow.


Generated by Claude Code

@justinhelmer
justinhelmer merged commit 4ad930a into main Aug 25, 2026
4 checks passed
@justinhelmer
justinhelmer deleted the claude/cli-75-exit-code branch August 25, 2026 19:21
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.

integration connect / cloud connect exit 0 on browser-wait timeout: main.ts process.exit(0) discards process.exitCode

1 participant