Skip to content

Commit 69f2a73

Browse files
authored
fix: repair rb update on Windows and wire the background update check (#37)
* fix: extract rb update archive to temp dir, not over the running binary rb update extracted the release archive straight into the binary's own directory. The payload (rb / rb.exe) shares the running executable's name, so Expand-Archive -Force tried to overwrite the live, locked exe. On Windows its internal Remove-Item fails with "Access to the path is denied", aborting the update. The extractedPath !== binPath guard was then false (both resolved to the same rb.exe), so the .new staging rename was skipped and the run died with "extracted binary not found at expected location". Extract into an isolated .rb-update-<ts> temp dir instead, then stage the result as .new and rename-swap into place. Renaming a running binary is permitted on Windows; deleting it is not. The existing .bak rollback and post-swap --version verification are unchanged. The temp dir is always removed via finally. Verified end-to-end on Windows: a pinned 1.2.0 standalone self-replaced to the live 1.3.0 release, kept rb.exe.bak for rollback, left no temp dirs, and re-ran as a clean no-op. * fix: wire the background update check so the update notice works The "Update available" notice on the welcome screen read a cache that nothing ever populated: checkForUpdate() was only ever called from tests, so the cache stayed empty and getPendingNotification() always returned null. The notice could never fire on its own. A naive inline call cannot fix this — short-lived commands (the welcome screen exits immediately) terminate long before the GitHub round-trip completes, so the fetch would be killed mid-flight. Instead spawn a detached, unref'd `rb __update-check` process on launch that outlives the command and warms the cache for the next run (the same approach gh and update-notifier use). - add isRefreshDue(): cheap sync gate (honors skip rules + 24h TTL) so a fresh cache costs zero subprocesses - add the hidden __update-check entrypoint in main.ts; gate it before routing - spawn the detached refresh only in standalone builds (IS_BIN); dev mode's execPath is bun, not rb - skip entirely in CI and under --quiet/--json/--url-only (existing shouldSkip) Verified end-to-end on Windows: a pinned 1.2.0 standalone spawned the detached check, the cache filled with 1.3.0, and the next run rendered "Update available: 1.2.0 -> 1.3.0". --quiet produced no output and no spawn.
1 parent 637ee44 commit 69f2a73

4 files changed

Lines changed: 168 additions & 72 deletions

File tree

src/__tests__/update-check.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,40 @@ describe("update-check", () => {
151151
expect(cache.latest).toBe("1.1.0");
152152
});
153153

154+
it("isRefreshDue: true when no cache exists", async () => {
155+
const { isRefreshDue } = await import("../lib/update-check.js");
156+
expect(isRefreshDue()).toBe(true);
157+
});
158+
159+
it("isRefreshDue: false when cache is within TTL", async () => {
160+
const cachePath = join(tempDir, "update-check.json");
161+
mkdirSync(dirname(cachePath), { recursive: true });
162+
writeFileSync(
163+
cachePath,
164+
JSON.stringify({ latest: "1.1.0", checkedAt: Date.now(), ttl: 24 * 60 * 60 * 1000 })
165+
);
166+
const { isRefreshDue } = await import("../lib/update-check.js");
167+
expect(isRefreshDue()).toBe(false);
168+
});
169+
170+
it("isRefreshDue: true when cache is older than TTL", async () => {
171+
const cachePath = join(tempDir, "update-check.json");
172+
mkdirSync(dirname(cachePath), { recursive: true });
173+
const ttl = 24 * 60 * 60 * 1000;
174+
writeFileSync(
175+
cachePath,
176+
JSON.stringify({ latest: "1.1.0", checkedAt: Date.now() - ttl - 1000, ttl })
177+
);
178+
const { isRefreshDue } = await import("../lib/update-check.js");
179+
expect(isRefreshDue()).toBe(true);
180+
});
181+
182+
it("isRefreshDue: false when a skip rule applies (CI)", async () => {
183+
process.env.CI = "true";
184+
const { isRefreshDue } = await import("../lib/update-check.js");
185+
expect(isRefreshDue()).toBe(false);
186+
});
187+
154188
it("does not write cache when no CLI release found", async () => {
155189
const fetchMock = mock(async () =>
156190
new Response(JSON.stringify([

src/commands/update.ts

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* `rb update` — Self-replace for standalone binary, print install instructions for dev mode.
33
* Detects install method via compile-time IS_STANDALONE define.
44
*/
5-
import { writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, unlinkSync } from "node:fs";
5+
import { writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, unlinkSync, rmSync } from "node:fs";
66
import { join, dirname } from "node:path";
77
import { homedir } from "node:os";
88
import { spawnSync } from "node:child_process";
@@ -139,50 +139,50 @@ async function updateBinary(latest: string): Promise<void> {
139139
}
140140
console.log(`Checksum verified: ${actualHash}`);
141141

142-
// Extract archive to temp path alongside current binary
142+
// Extract into an isolated temp dir, NEVER straight into binDir. The archive's
143+
// payload (`rb` / `rb.exe`) shares the running binary's name, so extracting
144+
// into binDir forces the archiver to overwrite the live executable — which
145+
// Windows forbids (the running image is locked; `Expand-Archive -Force`'s
146+
// internal Remove-Item fails with "Access to the path ... is denied"). Extract
147+
// aside, then rename-swap below: renaming a running binary IS allowed on
148+
// Windows, deleting it is not.
143149
const binDir = dirname(binPath);
144150
const tmpBinPath = `${binPath}.new`;
145151
const bakBinPath = `${binPath}.bak`;
152+
const archiveBase = ext === ".tar.gz" ? "rb" : "rb.exe";
153+
const extractDir = join(binDir, `.rb-update-${Date.now()}`);
154+
mkdirSync(extractDir, { recursive: true });
146155

147-
if (ext === ".tar.gz") {
148-
const tmpArchivePath = join(binDir, `update-${Date.now()}.tar.gz`);
149-
writeFileSync(tmpArchivePath, archive);
150-
const tar = spawnSync("tar", ["-xzf", tmpArchivePath, "-C", binDir], { stdio: "inherit" });
151-
try {
152-
unlinkSync(tmpArchivePath);
153-
} catch {
154-
// non-fatal
155-
}
156-
if (tar.status !== 0) throw new Error("tar extraction failed");
157-
const extractedPath = join(binDir, "rb");
158-
if (existsSync(extractedPath) && extractedPath !== binPath) {
159-
renameSync(extractedPath, tmpBinPath);
160-
}
161-
} else {
162-
const tmpArchivePath = join(binDir, `update-${Date.now()}.zip`);
156+
try {
157+
const tmpArchivePath = join(extractDir, `archive${ext}`);
163158
writeFileSync(tmpArchivePath, archive);
164-
const expand = spawnSync(
165-
"powershell",
166-
[
167-
"-Command",
168-
`Expand-Archive -Path '${tmpArchivePath}' -DestinationPath '${binDir}' -Force`,
169-
],
170-
{ stdio: "inherit" }
171-
);
172-
try {
173-
unlinkSync(tmpArchivePath);
174-
} catch {
175-
// non-fatal
176-
}
177-
if (expand.status !== 0) throw new Error("Expand-Archive failed");
178-
const extractedPath = join(binDir, "rb.exe");
179-
if (existsSync(extractedPath) && extractedPath !== binPath) {
180-
renameSync(extractedPath, tmpBinPath);
159+
160+
if (ext === ".tar.gz") {
161+
const tar = spawnSync("tar", ["-xzf", tmpArchivePath, "-C", extractDir], { stdio: "inherit" });
162+
if (tar.status !== 0) throw new Error("tar extraction failed");
163+
} else {
164+
const expand = spawnSync(
165+
"powershell",
166+
[
167+
"-NoProfile",
168+
"-Command",
169+
`Expand-Archive -Path '${tmpArchivePath}' -DestinationPath '${extractDir}' -Force`,
170+
],
171+
{ stdio: "inherit" }
172+
);
173+
if (expand.status !== 0) throw new Error("Expand-Archive failed");
181174
}
182-
}
183175

184-
if (!existsSync(tmpBinPath)) {
185-
throw new Error("extracted binary not found at expected location");
176+
const extractedPath = join(extractDir, archiveBase);
177+
if (!existsSync(extractedPath)) {
178+
throw new Error("extracted binary not found at expected location");
179+
}
180+
// Stage the new binary next to the live one as `.new` (same volume → the
181+
// swap below is atomic). Clear any stale `.new` from an aborted prior run.
182+
if (existsSync(tmpBinPath)) unlinkSync(tmpBinPath);
183+
renameSync(extractedPath, tmpBinPath);
184+
} finally {
185+
rmSync(extractDir, { recursive: true, force: true });
186186
}
187187

188188
chmodSync(tmpBinPath, 0o755);

src/lib/update-check.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ function versionGt(a: string, b: string): boolean {
7777
return false;
7878
}
7979

80+
/**
81+
* Cheap synchronous gate: is a background refresh warranted right now?
82+
* Honors the same skip rules as the check itself, plus the 24h TTL. Used by the
83+
* launcher to decide whether to spawn the detached refresh process at all — so a
84+
* fresh cache costs zero subprocesses. No jitter here: jitter exists to spread
85+
* the network call across the fleet on release day, not to gate a local spawn.
86+
*/
87+
export function isRefreshDue(): boolean {
88+
if (shouldSkip()) return false;
89+
const cache = readCache();
90+
if (!cache) return true;
91+
return Date.now() - cache.checkedAt >= cache.ttl;
92+
}
93+
8094
/**
8195
* Non-blocking background check. Updates cache file if network succeeds.
8296
* Uses injected fetch so tests can mock it.

src/main.ts

Lines changed: 82 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,49 @@
44
* Bare `rb` and `rb --help` render the welcome screen. Subcommands are derived
55
* from the central registry. Unknown commands and stray ffmpeg flags are hinted.
66
*/
7+
import { spawn } from "node:child_process";
78
import { defineCommand, runMain, renderUsage, type CommandDef } from "citty";
89
import { VERSION } from "./generated/version.js";
910
import { toSubCommands, commandNames, COMMANDS } from "./registry.js";
1011
import { buildState, renderWelcome, renderHelp, renderWelcomeJson } from "./lib/welcome.js";
12+
import { checkForUpdate, isRefreshDue } from "./lib/update-check.js";
13+
14+
// Compile-time define from `bun build --compile --define`. Undefined in dev mode
15+
// (`bun run src/main.ts`), where process.execPath is the bun runtime, not `rb`.
16+
declare const IS_STANDALONE: boolean | undefined;
17+
const IS_BIN = typeof IS_STANDALONE !== "undefined" && IS_STANDALONE === true;
1118

1219
// Never crash with an EPIPE stack trace when piped into a closed reader (`rb | head`).
1320
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
1421
if (err.code === "EPIPE") process.exit(0);
1522
});
1623

24+
// Hidden internal entrypoint. A prior invocation spawns `rb __update-check`
25+
// detached so the GitHub round-trip warms the version cache WITHOUT blocking the
26+
// user's command — short-lived commands (e.g. the welcome screen) exit long
27+
// before an inline fetch could finish, which is why the refresh must outlive
28+
// them in its own process. Not a registered command; handled before all routing.
29+
function isBackgroundCheck(): boolean {
30+
return process.argv.slice(2)[0] === "__update-check";
31+
}
32+
33+
// On launch, fire the detached refresh if one is due. Best-effort: a failed
34+
// spawn must never surface to the user or delay their command.
35+
function spawnBackgroundCheck(): void {
36+
if (!IS_BIN) return; // dev mode: execPath is bun, not the rb binary
37+
if (!isRefreshDue()) return; // fresh cache or a skip rule (CI, --quiet, ...)
38+
try {
39+
const child = spawn(process.execPath, ["__update-check"], {
40+
detached: true,
41+
stdio: "ignore",
42+
windowsHide: true,
43+
});
44+
child.unref();
45+
} catch {
46+
// Never block the CLI on a failed background spawn.
47+
}
48+
}
49+
1750
const FFMPEG_FLAGS = ["-i", "-vf", "-c:v", "-c:a", "-f", "-filter_complex"];
1851

1952
// A minimal parent stub passed to renderUsage so subcommand usage lines are
@@ -87,43 +120,58 @@ const main = defineCommand({
87120
},
88121
});
89122

90-
// Pre-validate: intercept unknown commands before citty throws with exit 1.
91-
// citty dispatches to run() only when args parse cleanly, but it throws
92-
// CLIError("Unknown command") before run() for unrecognised positional args.
93-
// We catch those here so we can exit 2 instead of 1.
94-
const rawArgs = process.argv.slice(2);
123+
// `knownNames` is referenced by the main command's run() closure above, so it
124+
// stays at module scope.
95125
const knownNames = new Set([...commandNames(), "help"]);
96126

97-
// Check for --help / --version / -h — citty owns these, don't intercept.
98-
const isCittyOwned =
99-
rawArgs.includes("--help") ||
100-
rawArgs.includes("-h") ||
101-
(rawArgs.length === 1 && rawArgs[0] === "--version");
102-
103-
if (!isCittyOwned) {
104-
const firstPositional = rawArgs.find((a) => !a.startsWith("-"));
105-
if (firstPositional !== undefined && !knownNames.has(firstPositional)) {
106-
// Unknown first positional — but first check if any arg looks like an
107-
// ffmpeg flag (e.g. `rb -i in.mp4 out.mp4` or `rb -vf scale=1:1`).
108-
// The check must run even when the first positional isn't itself a flag,
109-
// because the flag may appear after file arguments.
110-
if (rawArgs.some((a) => FFMPEG_FLAGS.includes(a))) {
111-
process.stderr.write(`Did you mean: rb ffmpeg ${rawArgs.join(" ")}?\n`);
127+
if (isBackgroundCheck()) {
128+
// Detached refresh process: warm the cache, then exit. Never touches routing,
129+
// stdout, or the user's terminal. Failures stay silent.
130+
checkForUpdate(VERSION)
131+
.catch(() => {})
132+
.finally(() => process.exit(0));
133+
} else {
134+
// Kick off the next refresh in the background before handing control to the
135+
// CLI. The notice shown this run (if any) comes from a prior run's cache.
136+
spawnBackgroundCheck();
137+
138+
// Pre-validate: intercept unknown commands before citty throws with exit 1.
139+
// citty dispatches to run() only when args parse cleanly, but it throws
140+
// CLIError("Unknown command") before run() for unrecognised positional args.
141+
// We catch those here so we can exit 2 instead of 1.
142+
const rawArgs = process.argv.slice(2);
143+
144+
// Check for --help / --version / -h — citty owns these, don't intercept.
145+
const isCittyOwned =
146+
rawArgs.includes("--help") ||
147+
rawArgs.includes("-h") ||
148+
(rawArgs.length === 1 && rawArgs[0] === "--version");
149+
150+
if (!isCittyOwned) {
151+
const firstPositional = rawArgs.find((a) => !a.startsWith("-"));
152+
if (firstPositional !== undefined && !knownNames.has(firstPositional)) {
153+
// Unknown first positional — but first check if any arg looks like an
154+
// ffmpeg flag (e.g. `rb -i in.mp4 out.mp4` or `rb -vf scale=1:1`).
155+
// The check must run even when the first positional isn't itself a flag,
156+
// because the flag may appear after file arguments.
157+
if (rawArgs.some((a) => FFMPEG_FLAGS.includes(a))) {
158+
process.stderr.write(`Did you mean: rb ffmpeg ${rawArgs.join(" ")}?\n`);
159+
process.exit(2);
160+
}
161+
process.stderr.write(`unknown command '${firstPositional}' -- run \`rb\` to see commands\n`);
112162
process.exit(2);
113163
}
114-
process.stderr.write(`unknown command '${firstPositional}' -- run \`rb\` to see commands\n`);
115-
process.exit(2);
116164
}
117-
}
118165

119-
runMain(main, {
120-
async showUsage(cmd, parent) {
121-
// Root help (`rb --help` / `rb -h`) → our welcome+flags screen.
122-
if (!parent) {
123-
process.stdout.write(renderHelp(buildState()) + "\n");
124-
return;
125-
}
126-
// Subcommand help (`rb ffmpeg --help`) → citty's default usage.
127-
process.stdout.write((await renderUsage(cmd, parent)) + "\n");
128-
},
129-
});
166+
runMain(main, {
167+
async showUsage(cmd, parent) {
168+
// Root help (`rb --help` / `rb -h`) → our welcome+flags screen.
169+
if (!parent) {
170+
process.stdout.write(renderHelp(buildState()) + "\n");
171+
return;
172+
}
173+
// Subcommand help (`rb ffmpeg --help`) → citty's default usage.
174+
process.stdout.write((await renderUsage(cmd, parent)) + "\n");
175+
},
176+
});
177+
}

0 commit comments

Comments
 (0)