fix(upgrade): prevent version downgrades

This commit is contained in:
N0zoM1z0 2026-07-14 20:40:52 +00:00
parent 528c05e2fa
commit 1d1d234eed
4 changed files with 76 additions and 8 deletions

10
package-lock.json generated
View File

@ -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"

View File

@ -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",

View File

@ -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<UpgradeResult> => {
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,

View File

@ -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();