Skip to content

fix(update): confirm prompt never appears — node:tty WriteStream throws under Bun - #59

Merged
johnpangalos merged 2 commits into
mainfrom
fix/update-no-tty
Sep 20, 2026
Merged

johnpangalos merged 2 commits into
mainfrom
fix/update-no-tty

Conversation

@johnpangalos

@johnpangalos johnpangalos commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

The bug

wt update could not be confirmed anywhere. In a normal interactive terminal:

wt update
wt v0.4.0 → v0.5.0
aborted.

No question is ever asked, and the exit code is 0.

promptYes opened the /dev/tty file descriptors and wrapped them in tty.ReadStream / tty.WriteStream. Under Bun 1.3.13 on macOS, new WriteStream(fd) on a tty fd throws:

EINVAL: invalid argument, kqueue
      at WriteStream (internal:fs/streams:244:58)
      at new WriteStream (node:tty:42:36)

ReadStream constructs fine; only WriteStream fails. That throw landed inside the same try block that guards the open, whose catch returned false — the same false that 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/tty so 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 — but writeSync/readSync talk to them directly instead of through node: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:tty and node:readline/promises are gone from update.ts.

promptYes now returns "yes" | "no" | "no-tty" rather than a boolean, so a real absence of a terminal is reported as itself:

wt update
wt v0.5.0 → v99.0.0
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 gains -y/--yes to 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 update tests cover only "up to date" and "gh missing", both of which return before the prompt.

bun test cannot 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 under script(1). From the run on this branch:

wt v0.5.0 → v99.0.0
install? [y/N] aborted.

The question reaches the terminal, n is read from it, and the abort is a real answer rather than a swallowed exception. The step also asserts --yes neither prompts nor demands a terminal:

wt v0.5.0 → v99.0.0
INSTALLED

Three unit tests cover what bun test can reach: the no-tty refusal, --yes installing and recording the tag, and an unknown flag.

🤖 Generated with Claude Code

johnpangalos and others added 2 commits September 20, 2026 15:58
`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 johnpangalos changed the title fix(update): distinguish "no terminal" from a declined prompt fix(update): confirm prompt never appears — node:tty WriteStream throws under Bun Sep 20, 2026
@johnpangalos
johnpangalos merged commit 6bcfdd2 into main Sep 20, 2026
4 checks passed
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>
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.

1 participant