From ba78d13a5249a19bd648b9cd829e828d03d39d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E9=AA=8F?= Date: Wed, 24 Jun 2026 15:31:24 +0800 Subject: [PATCH 1/2] feat: add auto update cli --- packages/cli/src/main.ts | 27 ++++-- packages/cli/src/utils/update-checker.ts | 108 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 279f65d..bfe3316 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -10,7 +10,12 @@ import { import { ensureApiKey } from "./utils/ensure-key.ts"; import { setupProxyFromEnv } from "./proxy.ts"; import { handleError } from "./error-handler.ts"; -import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts"; +import { + checkForUpdate, + getPendingUpdateNotification, + isMajorUpgrade, + performAutoUpdate, +} from "./utils/update-checker.ts"; import { maybeShowStatusBar } from "./output/status-bar.ts"; import { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; import { CLI_VERSION } from "./version.ts"; @@ -129,12 +134,20 @@ async function main() { const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update"; const newVersion = getPendingUpdateNotification(); if (newVersion && !config.quiet && !isUpdateCommand) { - const isTTY = process.stderr.isTTY; - const yellow = isTTY ? "\x1b[33m" : ""; - const cyan = isTTY ? "\x1b[36m" : ""; - const reset = isTTY ? "\x1b[0m" : ""; - process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`); - process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`); + if (isMajorUpgrade(newVersion, CLI_VERSION)) { + // 大版本差距,自动更新 + await performAutoUpdate(CLI_VERSION, newVersion); + } else { + // 普通小版本提示 + const isTTY = process.stderr.isTTY; + const yellow = isTTY ? "\x1b[33m" : ""; + const cyan = isTTY ? "\x1b[36m" : ""; + const reset = isTTY ? "\x1b[0m" : ""; + process.stderr.write( + `\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`, + ); + process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`); + } } // 进程退出前尽力等待在途的埋点完成。 diff --git a/packages/cli/src/utils/update-checker.ts b/packages/cli/src/utils/update-checker.ts index 0d194c4..1390301 100644 --- a/packages/cli/src/utils/update-checker.ts +++ b/packages/cli/src/utils/update-checker.ts @@ -71,6 +71,114 @@ export function getPendingUpdateNotification(): string | null { return pendingNotification; } +/** + * Determines if the version gap is large enough to warrant auto-update. + * Conditions (either triggers auto-update): + * 1. New major > current major + * 2. Same major, but new minor - current minor > 3 + */ +export function isMajorUpgrade(latest: string, current: string): boolean { + const [latestMajor, latestMinor] = latest.split(".").map(Number); + const [currentMajor, currentMinor] = current.split(".").map(Number); + + // Condition 1: major version bump + if (latestMajor > currentMajor) return true; + + // Condition 2: same major, minor gap > 3 + if (latestMajor === currentMajor && latestMinor - currentMinor > 3) return true; + + return false; +} + +/** + * Perform auto-update: install latest version globally and update agent skill. + * Returns true if update succeeded, false otherwise. + */ +export async function performAutoUpdate( + currentVersion: string, + latestVersion: string, +): Promise { + const isTTY = process.stderr.isTTY; + const green = isTTY ? "\x1b[32m" : ""; + const yellow = isTTY ? "\x1b[33m" : ""; + const cyan = isTTY ? "\x1b[36m" : ""; + const dim = isTTY ? "\x1b[2m" : ""; + const reset = isTTY ? "\x1b[0m" : ""; + + const [latestMajor] = latestVersion.split(".").map(Number); + const [currentMajor] = currentVersion.split(".").map(Number); + const isMajorBump = latestMajor > currentMajor; + + process.stderr.write("\n"); + process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n`); + if (isMajorBump) { + process.stderr.write( + ` ${yellow}⚡ Major update detected: ${currentVersion} → ${latestVersion}${reset}\n`, + ); + } else { + process.stderr.write( + ` ${yellow}⚡ Significant update detected: ${currentVersion} → ${latestVersion}${reset}\n`, + ); + } + process.stderr.write(` ${dim}Auto-updating to keep your CLI up to date...${reset}\n`); + process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n\n`); + + const cmd = `npm install -g ${NPM_PACKAGE}@latest`; + + try { + const { execSync } = await import("child_process"); + execSync(cmd, { stdio: "inherit" }); + + // Verify installed version + let newVer: string | null = null; + try { + const rawVer = execSync("bl --version 2>/dev/null", { encoding: "utf-8" }).trim(); + newVer = rawVer.replace(/^bl\s+/, ""); + } catch { + /* ignore */ + } + + // Update cached state + try { + const { writeFileSync } = await import("fs"); + const { join } = await import("path"); + const { getConfigDir } = await import("bailian-cli-core"); + const stateFile = join(getConfigDir(), "update-state.json"); + writeFileSync( + stateFile, + JSON.stringify({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion }), + ); + } catch { + /* ignore */ + } + + process.stderr.write( + ` ${green}✓ Update complete: ${currentVersion} → ${newVer ?? latestVersion}${reset}\n`, + ); + process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`); + + // Update agent skill + try { + const { execSync: exec } = await import("child_process"); + process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`); + exec(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" }); + process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`); + } catch { + process.stderr.write( + ` ${yellow}Agent skill sync skipped (run manually: npx skills add modelstudioai/cli --all -g -y)${reset}\n\n`, + ); + } + + // Clear pending notification + pendingNotification = null; + return true; + } catch { + process.stderr.write(` ${yellow}⚠ Auto-update failed. Please run manually:${reset}\n`); + process.stderr.write(` ${cyan}${cmd}${reset}\n\n`); + return false; + } +} + export async function checkForUpdate(currentVersion: string): Promise { // Skip in CI / non-TTY environments if (process.env.CI || !process.stderr.isTTY) return; From e67615eabd5c35db13f6546653ce561741b20662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Wed, 24 Jun 2026 20:50:30 +0800 Subject: [PATCH 2/2] feat: auto update --- packages/cli/src/main.ts | 6 +- packages/cli/src/utils/update-checker.ts | 196 +++++++++++++++++----- packages/cli/tests/update-checker.test.ts | 126 ++++++++++++++ 3 files changed, 287 insertions(+), 41 deletions(-) create mode 100644 packages/cli/tests/update-checker.test.ts diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index bfe3316..56d405b 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -13,8 +13,8 @@ import { handleError } from "./error-handler.ts"; import { checkForUpdate, getPendingUpdateNotification, - isMajorUpgrade, performAutoUpdate, + shouldAutoUpdate, } from "./utils/update-checker.ts"; import { maybeShowStatusBar } from "./output/status-bar.ts"; import { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; @@ -134,8 +134,8 @@ async function main() { const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update"; const newVersion = getPendingUpdateNotification(); if (newVersion && !config.quiet && !isUpdateCommand) { - if (isMajorUpgrade(newVersion, CLI_VERSION)) { - // 大版本差距,自动更新 + if (shouldAutoUpdate(newVersion, CLI_VERSION)) { + // 大版本差距且目标为稳定版,自动更新 await performAutoUpdate(CLI_VERSION, newVersion); } else { // 普通小版本提示 diff --git a/packages/cli/src/utils/update-checker.ts b/packages/cli/src/utils/update-checker.ts index 1390301..a4c8e91 100644 --- a/packages/cli/src/utils/update-checker.ts +++ b/packages/cli/src/utils/update-checker.ts @@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h const FETCH_TIMEOUT_MS = 3000; /** - * Simple semver comparison: returns true if a > b. - * Supports standard x.y.z format. + * Parse a version string into a numeric [major, minor, patch] tuple. + * + * Pre-release (`-beta.1`) and build (`+build.42`) metadata are stripped + * first, and any non-numeric segment is coerced to 0. This guarantees we + * never produce `NaN` (which makes every comparison silently false) for + * versions like `2.0.0-beta.1` where `Number("0-beta")` would otherwise be + * `NaN`. */ -function isNewerVersion(a: string, b: string): boolean { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - for (let i = 0; i < 3; i++) { - if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true; - if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false; +export function parseVersion(version: string): [number, number, number] { + const core = String(version).split("+")[0].split("-")[0].trim(); + const parts = core.split(".").map((s) => { + const n = Number(s); + return Number.isFinite(n) ? n : 0; + }); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +/** + * Extract the pre-release suffix of a version (e.g. `1.4.2-beta.1` -> `beta.1`), + * ignoring build metadata. Returns `""` for a plain release (`1.4.2`). + */ +function prereleaseOf(version: string): string { + const core = String(version).split("+")[0]; + const idx = core.indexOf("-"); + return idx >= 0 ? core.slice(idx + 1) : ""; +} + +/** + * True if the version is a pre-release (carries a `-suffix`), e.g. + * `1.4.2-beta.1` or `0.0.0-beta-e0a7c86`. Build metadata (`+build`) is ignored. + */ +export function isPrerelease(version: string): boolean { + return prereleaseOf(version) !== ""; +} + +function isNumericIdentifier(s: string): boolean { + return s.length > 0 && /^[0-9]+$/.test(s); +} + +/** + * Compare two pre-release suffixes per semver precedence rules. + * Returns >0 if `a` has higher precedence, <0 if lower, 0 if equal. + * + * A release (empty suffix) has HIGHER precedence than any pre-release, so: + * comparePrerelease("", "beta.1") -> >0 (1.4.2 > 1.4.2-beta.1) + * comparePrerelease("beta.1", "") -> <0 + * + * When both are pre-releases, dot-separated identifiers are compared left to + * right: numeric identifiers numerically, alphanumeric lexically (ASCII), and + * a numeric identifier has lower precedence than an alphanumeric one. + */ +function comparePrerelease(aPre: string, bPre: string): number { + const aIds = aPre ? aPre.split(".") : []; + const bIds = bPre ? bPre.split(".") : []; + if (aIds.length === 0 && bIds.length === 0) return 0; + // A version without a pre-release outranks one with a pre-release. + if (aIds.length === 0) return 1; + if (bIds.length === 0) return -1; + const len = Math.max(aIds.length, bIds.length); + for (let i = 0; i < len; i++) { + const ai = aIds[i]; + const bi = bIds[i]; + if (ai === undefined) return -1; // fewer identifiers -> lower precedence + if (bi === undefined) return 1; + const aNum = isNumericIdentifier(ai); + const bNum = isNumericIdentifier(bi); + if (aNum && bNum) { + const diff = Number(ai) - Number(bi); + if (diff !== 0) return diff > 0 ? 1 : -1; + } else if (aNum !== bNum) { + // Numeric identifier has lower precedence than a non-numeric one. + return aNum ? -1 : 1; + } else if (ai !== bi) { + return ai > bi ? 1 : -1; + } } - return false; // equal + return 0; +} + +/** + * Full semver precedence comparison. + * Returns >0 if `a > b`, <0 if `a < b`, 0 if equal. + * Respects pre-release precedence (release > pre-release). + */ +export function compareVersion(a: string, b: string): number { + const [pa0, pa1, pa2] = parseVersion(a); + const [pb0, pb1, pb2] = parseVersion(b); + if (pa0 !== pb0) return pa0 > pb0 ? 1 : -1; + if (pa1 !== pb1) return pa1 > pb1 ? 1 : -1; + if (pa2 !== pb2) return pa2 > pb2 ? 1 : -1; + return comparePrerelease(prereleaseOf(a), prereleaseOf(b)); +} + +/** + * Semver comparison: returns true if a > b. + * Handles pre-release and build metadata with correct precedence, so a stable + * release is correctly detected as newer than its own pre-release + * (`isNewerVersion("1.4.2", "1.4.2-beta.1")` -> true). + */ +export function isNewerVersion(a: string, b: string): boolean { + return compareVersion(a, b) > 0; +} + +/** + * Policy gate for unattended auto-update. + * + * Auto-update runs `npm install -g @latest` without supervision, so it must + * only target a stable release — never a pre-release (`2.0.0-beta.1`), since + * silently jumping a user onto a beta channel is unsafe. A pre-release latest + * is reported as a notification instead. + * + * Combined with `isMajorUpgrade`, the rule is: a significant version gap + * (major bump or minor gap > 3) AND the target is a stable release. + */ +export function shouldAutoUpdate(latest: string, current: string): boolean { + return isMajorUpgrade(latest, current) && !isPrerelease(latest); } interface UpdateState { @@ -78,8 +183,8 @@ export function getPendingUpdateNotification(): string | null { * 2. Same major, but new minor - current minor > 3 */ export function isMajorUpgrade(latest: string, current: string): boolean { - const [latestMajor, latestMinor] = latest.split(".").map(Number); - const [currentMajor, currentMinor] = current.split(".").map(Number); + const [latestMajor, latestMinor] = parseVersion(latest); + const [currentMajor, currentMinor] = parseVersion(current); // Condition 1: major version bump if (latestMajor > currentMajor) return true; @@ -90,6 +195,15 @@ export function isMajorUpgrade(latest: string, current: string): boolean { return false; } +/** + * Extract a single-line error message from an unknown thrown value, + * so failures can be surfaced to the user instead of swallowed. + */ +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + /** * Perform auto-update: install latest version globally and update agent skill. * Returns true if update succeeded, false otherwise. @@ -105,8 +219,8 @@ export async function performAutoUpdate( const dim = isTTY ? "\x1b[2m" : ""; const reset = isTTY ? "\x1b[0m" : ""; - const [latestMajor] = latestVersion.split(".").map(Number); - const [currentMajor] = currentVersion.split(".").map(Number); + const [latestMajor] = parseVersion(latestVersion); + const [currentMajor] = parseVersion(currentVersion); const isMajorBump = latestMajor > currentMajor; process.stderr.write("\n"); @@ -129,29 +243,28 @@ export async function performAutoUpdate( const { execSync } = await import("child_process"); execSync(cmd, { stdio: "inherit" }); - // Verify installed version + // Verify the actually-installed version by reading the global package.json. + // We must NOT rely on `bl --version`: the user may run via npx, a local + // install, or a custom bin name, in which case `bl` on PATH points at the + // wrong binary (or nothing at all). Reading the installed package directly + // is correct regardless of how the CLI was invoked. let newVer: string | null = null; try { - const rawVer = execSync("bl --version 2>/dev/null", { encoding: "utf-8" }).trim(); - newVer = rawVer.replace(/^bl\s+/, ""); - } catch { - /* ignore */ - } - - // Update cached state - try { - const { writeFileSync } = await import("fs"); - const { join } = await import("path"); - const { getConfigDir } = await import("bailian-cli-core"); - const stateFile = join(getConfigDir(), "update-state.json"); - writeFileSync( - stateFile, - JSON.stringify({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion }), + const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); + const pkgPath = join(globalRoot, NPM_PACKAGE, "package.json"); + const rawPkg = readFileSync(pkgPath, "utf-8"); + const pkg = JSON.parse(rawPkg) as { version?: string }; + newVer = pkg.version ?? null; + } catch (err) { + process.stderr.write( + ` ${yellow}⚠ Could not verify installed version: ${errorMessage(err)}${reset}\n`, ); - } catch { - /* ignore */ } + // Update cached state. writeState swallows errors internally: state caching + // is non-critical and must never break the CLI startup path. + writeState({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion }); + process.stderr.write( ` ${green}✓ Update complete: ${currentVersion} → ${newVer ?? latestVersion}${reset}\n`, ); @@ -159,22 +272,29 @@ export async function performAutoUpdate( // Update agent skill try { - const { execSync: exec } = await import("child_process"); process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`); - exec(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" }); + execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" }); process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`); - } catch { + } catch (err) { + // Surface the reason the skill sync failed rather than swallowing it + // silently, but keep degradation: the CLI itself already updated. + process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`); process.stderr.write( - ` ${yellow}Agent skill sync skipped (run manually: npx skills add modelstudioai/cli --all -g -y)${reset}\n\n`, + ` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`, ); } // Clear pending notification pendingNotification = null; return true; - } catch { - process.stderr.write(` ${yellow}⚠ Auto-update failed. Please run manually:${reset}\n`); - process.stderr.write(` ${cyan}${cmd}${reset}\n\n`); + } catch (err) { + // npm install failure — most commonly EACCES (global installs often need + // elevated permissions). Tell the user *why* it failed, not just *that*. + process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`); + process.stderr.write( + ` ${yellow} If this is a permissions error (EACCES), retry with sudo or fix npm perms.${reset}\n`, + ); + process.stderr.write(` ${yellow} Run manually:${reset} ${cyan}${cmd}${reset}\n\n`); return false; } } diff --git a/packages/cli/tests/update-checker.test.ts b/packages/cli/tests/update-checker.test.ts new file mode 100644 index 0000000..94886ea --- /dev/null +++ b/packages/cli/tests/update-checker.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from "vite-plus/test"; +import { + compareVersion, + isMajorUpgrade, + isNewerVersion, + isPrerelease, + parseVersion, + shouldAutoUpdate, +} from "../src/utils/update-checker.ts"; + +test("parseVersion strips pre-release and build metadata", () => { + expect(parseVersion("1.4.2")).toEqual([1, 4, 2]); + expect(parseVersion("2.0.0-beta.1")).toEqual([2, 0, 0]); + expect(parseVersion("2.0.0+build.42")).toEqual([2, 0, 0]); + expect(parseVersion("2.0.0-beta.1+build.42")).toEqual([2, 0, 0]); +}); + +test("parseVersion coerces non-numeric segments to 0 instead of NaN", () => { + const [a, b, c] = parseVersion("2.0.0-beta.1"); + expect(Number.isNaN(a)).toBe(false); + expect(Number.isNaN(b)).toBe(false); + expect(Number.isNaN(c)).toBe(false); + expect([a, b, c]).toEqual([2, 0, 0]); +}); + +test("isNewerVersion never misjudges pre-release versions as equal", () => { + // Pre-release of a higher version must still be detected as newer. + expect(isNewerVersion("2.0.0-beta.1", "1.4.2")).toBe(true); + // Pre-release target must not be considered newer than an equal release. + expect(isNewerVersion("1.4.2", "2.0.0-beta.1")).toBe(false); + // Patch bump still detected. + expect(isNewerVersion("1.4.3", "1.4.2")).toBe(true); + // Equal versions are not newer. + expect(isNewerVersion("1.4.2", "1.4.2")).toBe(false); + // A release outranks its own pre-release: the release IS newer. + expect(isNewerVersion("1.4.2", "1.4.2-beta.1")).toBe(true); + // ...and the pre-release is NOT newer than the release. + expect(isNewerVersion("1.4.2-beta.1", "1.4.2")).toBe(false); +}); + +test("isMajorUpgrade handles pre-release versions without false negatives", () => { + // Major bump through a pre-release channel must trigger. + expect(isMajorUpgrade("2.0.0-beta.1", "1.4.2")).toBe(true); + // Small minor gap does not trigger. + expect(isMajorUpgrade("1.5.0", "1.4.2")).toBe(false); + // Minor gap > 3 triggers within the same major. + expect(isMajorUpgrade("1.8.0", "1.4.2")).toBe(true); + // No upgrade. + expect(isMajorUpgrade("1.4.2", "1.4.2")).toBe(false); + // Pre-release of the same major/minor does not trigger. + expect(isMajorUpgrade("1.4.2-beta.1", "1.4.2")).toBe(false); +}); + +// All published beta builds use the `0.0.0-` convention today. Under that +// scheme a beta collapses to [0,0,0], so a stable release is always a major +// upgrade over a beta. The robust invariants — regardless of hash ordering — +// are: beta -> stable auto-updates, canary -> canary never auto-updates, and a +// stable user is never upgraded down to a canary. +test("beta builds (0.0.0-*) auto-update to stable, never to another beta", () => { + const beta = "0.0.0-beta-e0a7c86-20260624"; + const otherBeta = "0.0.0-beta-aaaaaaaa-20260625"; + const stable = "1.4.2"; + + // beta -> stable: newer, significant, and stable -> auto-update + expect(isNewerVersion(stable, beta)).toBe(true); + expect(shouldAutoUpdate(stable, beta)).toBe(true); + + // canary -> canary: never auto-updates (same core, no major gap) + expect(shouldAutoUpdate(otherBeta, beta)).toBe(false); + expect(shouldAutoUpdate(beta, otherBeta)).toBe(false); + + // stable -> canary: never an upgrade + expect(isNewerVersion(beta, stable)).toBe(false); + expect(shouldAutoUpdate(beta, stable)).toBe(false); +}); + +test("isPrerelease detects pre-release suffixes and ignores build metadata", () => { + expect(isPrerelease("1.4.2")).toBe(false); + expect(isPrerelease("1.4.2-beta.1")).toBe(true); + expect(isPrerelease("0.0.0-beta-e0a7c86-20260624")).toBe(true); + expect(isPrerelease("2.0.0-alpha")).toBe(true); + // Build metadata alone does not make a version a pre-release. + expect(isPrerelease("1.4.2+build.42")).toBe(false); + expect(isPrerelease("1.4.2-beta.1+build.42")).toBe(true); +}); + +test("compareVersion respects full semver pre-release precedence", () => { + // Release outranks its own pre-release (the case the old stripper missed). + expect(compareVersion("1.4.2", "1.4.2-beta.1")).toBeGreaterThan(0); + expect(compareVersion("1.4.2-beta.1", "1.4.2")).toBeLessThan(0); + // Numeric pre-release identifiers compared numerically. + expect(compareVersion("1.4.2-beta.11", "1.4.2-beta.2")).toBeGreaterThan(0); + // Alphanumeric identifiers compared lexically (rc > beta > alpha). + expect(compareVersion("1.4.2-rc.1", "1.4.2-beta.5")).toBeGreaterThan(0); + expect(compareVersion("1.4.2-beta.1", "1.4.2-alpha.1")).toBeGreaterThan(0); + // Numeric identifier has lower precedence than a non-numeric one. + expect(compareVersion("1.4.2-alpha.beta", "1.4.2-alpha.1")).toBeGreaterThan(0); + // Canonical semver ordering end-to-end. + const ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ]; + for (let i = 0; i < ordered.length - 1; i++) { + expect(compareVersion(ordered[i + 1]!, ordered[i]!)).toBeGreaterThan(0); + } +}); + +test("shouldAutoUpdate only targets stable releases with a significant gap", () => { + // Stable major bump: auto-update. + expect(shouldAutoUpdate("2.0.0", "1.4.2")).toBe(true); + // Stable minor gap > 3: auto-update. + expect(shouldAutoUpdate("1.8.0", "1.4.2")).toBe(true); + // Small stable gap: notify only. + expect(shouldAutoUpdate("1.5.0", "1.4.2")).toBe(false); + // Pre-release target is NEVER auto-installed, even on a major bump. + expect(shouldAutoUpdate("2.0.0-beta.1", "1.4.2")).toBe(false); + expect(shouldAutoUpdate("2.0.0-rc.1", "1.4.2")).toBe(false); + // Same core, release over its pre-release: notify only (no major gap). + expect(shouldAutoUpdate("1.4.2", "1.4.2-beta.1")).toBe(false); +});