refactor(cli): move version-compare helper into dev.ts

isVersionNewer/parseVersionCore no longer gate the npx re-exec (which
always runs @latest); their only remaining consumer is the dashboard
restart check in dev.ts. Move them (and their tests) there so
self-update.ts is purely about the re-exec.
This commit is contained in:
Bilal Godil 2026-06-03 14:41:19 -07:00
parent e8f25edc14
commit 6df30665a4
4 changed files with 86 additions and 87 deletions

View File

@ -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", () => {

View File

@ -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<boolean> {
}
}
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

View File

@ -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({

View File

@ -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[],