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
34 changes: 34 additions & 0 deletions src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,40 @@ describe("update-check", () => {
expect(cache.latest).toBe("1.1.0");
});

it("isRefreshDue: true when no cache exists", async () => {
const { isRefreshDue } = await import("../lib/update-check.js");
expect(isRefreshDue()).toBe(true);
});

it("isRefreshDue: false when cache is within TTL", async () => {
const cachePath = join(tempDir, "update-check.json");
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(
cachePath,
JSON.stringify({ latest: "1.1.0", checkedAt: Date.now(), ttl: 24 * 60 * 60 * 1000 })
);
const { isRefreshDue } = await import("../lib/update-check.js");
expect(isRefreshDue()).toBe(false);
});

it("isRefreshDue: true when cache is older than TTL", async () => {
const cachePath = join(tempDir, "update-check.json");
mkdirSync(dirname(cachePath), { recursive: true });
const ttl = 24 * 60 * 60 * 1000;
writeFileSync(
cachePath,
JSON.stringify({ latest: "1.1.0", checkedAt: Date.now() - ttl - 1000, ttl })
);
const { isRefreshDue } = await import("../lib/update-check.js");
expect(isRefreshDue()).toBe(true);
});

it("isRefreshDue: false when a skip rule applies (CI)", async () => {
process.env.CI = "true";
const { isRefreshDue } = await import("../lib/update-check.js");
expect(isRefreshDue()).toBe(false);
});

it("does not write cache when no CLI release found", async () => {
const fetchMock = mock(async () =>
new Response(JSON.stringify([
Expand Down
76 changes: 38 additions & 38 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* `rb update` — Self-replace for standalone binary, print install instructions for dev mode.
* Detects install method via compile-time IS_STANDALONE define.
*/
import { writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, unlinkSync } from "node:fs";
import { writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, unlinkSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
Expand Down Expand Up @@ -139,50 +139,50 @@ async function updateBinary(latest: string): Promise<void> {
}
console.log(`Checksum verified: ${actualHash}`);

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

if (ext === ".tar.gz") {
const tmpArchivePath = join(binDir, `update-${Date.now()}.tar.gz`);
writeFileSync(tmpArchivePath, archive);
const tar = spawnSync("tar", ["-xzf", tmpArchivePath, "-C", binDir], { stdio: "inherit" });
try {
unlinkSync(tmpArchivePath);
} catch {
// non-fatal
}
if (tar.status !== 0) throw new Error("tar extraction failed");
const extractedPath = join(binDir, "rb");
if (existsSync(extractedPath) && extractedPath !== binPath) {
renameSync(extractedPath, tmpBinPath);
}
} else {
const tmpArchivePath = join(binDir, `update-${Date.now()}.zip`);
try {
const tmpArchivePath = join(extractDir, `archive${ext}`);
writeFileSync(tmpArchivePath, archive);
const expand = spawnSync(
"powershell",
[
"-Command",
`Expand-Archive -Path '${tmpArchivePath}' -DestinationPath '${binDir}' -Force`,
],
{ stdio: "inherit" }
);
try {
unlinkSync(tmpArchivePath);
} catch {
// non-fatal
}
if (expand.status !== 0) throw new Error("Expand-Archive failed");
const extractedPath = join(binDir, "rb.exe");
if (existsSync(extractedPath) && extractedPath !== binPath) {
renameSync(extractedPath, tmpBinPath);

if (ext === ".tar.gz") {
const tar = spawnSync("tar", ["-xzf", tmpArchivePath, "-C", extractDir], { stdio: "inherit" });
if (tar.status !== 0) throw new Error("tar extraction failed");
} else {
const expand = spawnSync(
"powershell",
[
"-NoProfile",
"-Command",
`Expand-Archive -Path '${tmpArchivePath}' -DestinationPath '${extractDir}' -Force`,
],
{ stdio: "inherit" }
);
if (expand.status !== 0) throw new Error("Expand-Archive failed");
}
}

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

chmodSync(tmpBinPath, 0o755);
Expand Down
14 changes: 14 additions & 0 deletions src/lib/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ function versionGt(a: string, b: string): boolean {
return false;
}

/**
* Cheap synchronous gate: is a background refresh warranted right now?
* Honors the same skip rules as the check itself, plus the 24h TTL. Used by the
* launcher to decide whether to spawn the detached refresh process at all — so a
* fresh cache costs zero subprocesses. No jitter here: jitter exists to spread
* the network call across the fleet on release day, not to gate a local spawn.
*/
export function isRefreshDue(): boolean {
if (shouldSkip()) return false;
const cache = readCache();
if (!cache) return true;
return Date.now() - cache.checkedAt >= cache.ttl;
}

/**
* Non-blocking background check. Updates cache file if network succeeds.
* Uses injected fetch so tests can mock it.
Expand Down
116 changes: 82 additions & 34 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,49 @@
* Bare `rb` and `rb --help` render the welcome screen. Subcommands are derived
* from the central registry. Unknown commands and stray ffmpeg flags are hinted.
*/
import { spawn } from "node:child_process";
import { defineCommand, runMain, renderUsage, type CommandDef } from "citty";
import { VERSION } from "./generated/version.js";
import { toSubCommands, commandNames, COMMANDS } from "./registry.js";
import { buildState, renderWelcome, renderHelp, renderWelcomeJson } from "./lib/welcome.js";
import { checkForUpdate, isRefreshDue } from "./lib/update-check.js";

// Compile-time define from `bun build --compile --define`. Undefined in dev mode
// (`bun run src/main.ts`), where process.execPath is the bun runtime, not `rb`.
declare const IS_STANDALONE: boolean | undefined;
const IS_BIN = typeof IS_STANDALONE !== "undefined" && IS_STANDALONE === true;

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

// Hidden internal entrypoint. A prior invocation spawns `rb __update-check`
// detached so the GitHub round-trip warms the version cache WITHOUT blocking the
// user's command — short-lived commands (e.g. the welcome screen) exit long
// before an inline fetch could finish, which is why the refresh must outlive
// them in its own process. Not a registered command; handled before all routing.
function isBackgroundCheck(): boolean {
return process.argv.slice(2)[0] === "__update-check";
}

// On launch, fire the detached refresh if one is due. Best-effort: a failed
// spawn must never surface to the user or delay their command.
function spawnBackgroundCheck(): void {
if (!IS_BIN) return; // dev mode: execPath is bun, not the rb binary
if (!isRefreshDue()) return; // fresh cache or a skip rule (CI, --quiet, ...)
try {
const child = spawn(process.execPath, ["__update-check"], {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();
} catch {
// Never block the CLI on a failed background spawn.
}
}

const FFMPEG_FLAGS = ["-i", "-vf", "-c:v", "-c:a", "-f", "-filter_complex"];

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

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

// Check for --help / --version / -h — citty owns these, don't intercept.
const isCittyOwned =
rawArgs.includes("--help") ||
rawArgs.includes("-h") ||
(rawArgs.length === 1 && rawArgs[0] === "--version");

if (!isCittyOwned) {
const firstPositional = rawArgs.find((a) => !a.startsWith("-"));
if (firstPositional !== undefined && !knownNames.has(firstPositional)) {
// Unknown first positional — but first check if any arg looks like an
// ffmpeg flag (e.g. `rb -i in.mp4 out.mp4` or `rb -vf scale=1:1`).
// The check must run even when the first positional isn't itself a flag,
// because the flag may appear after file arguments.
if (rawArgs.some((a) => FFMPEG_FLAGS.includes(a))) {
process.stderr.write(`Did you mean: rb ffmpeg ${rawArgs.join(" ")}?\n`);
if (isBackgroundCheck()) {
// Detached refresh process: warm the cache, then exit. Never touches routing,
// stdout, or the user's terminal. Failures stay silent.
checkForUpdate(VERSION)
.catch(() => {})
.finally(() => process.exit(0));
} else {
// Kick off the next refresh in the background before handing control to the
// CLI. The notice shown this run (if any) comes from a prior run's cache.
spawnBackgroundCheck();

// Pre-validate: intercept unknown commands before citty throws with exit 1.
// citty dispatches to run() only when args parse cleanly, but it throws
// CLIError("Unknown command") before run() for unrecognised positional args.
// We catch those here so we can exit 2 instead of 1.
const rawArgs = process.argv.slice(2);

// Check for --help / --version / -h — citty owns these, don't intercept.
const isCittyOwned =
rawArgs.includes("--help") ||
rawArgs.includes("-h") ||
(rawArgs.length === 1 && rawArgs[0] === "--version");

if (!isCittyOwned) {
const firstPositional = rawArgs.find((a) => !a.startsWith("-"));
if (firstPositional !== undefined && !knownNames.has(firstPositional)) {
// Unknown first positional — but first check if any arg looks like an
// ffmpeg flag (e.g. `rb -i in.mp4 out.mp4` or `rb -vf scale=1:1`).
// The check must run even when the first positional isn't itself a flag,
// because the flag may appear after file arguments.
if (rawArgs.some((a) => FFMPEG_FLAGS.includes(a))) {
process.stderr.write(`Did you mean: rb ffmpeg ${rawArgs.join(" ")}?\n`);
process.exit(2);
}
process.stderr.write(`unknown command '${firstPositional}' -- run \`rb\` to see commands\n`);
process.exit(2);
}
process.stderr.write(`unknown command '${firstPositional}' -- run \`rb\` to see commands\n`);
process.exit(2);
}
}

runMain(main, {
async showUsage(cmd, parent) {
// Root help (`rb --help` / `rb -h`) → our welcome+flags screen.
if (!parent) {
process.stdout.write(renderHelp(buildState()) + "\n");
return;
}
// Subcommand help (`rb ffmpeg --help`) → citty's default usage.
process.stdout.write((await renderUsage(cmd, parent)) + "\n");
},
});
runMain(main, {
async showUsage(cmd, parent) {
// Root help (`rb --help` / `rb -h`) → our welcome+flags screen.
if (!parent) {
process.stdout.write(renderHelp(buildState()) + "\n");
return;
}
// Subcommand help (`rb ffmpeg --help`) → citty's default usage.
process.stdout.write((await renderUsage(cmd, parent)) + "\n");
},
});
}