Repository navigation
fix(update): confirm prompt never appears — node:tty WriteStream throws under Bun - #59
Merged
Merged
Conversation
`wt update` confirms with a y/N prompt read from /dev/tty. Where there is no controlling terminal -- a Claude Code Bash call, a CI step, anything behind a pipe -- that open fails with ENXIO, and `promptYes` folded the failure into the same `false` it returns for a declined prompt. The result was a version line, "aborted.", and exit 0: an update that could never succeed, reported as one the user turned down, with no prompt ever shown and nothing on stderr to say why. `promptYes` now returns "yes" | "no" | "no-tty". A missing terminal prints "wt: no terminal to confirm on -- re-run with: wt update --yes" and exits 1; only a real "n" still prints "aborted." and exits 0. `wt update` takes -y/--yes to skip the prompt, which is what makes the error message actionable in the contexts that hit it. Unknown flags are rejected rather than ignored. The newer-version path had no test at all, which is how this shipped. Three now cover it: the no-tty refusal, --yes installing and recording the tag, and an unknown flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The confirm prompt never appeared in a real terminal either. `promptYes`
wrapped the /dev/tty fds in tty.ReadStream/tty.WriteStream, and under Bun
1.3.13 on macOS `new WriteStream(fd)` on a tty fd throws:
EINVAL: invalid argument, kqueue
That throw lands inside the same try block that guards the open, so it
was caught and reported as a declined prompt: the version line, then
"aborted.", for everyone with a perfectly good terminal. ReadStream
constructs fine -- only WriteStream fails -- so the question was never
written, which is why no prompt was ever seen.
Introduced by 217550f, which moved the prompt off stdio onto /dev/tty so
it would survive a piped stdout. That goal is preserved: the fds are
still /dev/tty, but writeSync/readSync talk to them directly. The tty
stays in canonical mode, so the kernel echoes what is typed and returns
one line per read, which is all a y/N question needs, and neither call
has the kqueue failure mode.
`bun test` cannot allocate a pty, so the regression is guarded where one
exists: the macOS CI job now drives the real binary's `wt update` under
script(1), answering n, and fails if the question never reaches the
terminal. It also checks --yes neither prompts nor demands a terminal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
johnpangalos
added a commit
that referenced
this pull request
Sep 20, 2026
main (#59) appended an "update prompt works on a real terminal" step to the e2e-macos job, immediately above the release-please-config job this branch deletes. A three-way merge could not tell the appended step from the deleted job, so the tail of quality-check.yml conflicted. Resolved by keeping main's new step and still dropping the job. The parsed YAML has three jobs -- test (5 steps), distribution (6) and e2e-macos (5, one more than before the merge) -- a top-level permissions: contents: read, and no secrets. reference anywhere. The diff against main is once again the workflow file alone: the permissions block added, the release-please-config job removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
johnpangalos
added a commit
that referenced
this pull request
Sep 20, 2026
…rity Conflicts were in src/update.ts only, and both sides were additive: - Imports: main's #59 fix reads the tty through openSync/readSync/ writeSync/closeSync; this branch added chmodSync/realpathSync/ renameSync/rmSync and randomBytes for the staged, verified install. Kept the union of both. - cmdUpdate: main gained an args parameter with -y/--yes parsing; this branch replaced the `curl | sh` install with in-process download, SHA256 verification, and an atomic rename. Kept main's signature and flag parsing, and this branch's installRelease call site, which had already merged cleanly in the function body. INSTALL_URL is gone with the curl pipeline it served. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
wt updatecould not be confirmed anywhere. In a normal interactive terminal:No question is ever asked, and the exit code is 0.
promptYesopened the/dev/ttyfile descriptors and wrapped them intty.ReadStream/tty.WriteStream. Under Bun 1.3.13 on macOS,new WriteStream(fd)on a tty fd throws:ReadStreamconstructs fine; onlyWriteStreamfails. That throw landed inside the sametryblock that guards theopen, whosecatchreturnedfalse— the samefalsethat means "the user typed n". So the question was never written, and its absence was reported as a refusal.The same catch-all also swallowed the legitimate no-terminal case (
ENXIO, under a pipe or a CI step), making a genuinely unanswerable prompt indistinguishable from a declined one.Introduced by 217550f, which moved the prompt off stdio onto
/dev/ttyso it would survive a piped stdout.The fix
That goal is kept — the fds are still
/dev/tty, so a piped stdout is still fine — butwriteSync/readSynctalk to them directly instead of throughnode:tty. The terminal stays in canonical mode, so the kernel echoes what is typed and returns one line per read, which is all a y/N question needs, and neither call has the kqueue failure mode.node:ttyandnode:readline/promisesare gone fromupdate.ts.promptYesnow returns"yes" | "no" | "no-tty"rather than a boolean, so a real absence of a terminal is reported as itself:and exits 1. Only a real "n" still prints
aborted.and exits 0.wt updategains-y/--yesto skip the prompt, which is what makes that message actionable. Unknown flags are rejected rather than silently ignored.Tests
The newer-version path had no test at all, which is how this shipped: the existing
wt updatetests cover only "up to date" and "gh missing", both of which return before the prompt.bun testcannot allocate a pty, so it cannot reach the failing line — which is exactly why the bug survived. The regression is therefore guarded where a pty does exist: the macOS CI job drives the real compiled binary underscript(1). From the run on this branch:The question reaches the terminal,
nis read from it, and the abort is a real answer rather than a swallowed exception. The step also asserts--yesneither prompts nor demands a terminal:Three unit tests cover what
bun testcan reach: the no-tty refusal,--yesinstalling and recording the tag, and an unknown flag.🤖 Generated with Claude Code