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.1.1",
"version": "1.0.20",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
23 changes: 9 additions & 14 deletions packages/cli/src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,9 @@ describe("update-check", () => {
// - 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.1.0 -> 1.1.99 is a patch bump (same major.minor)
// 1.0.20 -> 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.1.99\n")));
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((file: string) =>
Buffer.from(file === "curl" ? FAKE_INSTALL_SCRIPT : ""),
Expand All @@ -507,8 +507,8 @@ describe("update-check", () => {
execFileSyncSpy.mockRestore();
});

it("shows notice only for minor bumps without SPAWN_AUTO_UPDATE=1", async () => {
// 1.1.0 -> 1.2.0 is a minor bump
it("auto-installs minor bumps (same major)", async () => {
// 1.0.20 -> 1.2.0 is a minor bump — should auto-install
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.2.0\n")));
const { executor } = await import("../update-check.js");
Expand All @@ -519,20 +519,15 @@ describe("update-check", () => {
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.2.0");
// Must NOT auto-install — no curl, no bash, no re-exec
expect(execFileSyncSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
// Should auto-install: curl to fetch script, bash to run it, which + re-exec
expect(execFileSyncSpy).toHaveBeenCalled();

fetchSpy.mockRestore();
execFileSyncSpy.mockRestore();
});

it("shows notice only for major bumps without SPAWN_AUTO_UPDATE=1", async () => {
// 1.1.0 -> 2.0.0 is a major bump
// 1.0.20 -> 2.0.0 is a major bump — should NOT auto-install
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");
Expand All @@ -550,8 +545,8 @@ describe("update-check", () => {
execFileSyncSpy.mockRestore();
});

it("auto-installs minor bumps WITH SPAWN_AUTO_UPDATE=1", async () => {
// 1.1.0 -> 1.2.0 with opt-in env var
it("auto-installs major bumps WITH SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.20 -> 1.2.0 with opt-in env var
process.env.SPAWN_AUTO_UPDATE = "1";
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.2.0\n")));
const { executor } = await import("../update-check.js");
Expand Down
32 changes: 11 additions & 21 deletions packages/cli/src/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,6 @@ 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 {
Expand Down Expand Up @@ -439,25 +432,22 @@ export async function checkForUpdates(jsonOutput = false): Promise<void> {

// Notify (or auto-install) if a newer version is available.
if (compareVersions(VERSION, latestVersion)) {
// Update policy, semver-aligned:
// Update policy:
//
// 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.
// PATCH and MINOR bumps (e.g. 1.0.5 → 1.0.7, 1.0.x → 1.1.0) are
// auto-installed. These contain bug fixes, security hardening, and
// new features that users benefit from getting promptly.
//
// 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.
// MAJOR bumps (e.g. 1.x.x → 2.0.0) respect SPAWN_AUTO_UPDATE=1
// as opt-in, since these can contain breaking changes.
//
// 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);
// SPAWN_NO_AUTO_UPDATE=1 lets users opt OUT of auto-update entirely
// if they need a fully pinned CLI (CI environments, etc.).
const sameMajor = parseSemver(VERSION)[0] === parseSemver(latestVersion)[0];
const explicitOptOut = process.env.SPAWN_NO_AUTO_UPDATE === "1";
const explicitOptIn = process.env.SPAWN_AUTO_UPDATE === "1";

const shouldAutoInstall = !explicitOptOut && (patchOnly || explicitOptIn);
const shouldAutoInstall = !explicitOptOut && (sameMajor || explicitOptIn);

if (shouldAutoInstall) {
const r = tryCatch(() => performAutoUpdate(latestVersion, jsonOutput));
Expand All @@ -466,7 +456,7 @@ export async function checkForUpdates(jsonOutput = false): Promise<void> {
logDebug(getErrorMessage(r.error));
}
} else {
// Minor/major bump without opt-in, or explicit opt-out — show notice.
// Major bump without opt-in, or explicit opt-out — show notice.
printUpdateNotice(latestVersion);
}
}
Expand Down
Loading