feat: v1.0.0 golden release — auto-update now opt-in (#3254)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run

Two changes to update behavior:

1. Auto-update is now opt-in via SPAWN_AUTO_UPDATE=1 (default: notify only)
2. Even with auto-update on, only patch versions install automatically
   (e.g. 1.0.0 → 1.0.5 yes, 1.0.0 → 1.1.0 no)

This pins users to a stable major.minor — bug fixes flow automatically
but new features require an explicit `spawn update`.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-04-10 01:38:01 -07:00 committed by GitHub
parent 1cf0e0b9c6
commit 317227bd41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 56 additions and 17 deletions

View file

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

View file

@ -34,6 +34,8 @@ function mockEnv() {
process.env.NODE_ENV = undefined;
process.env.BUN_ENV = undefined;
process.env.SPAWN_NO_UPDATE_CHECK = undefined;
// Enable auto-update for tests that verify update behavior
process.env.SPAWN_AUTO_UPDATE = "1";
return originalEnv;
}
@ -92,7 +94,7 @@ describe("update-check", () => {
});
it("should check for updates on every run", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execFileSync to prevent actual update + re-exec
@ -108,7 +110,7 @@ describe("update-check", () => {
});
it("should auto-update when newer version is available", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execFileSync to prevent actual update + re-exec
@ -121,7 +123,7 @@ describe("update-check", () => {
// Should have printed update message to stderr
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Update available");
expect(output).toContain("99.0.0");
expect(output).toContain("1.0.99");
expect(output).toContain("Updating automatically");
// Should have called execFileSync for curl, bash, which, and re-exec
@ -167,7 +169,7 @@ describe("update-check", () => {
});
it("should handle update failures gracefully", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execFileSync to throw an error (curl fetch fails)
@ -210,7 +212,7 @@ describe("update-check", () => {
});
it("should redirect install script stdout to stderr when jsonOutput=true", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
@ -247,7 +249,7 @@ describe("update-check", () => {
});
it("should use inherit stdio for install script when jsonOutput=false", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
@ -287,7 +289,7 @@ describe("update-check", () => {
"sprite",
];
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
@ -350,7 +352,7 @@ describe("update-check", () => {
"sprite",
];
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
@ -427,7 +429,7 @@ describe("update-check", () => {
"/usr/local/bin/spawn",
];
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");

View file

@ -67,10 +67,12 @@ async function fetchLatestVersion(): Promise<string | null> {
return fallback.ok ? fallback.data : null;
}
function parseSemver(v: string): number[] {
return v.split(".").map((n) => Number.parseInt(n, 10) || 0);
}
function compareVersions(current: string, latest: string): boolean {
// Simple semantic version comparison (assumes format: major.minor.patch)
const parseSemver = (v: string): number[] => v.split(".").map((n) => Number.parseInt(n, 10) || 0);
const currentParts = parseSemver(current);
const latestParts = parseSemver(latest);
@ -86,6 +88,13 @@ function compareVersions(current: string, latest: string): boolean {
return false;
}
/** Check if two versions share the same major.minor (e.g. 1.0.x). */
function isSameMinor(current: string, latest: string): boolean {
const c = parseSemver(current);
const l = parseSemver(latest);
return c[0] === l[0] && c[1] === l[1];
}
// ── Failure Backoff ──────────────────────────────────────────────────────────
function isUpdateBackedOff(): boolean {
@ -167,6 +176,24 @@ function printUpdateBanner(latestVersion: string): void {
console.error();
}
/**
* Show a non-blocking update notice without auto-installing.
* Users can update manually with `spawn update` or set SPAWN_AUTO_UPDATE=1.
*/
function printUpdateNotice(latestVersion: string): void {
console.error();
console.error(
pc.yellow(" Update available: ") +
pc.dim(`v${VERSION}`) +
pc.yellow(" → ") +
pc.green(pc.bold(`v${latestVersion}`)),
);
console.error(
pc.dim(` Run ${pc.cyan("spawn update")} to install, or set SPAWN_AUTO_UPDATE=1 for automatic updates`),
);
console.error();
}
/**
* Find the spawn binary to re-exec after an update.
*
@ -362,12 +389,22 @@ export async function checkForUpdates(jsonOutput = false): Promise<void> {
// Record successful check so we don't hit the network again for an hour
markUpdateChecked();
// Auto-update if newer version is available
// Notify if newer version is available
if (compareVersions(VERSION, latestVersion)) {
const r = tryCatch(() => performAutoUpdate(latestVersion, jsonOutput));
if (!r.ok) {
logWarn("Auto-update encountered an error");
logDebug(getErrorMessage(r.error));
// Only auto-update within the same major.minor (patch updates only).
// e.g. 1.0.0 → 1.0.5 is allowed, 1.0.0 → 1.1.0 is not.
const patchOnly = isSameMinor(VERSION, latestVersion);
if (patchOnly && process.env.SPAWN_AUTO_UPDATE === "1") {
// Opt-in auto-update for patch versions
const r = tryCatch(() => performAutoUpdate(latestVersion, jsonOutput));
if (!r.ok) {
logWarn("Auto-update encountered an error");
logDebug(getErrorMessage(r.error));
}
} else {
// Show notice: either auto-update is off, or it's a minor/major bump
printUpdateNotice(latestVersion);
}
}
}