diff --git a/package-lock.json b/package-lock.json index 049e1ff80..626cb053a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", + "semver": "7.8.5", "smol-toml": "^1.7.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", @@ -40,6 +41,7 @@ "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.4", "@types/node": "24.10.1", + "@types/semver": "7.7.1", "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", @@ -1718,6 +1720,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4347,7 +4356,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index e3a3148a4..329e662c7 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", + "semver": "7.8.5", "smol-toml": "^1.7.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", @@ -116,6 +117,7 @@ "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.4", "@types/node": "24.10.1", + "@types/semver": "7.7.1", "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", diff --git a/src/application/Upgrade.ts b/src/application/Upgrade.ts index f8a8b9704..756c5d5e4 100644 --- a/src/application/Upgrade.ts +++ b/src/application/Upgrade.ts @@ -4,6 +4,7 @@ import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { gt, valid } from "semver"; import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; @@ -59,7 +60,7 @@ export type UpgradeResult = readonly status: "failed"; readonly currentVersion: string; readonly latestVersion: string | null; - readonly reason: "unknown-install-method" | "install"; + readonly reason: "unknown-install-method" | "version-check" | "install"; readonly remediation: string; }; @@ -70,7 +71,20 @@ export const runUpgrade = async ( output: UpgradeOutput = "human", ): Promise => { const latestVersion = await host.latestVersion(); - if (latestVersion === currentVersion) + const currentIsValid = valid(currentVersion) !== null; + const latestIsValid = + latestVersion !== undefined && valid(latestVersion) !== null; + if (!currentIsValid || !latestIsValid) + return { + status: "failed", + currentVersion, + latestVersion: latestVersion ?? null, + reason: "version-check", + remediation: + "REA could not verify a newer registry release. Check npm registry access and version metadata, then rerun rea upgrade.", + }; + + if (!gt(latestVersion, currentVersion)) return { status: "current", currentVersion, @@ -100,8 +114,8 @@ export const runUpgrade = async ( return { status: "upgraded", previousVersion: currentVersion, - latestVersion: latestVersion ?? null, - versionCheck: latestVersion === undefined ? "unavailable" : "available", + latestVersion, + versionCheck: "available", installMethod: "npm", command: UPGRADE_COMMAND, clientRestartRequired: true, diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index 9344d186a..f3399c855 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -61,18 +61,62 @@ describe("CLI upgrade", () => { expect(host.installs).toBe(1); }); - it("continues with npm latest when the registry check is unavailable", async () => { + it("does not mutate when the registry check is unavailable", async () => { const host = new FakeUpgradeHost(); host.latest = undefined; expect(await runUpgrade("0.5.0", host)).toMatchObject({ - status: "upgraded", + status: "failed", latestVersion: null, - versionCheck: "unavailable", + reason: "version-check", + }); + expect(host.installs).toBe(0); + }); + + it.each([ + ["1.10.0", "1.9.0"], + ["1.0.0", "1.0.0-rc.1"], + ])( + "does not downgrade installed version %s to registry version %s", + async (currentVersion, latestVersion) => { + const host = new FakeUpgradeHost(); + host.latest = latestVersion; + + expect(await runUpgrade(currentVersion, host)).toEqual({ + status: "current", + currentVersion, + latestVersion, + installMethod: "npm", + }); + expect(host.installs).toBe(0); + }, + ); + + it("upgrades a prerelease when the final release is available", async () => { + const host = new FakeUpgradeHost(); + host.latest = "1.0.0"; + + expect(await runUpgrade("1.0.0-rc.1", host)).toMatchObject({ + status: "upgraded", + previousVersion: "1.0.0-rc.1", + latestVersion: "1.0.0", }); expect(host.installs).toBe(1); }); + it("rejects invalid registry version metadata without mutation", async () => { + const host = new FakeUpgradeHost(); + host.latest = "latest"; + + expect(await runUpgrade("1.0.0", host)).toMatchObject({ + status: "failed", + currentVersion: "1.0.0", + latestVersion: "latest", + reason: "version-check", + }); + expect(host.installs).toBe(0); + }); + it("preserves stdout for an explicitly structured CLI response", async () => { const host = new FakeUpgradeHost();