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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.8",
"version": "1.0.9",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
27 changes: 21 additions & 6 deletions packages/cli/src/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,21 +400,36 @@ export async function checkForUpdates(jsonOutput = false): Promise<void> {
// 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);
}
}
Expand Down
Loading