diff --git a/packages/stack-cli/src/commands/dev.test.ts b/packages/stack-cli/src/commands/dev.test.ts index 91fee80ac..eecefb13e 100644 --- a/packages/stack-cli/src/commands/dev.test.ts +++ b/packages/stack-cli/src/commands/dev.test.ts @@ -3,7 +3,54 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { recordLocalDashboardProcess } from "../lib/dev-env-state.js"; -import { killLocalDashboard, processExists, shouldRestartDashboard } from "./dev.js"; +import { isVersionNewer, killLocalDashboard, processExists, shouldRestartDashboard } from "./dev.js"; + +describe("isVersionNewer", () => { + it("compares core versions numerically", () => { + expect(isVersionNewer("2.8.110", "2.8.109")).toBe(true); + expect(isVersionNewer("2.9.0", "2.8.999")).toBe(true); + expect(isVersionNewer("3.0.0", "2.999.999")).toBe(true); + expect(isVersionNewer("2.8.109", "2.8.109")).toBe(false); + expect(isVersionNewer("2.8.108", "2.8.109")).toBe(false); + }); + + it("does not treat double-digit segments as strings", () => { + expect(isVersionNewer("2.8.10", "2.8.9")).toBe(true); + }); + + it("ranks a final release above a prerelease of the same core", () => { + expect(isVersionNewer("2.8.109", "2.8.109-beta.1")).toBe(true); + expect(isVersionNewer("2.8.109-beta.1", "2.8.109")).toBe(false); + }); + + it("returns false for unparseable versions (never downgrade or guess)", () => { + expect(isVersionNewer("garbage", "2.8.109")).toBe(false); + expect(isVersionNewer("2.8.110", "garbage")).toBe(false); + }); + + it("tolerates a leading v and surrounding whitespace on either side", () => { + expect(isVersionNewer("v2.8.110", "2.8.109")).toBe(true); + expect(isVersionNewer("2.8.110", "v2.8.109")).toBe(true); + expect(isVersionNewer(" 2.8.110 ", "2.8.109")).toBe(true); + expect(isVersionNewer("v2.8.110", "v2.8.110")).toBe(false); + }); + + it("treats a two-segment version (x.y) as unparseable", () => { + expect(isVersionNewer("2.8", "2.8.109")).toBe(false); + expect(isVersionNewer("2.8.109", "2.8")).toBe(false); + }); + + it("ignores prerelease identifiers when both cores are equal prereleases", () => { + // Only "release beats prerelease" is modeled; beta.2 is NOT newer than beta.1. + expect(isVersionNewer("2.8.109-beta.2", "2.8.109-beta.1")).toBe(false); + expect(isVersionNewer("2.8.109-beta.1", "2.8.109-beta.2")).toBe(false); + }); + + it("compares very large numeric segments correctly", () => { + expect(isVersionNewer("2.8.1000000000", "2.8.999999999")).toBe(true); + expect(isVersionNewer("10000000000.0.0", "9999999999.0.0")).toBe(true); + }); +}); describe("shouldRestartDashboard", () => { it("restarts only when ours is strictly newer than the running dashboard", () => { diff --git a/packages/stack-cli/src/commands/dev.ts b/packages/stack-cli/src/commands/dev.ts index 62b4f44a6..6a38b4a34 100644 --- a/packages/stack-cli/src/commands/dev.ts +++ b/packages/stack-cli/src/commands/dev.ts @@ -9,7 +9,7 @@ import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess } from "../lib/dev-env-state.js"; import { CliError } from "../lib/errors.js"; import { cliVersion } from "../lib/own-package.js"; -import { isVersionNewer, maybeReexecToLatest } from "../lib/self-update.js"; +import { maybeReexecToLatest } from "../lib/self-update.js"; type ChildCommand = { command: string, @@ -273,6 +273,43 @@ async function isDashboardReachable(url: string): Promise { } } +type ParsedVersion = { + core: [number, number, number], + hasPrerelease: boolean, +}; + +function parseVersionCore(version: string): ParsedVersion | null { + const trimmed = version.trim(); + const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(trimmed); + if (!match) return null; + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + // A `-` immediately after the core marks a semver prerelease (e.g. + // 2.8.109-beta.1). `.test()` returns a plain boolean, sidestepping the + // optional-capture-group typing. + hasPrerelease: /^v?\d+\.\d+\.\d+-/.test(trimmed), + }; +} + +// Returns true only when `candidate` is strictly newer than `current`. Unknown +// or unparseable versions return false so we never act on a version we can't +// reason about (and never downgrade). Prerelease identifiers beyond the +// "release beats same-core prerelease" rule are intentionally not ordered. Only +// the dashboard restart check below needs this; the CLI re-exec just always runs +// `@latest`. Exported for unit testing. +export function isVersionNewer(candidate: string, current: string): boolean { + const a = parseVersionCore(candidate); + const b = parseVersionCore(current); + if (a == null || b == null) return false; + for (let i = 0; i < 3; i++) { + if (a.core[i] !== b.core[i]) { + return a.core[i] > b.core[i]; + } + } + // Same x.y.z: a final release outranks a prerelease of the same core. + return !a.hasPrerelease && b.hasPrerelease; +} + // Restart the running dashboard only when ours is strictly newer; this is how a // re-exec'd `npx @latest` rolls out a fresh dashboard without a reinstall. // Equal/older/unknown versions (e.g. a dashboard recorded by a pre-feature CLI diff --git a/packages/stack-cli/src/lib/self-update.test.ts b/packages/stack-cli/src/lib/self-update.test.ts index 927af30eb..2b35eb5f3 100644 --- a/packages/stack-cli/src/lib/self-update.test.ts +++ b/packages/stack-cli/src/lib/self-update.test.ts @@ -4,7 +4,6 @@ import { decideReexec, DISABLE_AUTO_UPDATE_ENV, isEnvFlagEnabled, - isVersionNewer, maybeReexecToLatest, shouldAutoUpdate, SKIP_AUTO_UPDATE_ENV, @@ -52,53 +51,6 @@ describe("shouldAutoUpdate", () => { }); }); -describe("isVersionNewer", () => { - it("compares core versions numerically", () => { - expect(isVersionNewer("2.8.110", "2.8.109")).toBe(true); - expect(isVersionNewer("2.9.0", "2.8.999")).toBe(true); - expect(isVersionNewer("3.0.0", "2.999.999")).toBe(true); - expect(isVersionNewer("2.8.109", "2.8.109")).toBe(false); - expect(isVersionNewer("2.8.108", "2.8.109")).toBe(false); - }); - - it("does not treat double-digit segments as strings", () => { - expect(isVersionNewer("2.8.10", "2.8.9")).toBe(true); - }); - - it("ranks a final release above a prerelease of the same core", () => { - expect(isVersionNewer("2.8.109", "2.8.109-beta.1")).toBe(true); - expect(isVersionNewer("2.8.109-beta.1", "2.8.109")).toBe(false); - }); - - it("returns false for unparseable versions (never downgrade or guess)", () => { - expect(isVersionNewer("garbage", "2.8.109")).toBe(false); - expect(isVersionNewer("2.8.110", "garbage")).toBe(false); - }); - - it("tolerates a leading v and surrounding whitespace on either side", () => { - expect(isVersionNewer("v2.8.110", "2.8.109")).toBe(true); - expect(isVersionNewer("2.8.110", "v2.8.109")).toBe(true); - expect(isVersionNewer(" 2.8.110 ", "2.8.109")).toBe(true); - expect(isVersionNewer("v2.8.110", "v2.8.110")).toBe(false); - }); - - it("treats a two-segment version (x.y) as unparseable", () => { - expect(isVersionNewer("2.8", "2.8.109")).toBe(false); - expect(isVersionNewer("2.8.109", "2.8")).toBe(false); - }); - - it("ignores prerelease identifiers when both cores are equal prereleases", () => { - // Only "release beats prerelease" is modeled; beta.2 is NOT newer than beta.1. - expect(isVersionNewer("2.8.109-beta.2", "2.8.109-beta.1")).toBe(false); - expect(isVersionNewer("2.8.109-beta.1", "2.8.109-beta.2")).toBe(false); - }); - - it("compares very large numeric segments correctly", () => { - expect(isVersionNewer("2.8.1000000000", "2.8.999999999")).toBe(true); - expect(isVersionNewer("10000000000.0.0", "9999999999.0.0")).toBe(true); - }); -}); - describe("buildNpxInvocation", () => { it("pins @latest and forwards the subcommand through the bin", () => { const { command, args } = buildNpxInvocation({ diff --git a/packages/stack-cli/src/lib/self-update.ts b/packages/stack-cli/src/lib/self-update.ts index 46bd7b368..16655014c 100644 --- a/packages/stack-cli/src/lib/self-update.ts +++ b/packages/stack-cli/src/lib/self-update.ts @@ -31,43 +31,6 @@ export function shouldAutoUpdate(env: NodeJS.ProcessEnv): boolean { return true; } -type ParsedVersion = { - core: [number, number, number], - hasPrerelease: boolean, -}; - -function parseVersionCore(version: string): ParsedVersion | null { - const trimmed = version.trim(); - const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(trimmed); - if (!match) return null; - return { - core: [Number(match[1]), Number(match[2]), Number(match[3])], - // A `-` immediately after the core marks a semver prerelease (e.g. - // 2.8.109-beta.1). `.test()` returns a plain boolean, sidestepping the - // optional-capture-group typing. - hasPrerelease: /^v?\d+\.\d+\.\d+-/.test(trimmed), - }; -} - -// Returns true only when `candidate` is strictly newer than `current`. Unknown -// or unparseable versions return false so we never act on a version we can't -// reason about (and never downgrade). Prerelease identifiers beyond the -// "release beats same-core prerelease" rule are intentionally not ordered. -// Used by the dashboard restart check in dev.ts (the re-exec itself just always -// runs `@latest`). -export function isVersionNewer(candidate: string, current: string): boolean { - const a = parseVersionCore(candidate); - const b = parseVersionCore(current); - if (a == null || b == null) return false; - for (let i = 0; i < 3; i++) { - if (a.core[i] !== b.core[i]) { - return a.core[i] > b.core[i]; - } - } - // Same x.y.z: a final release outranks a prerelease of the same core. - return !a.hasPrerelease && b.hasPrerelease; -} - export type NpxInvocation = { command: string, args: string[],