fix: auto re-exec command after CLI auto-update (fixes #780) (#830)

When a CLI auto-update triggers mid-command (e.g. `spawn claude sprite`),
the updated binary now automatically re-runs with the original arguments
instead of asking the user to manually re-run. Sets SPAWN_NO_UPDATE_CHECK=1
on re-exec to prevent infinite update loops. Falls back to the old "run
again" message when no arguments were provided (bare `spawn`).

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-12 23:54:49 -08:00 committed by GitHub
parent c7bbe8bc3b
commit 716da5d43b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 143 additions and 5 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "@openrouter/spawn", "name": "@openrouter/spawn",
"version": "0.2.66", "version": "0.2.67",
"type": "module", "type": "module",
"bin": { "bin": {
"spawn": "cli.js" "spawn": "cli.js"

View file

@ -197,5 +197,120 @@ describe("update-check", () => {
fetchSpy.mockRestore(); fetchSpy.mockRestore();
}); });
it("should re-exec with original args after successful update", async () => {
const originalArgv = process.argv;
process.argv = ["/usr/bin/bun", "/usr/local/bin/spawn", "claude", "sprite"];
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
const calls: string[] = [];
const execSyncSpy = spyOn(executor, "execSync").mockImplementation((cmd: string) => {
calls.push(cmd);
});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// First call: install script, second call: re-exec with original args
expect(calls.length).toBe(2);
expect(calls[0]).toContain("install.sh");
expect(calls[1]).toContain("spawn");
expect(calls[1]).toContain("claude");
expect(calls[1]).toContain("sprite");
// Should show rerunning message
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Rerunning");
// Should set SPAWN_NO_UPDATE_CHECK=1 to prevent infinite loop
expect(execSyncSpy.mock.calls[1][1]).toHaveProperty("env");
expect(execSyncSpy.mock.calls[1][1].env.SPAWN_NO_UPDATE_CHECK).toBe("1");
expect(processExitSpy).toHaveBeenCalledWith(0);
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
process.argv = originalArgv;
});
it("should forward exit code when re-exec fails", async () => {
const originalArgv = process.argv;
process.argv = ["/usr/bin/bun", "/usr/local/bin/spawn", "claude", "sprite"];
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
let callCount = 0;
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {
callCount++;
if (callCount === 2) {
// Re-exec fails with exit code 1
const err = new Error("Command failed") as Error & { status: number };
err.status = 42;
throw err;
}
});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Should forward the exit code from the re-exec
expect(processExitSpy).toHaveBeenCalledWith(42);
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
process.argv = originalArgv;
});
it("should not re-exec when run without arguments (bare spawn)", async () => {
const originalArgv = process.argv;
process.argv = ["/usr/bin/bun", "/usr/local/bin/spawn"];
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
const calls: string[] = [];
const execSyncSpy = spyOn(executor, "execSync").mockImplementation((cmd: string) => {
calls.push(cmd);
});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Only one call: the install script (no re-exec)
expect(calls.length).toBe(1);
expect(calls[0]).toContain("install.sh");
// Should show "Run your spawn command again" instead
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Run your spawn command again");
expect(output).not.toContain("Rerunning");
expect(processExitSpy).toHaveBeenCalledWith(0);
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
process.argv = originalArgv;
});
}); });
}); });

View file

@ -85,11 +85,34 @@ function performAutoUpdate(latestVersion: string): void {
console.error(); console.error();
console.error(pc.green(pc.bold(`${CHECK_MARK} Updated successfully!`))); console.error(pc.green(pc.bold(`${CHECK_MARK} Updated successfully!`)));
console.error(pc.dim(" Run your spawn command again to use the new version."));
console.error();
// Exit cleanly after update // Re-exec the updated binary with the same arguments
process.exit(0); const args = process.argv.slice(2);
if (args.length > 0) {
const binPath = process.argv[1] || "spawn";
const quotedBin = `'${binPath.replace(/'/g, "'\\''")}'`;
const quotedArgs = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
console.error(pc.dim(` Rerunning: spawn ${args.join(" ")}`));
console.error();
try {
executor.execSync(`${quotedBin} ${quotedArgs}`, {
stdio: "inherit",
shell: "/bin/bash",
env: { ...process.env, SPAWN_NO_UPDATE_CHECK: "1" },
});
process.exit(0);
} catch (reexecErr) {
// Forward the exit code from the re-executed command
const code = reexecErr && typeof reexecErr === "object" && "status" in reexecErr
? (reexecErr as { status: number }).status
: 1;
process.exit(code);
}
} else {
console.error(pc.dim(" Run your spawn command again to use the new version."));
console.error();
process.exit(0);
}
} catch (err) { } catch (err) {
console.error(); console.error();
console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`))); console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`)));