diff --git a/packages/cli/package.json b/packages/cli/package.json index 7a86f8c04..06dca7d7c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.8", + "version": "1.0.9", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/update-check.test.ts b/packages/cli/src/__tests__/update-check.test.ts index 74c5d4319..8a378fda7 100644 --- a/packages/cli/src/__tests__/update-check.test.ts +++ b/packages/cli/src/__tests__/update-check.test.ts @@ -468,4 +468,106 @@ describe("update-check", () => { process.argv = originalArgv; }); }); + + // ── Update policy: patch = auto, minor/major = opt-in ──────────────────── + // + // These tests lock in the behavior from fix/auto-update-patches: + // - PATCH bumps (same major.minor) auto-install regardless of env vars + // - MINOR / MAJOR bumps require SPAWN_AUTO_UPDATE=1 to auto-install + // - SPAWN_NO_AUTO_UPDATE=1 suppresses auto-install entirely + describe("update policy", () => { + it("auto-installs patch bumps even without SPAWN_AUTO_UPDATE=1", async () => { + // 1.0.6 -> 1.0.99 is a patch bump (same major.minor) + process.env.SPAWN_AUTO_UPDATE = undefined; + const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n"))); + const { executor } = await import("../update-check.js"); + const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from("")); + + const { checkForUpdates } = await import("../update-check.js"); + await checkForUpdates(); + + const output = consoleErrorSpy.mock.calls.map((call: unknown[]) => call[0]).join("\n"); + expect(output).toContain("Update available"); + expect(output).toContain("Updating automatically"); + expect(execFileSyncSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(0); + + fetchSpy.mockRestore(); + execFileSyncSpy.mockRestore(); + }); + + it("shows notice only for minor bumps without SPAWN_AUTO_UPDATE=1", async () => { + // 1.0.6 -> 1.1.0 is a minor bump + process.env.SPAWN_AUTO_UPDATE = undefined; + const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.0\n"))); + const { executor } = await import("../update-check.js"); + const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from("")); + + const { checkForUpdates } = await import("../update-check.js"); + await checkForUpdates(); + + const output = consoleErrorSpy.mock.calls.map((call: unknown[]) => call[0]).join("\n"); + // Notice should mention the version jump + expect(output).toContain("Update available"); + expect(output).toContain("1.1.0"); + // Must NOT auto-install — no curl, no bash, no re-exec + expect(execFileSyncSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + execFileSyncSpy.mockRestore(); + }); + + it("shows notice only for major bumps without SPAWN_AUTO_UPDATE=1", async () => { + // 1.0.6 -> 2.0.0 is a major bump + process.env.SPAWN_AUTO_UPDATE = undefined; + const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("2.0.0\n"))); + const { executor } = await import("../update-check.js"); + const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from("")); + + const { checkForUpdates } = await import("../update-check.js"); + await checkForUpdates(); + + expect(execFileSyncSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + execFileSyncSpy.mockRestore(); + }); + + it("auto-installs minor bumps WITH SPAWN_AUTO_UPDATE=1", async () => { + // 1.0.6 -> 1.1.0 with opt-in env var + process.env.SPAWN_AUTO_UPDATE = "1"; + const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.0\n"))); + const { executor } = await import("../update-check.js"); + const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from("")); + + const { checkForUpdates } = await import("../update-check.js"); + await checkForUpdates(); + + expect(execFileSyncSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(0); + + fetchSpy.mockRestore(); + execFileSyncSpy.mockRestore(); + }); + + it("SPAWN_NO_AUTO_UPDATE=1 suppresses patch auto-install (CI pinning)", async () => { + // Explicit opt-out — even patches should show notice only + process.env.SPAWN_AUTO_UPDATE = undefined; + process.env.SPAWN_NO_AUTO_UPDATE = "1"; + const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n"))); + const { executor } = await import("../update-check.js"); + const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from("")); + + const { checkForUpdates } = await import("../update-check.js"); + await checkForUpdates(); + + expect(execFileSyncSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + execFileSyncSpy.mockRestore(); + }); + }); }); diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index 10310f5e5..d05a2b609 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -400,21 +400,36 @@ export async function checkForUpdates(jsonOutput = false): Promise { // Record successful check so we don't hit the network again for an hour markUpdateChecked(); - // Notify if newer version is available + // Notify (or auto-install) if a newer version is available. if (compareVersions(VERSION, latestVersion)) { - // 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. + // Update policy, semver-aligned: + // + // PATCH bumps (same major.minor, e.g. 1.0.5 → 1.0.7) are always + // auto-installed. Patches are reserved for bug fixes and security + // hardening — users benefit from getting them without opting in, and + // the blast radius is bounded by semver: no behavior changes, no + // breaking changes, no new features. + // + // MINOR / MAJOR bumps (e.g. 1.0.x → 1.1.0, 1.x.x → 2.0.0) respect + // SPAWN_AUTO_UPDATE=1 as opt-in. These can contain behavior changes + // and users should decide when to move to them. + // + // SPAWN_NO_AUTO_UPDATE=1 lets users opt OUT of patch-level auto-update + // entirely if they need a fully pinned CLI (CI environments, etc.). const patchOnly = isSameMinor(VERSION, latestVersion); + const explicitOptOut = process.env.SPAWN_NO_AUTO_UPDATE === "1"; + const explicitOptIn = process.env.SPAWN_AUTO_UPDATE === "1"; - if (patchOnly && process.env.SPAWN_AUTO_UPDATE === "1") { - // Opt-in auto-update for patch versions + const shouldAutoInstall = !explicitOptOut && (patchOnly || explicitOptIn); + + if (shouldAutoInstall) { 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 + // Minor/major bump without opt-in, or explicit opt-out — show notice. printUpdateNotice(latestVersion); } }