Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/quality-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,65 @@ jobs:
WT_VERSION=test PREFIX=/tmp/wt-install sh /tmp/install-patched.sh
/tmp/wt-install/bin/wt --help

- name: update prompt works on a real terminal
run: |
# The confirm prompt reads /dev/tty, so only a pty exercises it --
# and a pty is the one thing `bun test` cannot give it. This guards
# the regression where wrapping those fds in tty.ReadStream/
# tty.WriteStream made `new WriteStream(fd)` throw EINVAL (kqueue)
# under Bun on macOS; the throw was swallowed and every user with a
# perfectly good terminal got a silent "aborted." instead of a
# question.
mkdir -p /tmp/fakebin
cat > /tmp/fakebin/gh <<'GH'
#!/bin/sh
case "$*" in
*releases/latest*) printf 'wt-v99.0.0\n'; exit 0 ;;
esac
exit 0
GH
# `wt update` installs via `curl ... | sh`; a fake curl that emits a
# shell script keeps the whole path hermetic and off the network.
cat > /tmp/fakebin/curl <<'CURL'
#!/bin/sh
echo 'echo INSTALLED'
CURL
chmod +x /tmp/fakebin/gh /tmp/fakebin/curl
export PATH="/tmp/fakebin:$PATH"
export XDG_STATE_HOME=/tmp/wt-update-state
export WT_NO_UPDATE_CHECK=1

# Answer "n" so nothing is installed -- the assertion that matters is
# that the question reached the terminal at all.
out=$(printf 'n\n' | script -q /dev/null ./dist/wt-darwin-arm64 update || true)
printf '%s\n' "$out"

case "$out" in
*"install? [y/N]"*) ;;
*) echo "FAIL: prompt never reached the terminal" >&2; exit 1 ;;
esac
case "$out" in
*aborted*) ;;
*) echo "FAIL: answering n did not abort" >&2; exit 1 ;;
esac
case "$out" in
*"no terminal"*) echo "FAIL: claimed no terminal on a real pty" >&2; exit 1 ;;
esac

# --yes must not ask, and must not need a terminal to proceed.
out=$(./dist/wt-darwin-arm64 update --yes < /dev/null 2>&1 || true)
printf '%s\n' "$out"
case "$out" in
*"install? [y/N]"*) echo "FAIL: --yes still prompted" >&2; exit 1 ;;
esac
case "$out" in
*"no terminal"*) echo "FAIL: --yes demanded a terminal" >&2; exit 1 ;;
esac
case "$out" in
*INSTALLED*) ;;
*) echo "FAIL: --yes did not reach the installer" >&2; exit 1 ;;
esac

release-please-config:
runs-on: ubuntu-latest
steps:
Expand Down
6 changes: 4 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ Usage:
create the worktree if missing, then open it
wt root open the main (root) worktree in a new Ghostty tab
wt current print the path of the worktree containing $PWD
wt update check for a new release and install it
wt update [-y|--yes] check for a new release and install it
(--yes skips the confirm prompt; required
when there is no terminal to ask on)
wt --version print the installed wt version
wt --help show this help

Expand Down Expand Up @@ -379,7 +381,7 @@ async function main(): Promise<void> {
case "current":
return cmdCurrent(argv.slice(1), env);
case "update": {
const code = await cmdUpdate(env);
const code = await cmdUpdate(env, argv.slice(1));
process.exit(code);
}
default:
Expand Down
100 changes: 78 additions & 22 deletions src/update.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { readFileSync, writeFileSync, mkdirSync, openSync } from "node:fs";
import {
readFileSync,
writeFileSync,
mkdirSync,
openSync,
readSync,
writeSync,
closeSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { ReadStream, WriteStream } from "node:tty";
import { createInterface } from "node:readline/promises";
import { $ } from "bun";
import pkg from "../package.json";

Expand Down Expand Up @@ -148,30 +154,71 @@ export function maybeNag(env: UpdateEnv): void {
}
}

async function promptYes(): Promise<boolean> {
let input: ReadStream | null = null;
let output: WriteStream | null = null;
/**
* Three outcomes, not two. `wt update` runs plenty of places that have no
* controlling terminal — a Claude Code Bash call, a CI step, anything behind a
* pipe — and there `open("/dev/tty")` fails with ENXIO. Folding that into a
* plain `false` made an unanswerable prompt look exactly like a declined one:
* the version line, then "aborted.", exit 0, and no hint of which happened.
* Callers need the distinction so they can point at `--yes` instead.
*/
type Confirmation = "yes" | "no" | "no-tty";

/**
* Ask on the terminal, not on stdio, so the prompt still works when stdout is
* a pipe. We talk to the `/dev/tty` file descriptors directly rather than
* wrapping them in `tty.ReadStream`/`tty.WriteStream`: under Bun on macOS,
* `new WriteStream(fd)` on a tty fd throws `EINVAL: invalid argument, kqueue`,
* which the old catch-all turned into a silent "aborted." for every user with
* a perfectly good terminal. A blocking `readSync` is all a y/N question
* needs, and it has no such failure mode.
*
* The tty stays in canonical mode, so the kernel echoes what's typed and hands
* back one line per read; 64 bytes is far more than "yes" and anything longer
* is not an answer we'd accept anyway.
*/
function promptYes(): Confirmation {
let rfd: number | null = null;
let wfd: number | null = null;
try {
input = new ReadStream(openSync("/dev/tty", "r"));
output = new WriteStream(openSync("/dev/tty", "w"));
rfd = openSync("/dev/tty", "r");
wfd = openSync("/dev/tty", "w");
} catch {
input?.destroy();
output?.destroy();
return false;
if (rfd !== null) closeSync(rfd);
return "no-tty";
}
const rl = createInterface({ input, output });
try {
const answer = await rl.question("install? [y/N] ");
const trimmed = answer.trim().toLowerCase();
return trimmed === "y" || trimmed === "yes";
writeSync(wfd, "install? [y/N] ");
const buf = Buffer.alloc(64);
const n = readSync(rfd, buf, 0, buf.length, null);
const answer = buf.toString("utf8", 0, n).trim().toLowerCase();
return answer === "y" || answer === "yes" ? "yes" : "no";
} catch {
// The terminal opened but would not talk to us. We cannot claim the user
// declined, so report it the same way as having no terminal at all.
return "no-tty";
} finally {
rl.close();
input.destroy();
output.destroy();
closeSync(rfd);
closeSync(wfd);
}
}

export async function cmdUpdate(env: UpdateEnv): Promise<number> {
export async function cmdUpdate(
env: UpdateEnv,
args: string[] = [],
): Promise<number> {
let assumeYes = false;
for (const arg of args) {
if (arg === "-y" || arg === "--yes") {
assumeYes = true;
continue;
}
process.stderr.write(
`wt: unknown flag for update: ${arg} (try: wt --help)\n`,
);
return 1;
}

let tag: string;
try {
tag = await fetchLatestTag();
Expand All @@ -196,9 +243,18 @@ export async function cmdUpdate(env: UpdateEnv): Promise<number> {
return 0;
}
process.stdout.write(`wt v${VERSION} → v${latest}\n`);
if (!(await promptYes())) {
process.stdout.write("aborted.\n");
return 0;
if (!assumeYes) {
const answer = promptYes();
if (answer === "no-tty") {
process.stderr.write(
"wt: no terminal to confirm on — re-run with: wt update --yes\n",
);
return 1;
}
if (answer === "no") {
process.stdout.write("aborted.\n");
return 0;
}
}

const binDir = dirname(process.execPath);
Expand Down
50 changes: 49 additions & 1 deletion tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, afterEach } from "bun:test";
import { basename, join, resolve } from "node:path";
import { existsSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { existsSync, mkdtempSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";

Expand Down Expand Up @@ -890,4 +890,52 @@ describe("cli: version and update", () => {
expect(r.stderr).toContain("99.0.0");
expect(r.stderr).not.toContain("\x1b");
});

/**
* `runCli` spawns with piped stdio and no controlling terminal, which is
* exactly the shape that used to print "aborted." and exit 0 — a refusal
* indistinguishable from answering "n".
*/
function updateEnv(dir: string): Record<string, string> {
return {
PATH: `${dir}:/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin`,
HOME: process.env.HOME ?? "",
XDG_STATE_HOME: dir,
WT_NO_UPDATE_CHECK: "1",
};
}

it("update says so, and how to proceed, when there is no terminal", async () => {
const fake = fakeBin([]);
fakeGhBin(fake.dir, "wt-v99.0.0");
const r = await runCli(BIN, ["update"], { env: updateEnv(fake.dir) });
expect(r.stdout).toContain(`wt v${pkg.version} → v99.0.0`);
expect(r.stdout).not.toContain("aborted");
expect(r.stderr).toMatch(/no terminal/);
expect(r.stderr).toContain("--yes");
expect(r.exitCode).toBe(1);
});

it("update --yes installs without a prompt and records the new tag", async () => {
const fake = fakeBin([]);
fakeGhBin(fake.dir, "wt-v99.0.0");
// The installer is `curl -fsSL <url> | sh`; a fake curl that emits a shell
// script lets the real pipeline run without touching the network.
writeFileSync(join(fake.dir, "curl"), "#!/bin/sh\necho 'echo INSTALLED'\n");
spawnSync("chmod", ["755", join(fake.dir, "curl")]);
const r = await runCli(BIN, ["update", "--yes"], { env: updateEnv(fake.dir) });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("INSTALLED");
expect(r.stdout).not.toContain("aborted");
const cache = readFileSync(join(fake.dir, "wt", "update-check"), "utf8");
expect(cache).toContain("wt-v99.0.0");
});

it("update rejects an unknown flag", async () => {
const fake = fakeBin([]);
fakeGhBin(fake.dir, "wt-v99.0.0");
const r = await runCli(BIN, ["update", "--force"], { env: updateEnv(fake.dir) });
expect(r.exitCode).toBe(1);
expect(r.stderr).toMatch(/unknown flag/);
});
});
Loading