Fix Windows libuv/TTY crashes in create, branch create, config export/apply
Summary
Investigated whether #306, #300, #286, #270 (all Windows-only libuv/TTY crash reports) share one root cause. They trace to two distinct mechanisms, not one:
1. TTY-capability bypass → fixes #270, #286
src/commands/config/export.ts and src/commands/config/apply.ts call @clack/prompts' confirm() directly instead of the isInteractive-guarded wrapper already defined in src/lib/prompts.ts. Clack's raw confirm() always tries to init a TTY on stdin regardless of whether stdout/stdin is actually a console handle, which is exactly what produces #270's Error: TTY initialization failed: uv_tty_init returned EINVAL.
src/commands/branch/create.ts's spinner has the same category of gap — it's guarded by !json only, not !json && isInteractive, unlike the identical pattern already established in ai/setup.ts, posthog/setup.ts, and webscraper/apify/connect.ts. That mismatch matches #286 (spinner writes into a non-interactive Windows terminal and eventually EPIPEs).
Fix: route export.ts/apply.ts's confirmation prompt through lib/prompts.js, and add the same isInteractive guard to branch/create.ts's spinner — both changes adopt a pattern that already exists and is tested elsewhere in this codebase.
2. Forced process.exit() racing a still-open handle → fixes #306, #300
create.tscallsprocess.exit(0)immediately after a cancelled prompt. In the non-interactive fallback (lib/prompts.ts'snonTtyText/LineReader), a cancel means stdin just hit EOF and the underlyingreadline.Interface's own'close'event only just fired. Callingprocess.exit()synchronously the instant after that is the documented class of Windows libuv race insrc/win/async.c— forcing an immediate handle teardown while one is already mid-close.- Every command's
handleError()(the single shared error path) runs through PostHog analytics first.src/lib/analytics.tsconstructs thePostHogclient withoutenableLocalEvaluation: false. I pulledposthog-node@5.28.9's source to confirm: with local evaluation enabled (the default), theFeatureFlagsPollerconstructor callsloadFeatureFlags()unconditionally and unawaited, immediately firing a flag-definitions fetch and scheduling a recurringsetTimeoutpoll — entirely pointless here, since nothing insrc/ever callsgetFeatureFlag/isFeatureEnabled.shutdownAnalytics()→client.shutdown()→stopPoller()only doesclearTimeout(this.poller), which cancels the next scheduled poll — it does not cancel an already in-flight fetch.handleError()'sprocess.exit()fires right after thatshutdown()resolves, so an in-flight PostHog socket can still be open when the process is forced to exit. This matches #300'sAssertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94and the issue's own triage note about "the catch-path PostHog flush."
Fix: disable PostHog's local evaluation (one line, in the single shared client constructor — removes the dead poller/fetch entirely), and replace create.ts's cancel-time process.exit(0) with process.exitCode = 0; return; so Node drains naturally instead of forcing a hard stop (Node's own docs recommend exitCode over exit() for exactly this class of issue).
Confidence
- High on fix (1): source-verified, exact error-message match (
uv_tty_init returned EINVAL), minimal diff adopting an existing in-repo pattern. - Medium-high on fix (2): I don't have a Windows box to reproduce the libuv assertion directly, but both changes are independently correct on their own merits (the PostHog poller is genuinely dead weight;
process.exitCodeoverprocess.exit()is the documented-safe pattern for this exact race), and they directly target the mechanisms each issue's own repro describes.
Test plan
-
npx tsc --noEmit— no new errors vs. the pre-fix baseline (diffed explicitly) -
npx eslint src/— clean -
npx vitest run— 835 passed, 5 pre-existing failures (Windows path-separator issues inflyctl.test.ts,migrations.test.ts,local/checkout.test.ts— none of these files were touched by this PR) - Added regression tests for
config export/config apply's overwrite/apply confirmation prompt — previously uncovered, since every existing test passes--force/--yes/--jsonand skips the prompt entirely. New tests assert the command callslib/prompts.js'sconfirm()(not raw@clack/prompts) and that declining leaves state untouched. - Updated
branch/create.test.tsto mockisInteractive: true, since those tests specifically assert on the spinner-driven interactive path.
🤖 Generated with Claude Code
Summary by cubic
Fixes four Windows-only crashes in create, branch create, config export, and config apply (#306, #300, #286, #270). They came from two sources: prompts and spinners that bypassed the CLI's isInteractive TTY guard, and process.exit() racing a still-open libuv handle.
Bug Fixes
- Route
config exportandconfig apply's confirmation prompts throughlib/prompts.js's guardedconfirm()instead of raw@clack/prompts, which tried to init a TTY on stdin even without a console handle and crashed withuv_tty_init returned EINVAL. - Guard
branch create's spinner withisInteractive(previously only!json), matching the pattern used elsewhere in the CLI. - Disable PostHog local evaluation (
enableLocalEvaluation: false) so the unused feature-flag poller never starts, leaving no in-flight socket forhandleError()'sprocess.exit()to race. - Replace cancel-time
process.exit(0)increate.tswithprocess.exitCode = 0; return;so Node drains naturally instead of forcing a hard stop. - Add regression tests for the export/apply confirmation routing (previously uncovered, since existing tests always passed
--force/--yes/--json) and mockisInteractive: trueinbranch/create.test.tsfor its spinner path.