diff --git a/cli/package.json b/cli/package.json index dfd62d72..0d6c25f9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.66", + "version": "0.2.67", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/update-check.test.ts b/cli/src/__tests__/update-check.test.ts index 142073e8..bffcec36 100644 --- a/cli/src/__tests__/update-check.test.ts +++ b/cli/src/__tests__/update-check.test.ts @@ -197,5 +197,120 @@ describe("update-check", () => { 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; + }); }); }); diff --git a/cli/src/update-check.ts b/cli/src/update-check.ts index 607d1e3e..c9570b0a 100644 --- a/cli/src/update-check.ts +++ b/cli/src/update-check.ts @@ -85,11 +85,34 @@ function performAutoUpdate(latestVersion: string): void { console.error(); 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 - process.exit(0); + // Re-exec the updated binary with the same arguments + 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) { console.error(); console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`)));