mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor: use semver package for version ordering (#105944)
* refactor: use semver package for version ordering * refactor: centralize semver validation * chore: keep release notes in PR body * refactor: update semver LOC ratchet
This commit is contained in:
Generated
+13
@@ -9,6 +9,7 @@
|
||||
"version": "2026.7.2",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.144.1",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "1.7.0",
|
||||
"typebox": "1.3.3",
|
||||
"ws": "8.21.0",
|
||||
@@ -137,6 +138,18 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.144.1",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "1.7.0",
|
||||
"typebox": "1.3.3",
|
||||
"ws": "8.21.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
||||
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { compare as compareSemver, parse as parseSemver } from "semver";
|
||||
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
|
||||
import {
|
||||
type CodexAppServerRequestMethod,
|
||||
@@ -943,43 +944,28 @@ function readCodexVersionFromUserAgent(userAgent: string | undefined): string |
|
||||
|
||||
/** Compares stable Codex app-server versions for protocol floor checks. */
|
||||
function compareCodexAppServerVersions(left: string, right: string): number {
|
||||
const leftVersion = parseVersionForComparison(left);
|
||||
const rightVersion = parseVersionForComparison(right);
|
||||
const leftParts = leftVersion.parts;
|
||||
const rightParts = rightVersion.parts;
|
||||
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
||||
const leftPart = leftParts[index] ?? 0;
|
||||
const rightPart = rightParts[index] ?? 0;
|
||||
if (leftPart !== rightPart) {
|
||||
return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
const leftVersion = parseSemver(left);
|
||||
const rightVersion = parseSemver(right);
|
||||
if (!leftVersion || !rightVersion) {
|
||||
// Invalid detected versions fail below a valid protocol floor instead of bypassing it.
|
||||
return leftVersion ? 1 : rightVersion ? -1 : 0;
|
||||
}
|
||||
if (leftVersion.unstableSuffix && !rightVersion.unstableSuffix) {
|
||||
const precedence = compareSemver(leftVersion, rightVersion);
|
||||
if (precedence !== 0) {
|
||||
return precedence;
|
||||
}
|
||||
// Build metadata has no SemVer precedence, but custom Codex builds must not satisfy a stable floor.
|
||||
const leftUnstable = leftVersion.prerelease.length > 0 || leftVersion.build.length > 0;
|
||||
const rightUnstable = rightVersion.prerelease.length > 0 || rightVersion.build.length > 0;
|
||||
if (leftUnstable && !rightUnstable) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftVersion.unstableSuffix && rightVersion.unstableSuffix) {
|
||||
if (!leftUnstable && rightUnstable) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseVersionForComparison(version: string): { parts: number[]; unstableSuffix: boolean } {
|
||||
// Same-version prerelease or build-suffixed versions do not satisfy a stable
|
||||
// protocol floor because important app-server contract changes can land
|
||||
// between alpha cuts and custom builds.
|
||||
const hasBuildMetadata = version.includes("+");
|
||||
const [withoutBuild = version] = version.split("+", 1);
|
||||
const prereleaseIndex = withoutBuild.indexOf("-");
|
||||
const numeric = prereleaseIndex >= 0 ? withoutBuild.slice(0, prereleaseIndex) : withoutBuild;
|
||||
return {
|
||||
parts: numeric
|
||||
.split(".")
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.map((part) => (Number.isFinite(part) ? part : 0)),
|
||||
unstableSuffix: prereleaseIndex >= 0 || hasBuildMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
function redactCodexAppServerLinePreview(value: string): string {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
const redacted = compact
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@copilotkit/aimock": "1.35.0",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"playwright-core": "1.61.1",
|
||||
"semver": "7.8.5",
|
||||
"yaml": "2.9.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { coerce as coerceSemver } from "semver";
|
||||
|
||||
const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([
|
||||
"image-generation-core",
|
||||
@@ -25,41 +26,7 @@ function assertSafeQaBundledPluginId(pluginId: string) {
|
||||
}
|
||||
|
||||
function parseStableSemverFloor(value: string | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
major: Number.parseInt(match[1] ?? "", 10),
|
||||
minor: Number.parseInt(match[2] ?? "", 10),
|
||||
patch: Number.parseInt(match[3] ?? "", 10),
|
||||
label: `${match[1]}.${match[2]}.${match[3]}`,
|
||||
};
|
||||
}
|
||||
|
||||
function compareSemverFloors(
|
||||
left: ReturnType<typeof parseStableSemverFloor>,
|
||||
right: ReturnType<typeof parseStableSemverFloor>,
|
||||
) {
|
||||
if (!left && !right) {
|
||||
return 0;
|
||||
}
|
||||
if (!left) {
|
||||
return -1;
|
||||
}
|
||||
if (!right) {
|
||||
return 1;
|
||||
}
|
||||
if (left.major !== right.major) {
|
||||
return left.major - right.major;
|
||||
}
|
||||
if (left.minor !== right.minor) {
|
||||
return left.minor - right.minor;
|
||||
}
|
||||
return left.patch - right.patch;
|
||||
return value ? coerceSemver(value) : null;
|
||||
}
|
||||
|
||||
function isQaOpenAiResponsesProviderConfig(config: ModelProviderConfig) {
|
||||
@@ -392,12 +359,12 @@ export async function resolveQaRuntimeHostVersion(params: {
|
||||
};
|
||||
};
|
||||
const candidate = parseStableSemverFloor(packageJson.openclaw?.install?.minHostVersion);
|
||||
if (compareSemverFloors(candidate, selected) > 0) {
|
||||
if (candidate && (!selected || candidate.compare(selected) > 0)) {
|
||||
selected = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.label;
|
||||
return selected?.version;
|
||||
}
|
||||
|
||||
export async function createQaBundledPluginsDir(params: {
|
||||
|
||||
@@ -97,6 +97,21 @@ describe("codex plugin lifecycle: pinned-old codex plugin with new OpenClaw", ()
|
||||
'Codex plugin version 2026.5.19 is older than OpenClaw 2026.5.21. Run "openclaw plugins update codex" or unpin codex, then rerun "openclaw doctor --fix".',
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an equal-base prerelease plugin as older than the stable host", async () => {
|
||||
const agentDir = await createAgentDir("qa-codex-plugin-prerelease-");
|
||||
await seedCodexPluginAt("2026.5.21-beta.1", agentDir);
|
||||
await seedAuthProfiles("oauth-only", agentDir);
|
||||
|
||||
const result = evaluateCodexPluginLifecycle({
|
||||
plugin: await snapshotCodexPluginState(agentDir),
|
||||
auth: await snapshotAuthProfiles(agentDir),
|
||||
hostVersion: "2026.5.21",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("blocked");
|
||||
expect(result.remediation).toContain("is older than OpenClaw 2026.5.21");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex plugin lifecycle: pinned-new codex plugin with old OpenClaw", () => {
|
||||
@@ -116,6 +131,21 @@ describe("codex plugin lifecycle: pinned-new codex plugin with old OpenClaw", ()
|
||||
"Codex plugin version 2026.5.22 requires a newer OpenClaw host than 2026.5.21. Upgrade OpenClaw or install a codex plugin version pinned to 2026.5.21.",
|
||||
);
|
||||
});
|
||||
|
||||
it("orders a numeric correction plugin after the base stable host", async () => {
|
||||
const agentDir = await createAgentDir("qa-codex-plugin-correction-");
|
||||
await seedCodexPluginAt("2026.5.21-1", agentDir);
|
||||
await seedAuthProfiles("oauth-only", agentDir);
|
||||
|
||||
const result = evaluateCodexPluginLifecycle({
|
||||
plugin: await snapshotCodexPluginState(agentDir),
|
||||
auth: await snapshotAuthProfiles(agentDir),
|
||||
hostVersion: "2026.5.21",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("blocked");
|
||||
expect(result.remediation).toContain("requires a newer OpenClaw host than 2026.5.21");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex plugin lifecycle: install racing first agent turn", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Qa Lab plugin module implements codex plugin.fixture behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { compare as compareSemver, parse as parseSemver } from "semver";
|
||||
import { resolveCodexAuthProfile, type QaAuthProfileSnapshot } from "./auth-profile.fixture.js";
|
||||
|
||||
export const CODEX_PLUGIN_CURRENT_VERSION = "2026.5.21";
|
||||
@@ -43,12 +44,6 @@ type CodexPluginPackageJson = {
|
||||
};
|
||||
};
|
||||
|
||||
type ComparableVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
};
|
||||
|
||||
type CodexPluginInstallGateResult = {
|
||||
text: string;
|
||||
inputTokens: number;
|
||||
@@ -81,34 +76,38 @@ function buildPackageJson(version: string): CodexPluginPackageJson {
|
||||
};
|
||||
}
|
||||
|
||||
function parseComparableVersion(value: string | undefined): ComparableVersion | null {
|
||||
function parseComparableVersion(value: string | undefined) {
|
||||
if (!value || value === CODEX_PLUGIN_HEAD_VERSION) {
|
||||
return parseComparableVersion(CODEX_PLUGIN_CURRENT_VERSION);
|
||||
}
|
||||
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
major: Number.parseInt(match[1] ?? "0", 10),
|
||||
minor: Number.parseInt(match[2] ?? "0", 10),
|
||||
patch: Number.parseInt(match[3] ?? "0", 10),
|
||||
};
|
||||
return parseSemver(value.trim());
|
||||
}
|
||||
|
||||
function compareVersions(left: string | undefined, right: string): number {
|
||||
const leftVersion = parseComparableVersion(left);
|
||||
const rightVersion = parseComparableVersion(right);
|
||||
if (!leftVersion || !rightVersion) {
|
||||
return 0;
|
||||
type ParsedSemver = NonNullable<ReturnType<typeof parseSemver>>;
|
||||
|
||||
function compareCodexPluginVersions(left: ParsedSemver, right: ParsedSemver): number {
|
||||
const sameCore =
|
||||
left.major === right.major && left.minor === right.minor && left.patch === right.patch;
|
||||
const leftCorrection =
|
||||
left.prerelease.length === 1 && typeof left.prerelease[0] === "number"
|
||||
? left.prerelease[0]
|
||||
: null;
|
||||
const rightCorrection =
|
||||
right.prerelease.length === 1 && typeof right.prerelease[0] === "number"
|
||||
? right.prerelease[0]
|
||||
: null;
|
||||
if (sameCore && (leftCorrection !== null || rightCorrection !== null)) {
|
||||
// OpenClaw numeric suffixes are correction releases after stable, unlike SemVer prereleases.
|
||||
const leftRank = leftCorrection !== null ? 2 : left.prerelease.length === 0 ? 1 : 0;
|
||||
const rightRank = rightCorrection !== null ? 2 : right.prerelease.length === 0 ? 1 : 0;
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank < rightRank ? -1 : 1;
|
||||
}
|
||||
if (leftCorrection !== null && rightCorrection !== null) {
|
||||
return Math.sign(leftCorrection - rightCorrection);
|
||||
}
|
||||
}
|
||||
if (leftVersion.major !== rightVersion.major) {
|
||||
return leftVersion.major - rightVersion.major;
|
||||
}
|
||||
if (leftVersion.minor !== rightVersion.minor) {
|
||||
return leftVersion.minor - rightVersion.minor;
|
||||
}
|
||||
return leftVersion.patch - rightVersion.patch;
|
||||
return compareSemver(left, right);
|
||||
}
|
||||
|
||||
function formatPinnedOldRemediation(pluginVersion: string, hostVersion: string) {
|
||||
@@ -219,7 +218,10 @@ export function evaluateCodexPluginLifecycle(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const versionDelta = compareVersions(params.plugin.version, params.hostVersion);
|
||||
const pluginVersion = parseComparableVersion(params.plugin.version);
|
||||
const hostVersion = parseComparableVersion(params.hostVersion);
|
||||
const versionDelta =
|
||||
pluginVersion && hostVersion ? compareCodexPluginVersions(pluginVersion, hostVersion) : 0;
|
||||
if (versionDelta < 0 && params.plugin.version) {
|
||||
return {
|
||||
status: "blocked",
|
||||
|
||||
Generated
+13
@@ -56,6 +56,7 @@
|
||||
"qrcode": "1.5.4",
|
||||
"quickjs-wasi": "3.0.2",
|
||||
"rastermill": "0.3.1",
|
||||
"semver": "7.8.5",
|
||||
"tar": "7.5.19",
|
||||
"tree-sitter-bash": "0.25.1",
|
||||
"tslog": "4.10.2",
|
||||
@@ -2900,6 +2901,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"qrcode": "1.5.4",
|
||||
"quickjs-wasi": "3.0.2",
|
||||
"rastermill": "0.3.1",
|
||||
"semver": "7.8.5",
|
||||
"tar": "7.5.19",
|
||||
"tree-sitter-bash": "0.25.1",
|
||||
"tslog": "4.10.2",
|
||||
@@ -2076,6 +2077,7 @@
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/node": "26.1.0",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
|
||||
Generated
+17
@@ -184,6 +184,9 @@ importers:
|
||||
rastermill:
|
||||
specifier: 0.3.1
|
||||
version: 0.3.1
|
||||
semver:
|
||||
specifier: 7.8.5
|
||||
version: 7.8.5
|
||||
tar:
|
||||
specifier: 7.5.19
|
||||
version: 7.5.19
|
||||
@@ -260,6 +263,9 @@ importers:
|
||||
'@types/proper-lockfile':
|
||||
specifier: 4.1.4
|
||||
version: 4.1.4
|
||||
'@types/semver':
|
||||
specifier: 7.7.1
|
||||
version: 7.7.1
|
||||
'@types/ws':
|
||||
specifier: 8.18.1
|
||||
version: 8.18.1
|
||||
@@ -539,6 +545,9 @@ importers:
|
||||
'@openai/codex':
|
||||
specifier: 0.144.1
|
||||
version: 0.144.1
|
||||
semver:
|
||||
specifier: 7.8.5
|
||||
version: 7.8.5
|
||||
smol-toml:
|
||||
specifier: 1.7.0
|
||||
version: 1.7.0
|
||||
@@ -1396,6 +1405,9 @@ importers:
|
||||
playwright-core:
|
||||
specifier: 1.61.1
|
||||
version: 1.61.1
|
||||
semver:
|
||||
specifier: 7.8.5
|
||||
version: 7.8.5
|
||||
yaml:
|
||||
specifier: 2.9.0
|
||||
version: 2.9.0
|
||||
@@ -4543,6 +4555,9 @@ packages:
|
||||
'@types/sarif@2.1.7':
|
||||
resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==}
|
||||
|
||||
'@types/semver@7.7.1':
|
||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
@@ -10620,6 +10635,8 @@ snapshots:
|
||||
|
||||
'@types/sarif@2.1.7': {}
|
||||
|
||||
'@types/semver@7.7.1': {}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 26.1.0
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"extensions/codex/src/app-server/attempt-turn-watches.ts": 517,
|
||||
"extensions/codex/src/app-server/auth-bridge.ts": 1011,
|
||||
"extensions/codex/src/app-server/bounded-turn.ts": 535,
|
||||
"extensions/codex/src/app-server/client.ts": 1061,
|
||||
"extensions/codex/src/app-server/client.ts": 1047,
|
||||
"extensions/codex/src/app-server/compact.ts": 916,
|
||||
"extensions/codex/src/app-server/computer-use.ts": 1264,
|
||||
"extensions/codex/src/app-server/config.ts": 2492,
|
||||
@@ -961,7 +961,7 @@
|
||||
"src/infra/backup-create.ts": 976,
|
||||
"src/infra/bonjour-discovery.ts": 629,
|
||||
"src/infra/clawhub-install-trust.ts": 1098,
|
||||
"src/infra/clawhub.ts": 2001,
|
||||
"src/infra/clawhub.ts": 1999,
|
||||
"src/infra/command-analysis/inline-eval.ts": 526,
|
||||
"src/infra/command-explainer/extract.ts": 1391,
|
||||
"src/infra/device-bootstrap.ts": 509,
|
||||
@@ -1000,7 +1000,7 @@
|
||||
"src/infra/state-migrations.debug-proxy.ts": 578,
|
||||
"src/infra/state-migrations.ts": 6443,
|
||||
"src/infra/unhandled-rejections.ts": 566,
|
||||
"src/infra/update-check.ts": 746,
|
||||
"src/infra/update-check.ts": 745,
|
||||
"src/infra/update-global.ts": 1015,
|
||||
"src/infra/update-managed-service-handoff.ts": 830,
|
||||
"src/infra/update-runner.ts": 1874,
|
||||
@@ -1098,7 +1098,7 @@
|
||||
"src/plugins/tools.ts": 1573,
|
||||
"src/plugins/types.ts": 3071,
|
||||
"src/plugins/uninstall.ts": 780,
|
||||
"src/plugins/update.ts": 2745,
|
||||
"src/plugins/update.ts": 2742,
|
||||
"src/process/command-queue.ts": 700,
|
||||
"src/process/exec.ts": 773,
|
||||
"src/proxy-capture/runtime.ts": 668,
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("compareOpenClawVersions", () => {
|
||||
});
|
||||
|
||||
it("treats stable as newer than beta and compares beta identifiers", () => {
|
||||
expect(compareOpenClawVersions("2026.6.5", "2026.6.6-beta.1")).toBe(-1);
|
||||
expect(compareOpenClawVersions("2026.3.23", "2026.3.23-beta.1")).toBe(1);
|
||||
expect(compareOpenClawVersions("2026.3.23-beta.2", "2026.3.23-beta.1")).toBe(1);
|
||||
expect(compareOpenClawVersions("2026.3.23.beta.1", "2026.3.23-beta.2")).toBe(-1);
|
||||
|
||||
+42
-43
@@ -1,9 +1,6 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
// Normalizes config version metadata and compatibility comparisons.
|
||||
import {
|
||||
comparePrereleaseIdentifiers,
|
||||
normalizeLegacyDotBetaVersion,
|
||||
} from "../infra/semver-compare.js";
|
||||
import { compare as compareSemver, parse as parseSemver } from "semver";
|
||||
import { normalizeLegacyDotBetaVersion } from "../infra/semver.js";
|
||||
|
||||
type OpenClawVersion = {
|
||||
major: number;
|
||||
@@ -13,26 +10,29 @@ type OpenClawVersion = {
|
||||
prerelease: string[] | null;
|
||||
};
|
||||
|
||||
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
||||
|
||||
/** Parses stable, prerelease, and legacy dot-beta OpenClaw versions. */
|
||||
export function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeLegacyDotBetaVersion(raw.trim());
|
||||
const match = normalized.match(VERSION_RE);
|
||||
if (!match) {
|
||||
const parsed = parseSemver(normalized);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const [, major, minor, patch, suffix] = match;
|
||||
const revision = suffix && /^[0-9]+$/.test(suffix) ? Number.parseInt(suffix, 10) : null;
|
||||
const revision =
|
||||
parsed.prerelease.length === 1 && typeof parsed.prerelease[0] === "number"
|
||||
? parsed.prerelease[0]
|
||||
: null;
|
||||
return {
|
||||
major: Number.parseInt(expectDefined(major, "version major"), 10),
|
||||
minor: Number.parseInt(expectDefined(minor, "version minor"), 10),
|
||||
patch: Number.parseInt(expectDefined(patch, "version patch"), 10),
|
||||
major: parsed.major,
|
||||
minor: parsed.minor,
|
||||
patch: parsed.patch,
|
||||
revision,
|
||||
prerelease: suffix && revision == null ? suffix.split(".").filter(Boolean) : null,
|
||||
prerelease:
|
||||
parsed.prerelease.length > 0 && revision == null
|
||||
? parsed.prerelease.map((part) => String(part))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,35 +72,26 @@ export function compareOpenClawVersions(
|
||||
if (!parsedA || !parsedB) {
|
||||
return null;
|
||||
}
|
||||
if (parsedA.major !== parsedB.major) {
|
||||
return parsedA.major < parsedB.major ? -1 : 1;
|
||||
const sameCore =
|
||||
parsedA.major === parsedB.major &&
|
||||
parsedA.minor === parsedB.minor &&
|
||||
parsedA.patch === parsedB.patch;
|
||||
// Numeric suffixes are shipped OpenClaw correction releases, ordered after the base stable.
|
||||
if (sameCore && (parsedA.revision != null || parsedB.revision != null)) {
|
||||
const rankA = releaseRank(parsedA);
|
||||
const rankB = releaseRank(parsedB);
|
||||
if (rankA !== rankB) {
|
||||
return rankA < rankB ? -1 : 1;
|
||||
}
|
||||
if (
|
||||
parsedA.revision != null &&
|
||||
parsedB.revision != null &&
|
||||
parsedA.revision !== parsedB.revision
|
||||
) {
|
||||
return parsedA.revision < parsedB.revision ? -1 : 1;
|
||||
}
|
||||
}
|
||||
if (parsedA.minor !== parsedB.minor) {
|
||||
return parsedA.minor < parsedB.minor ? -1 : 1;
|
||||
}
|
||||
if (parsedA.patch !== parsedB.patch) {
|
||||
return parsedA.patch < parsedB.patch ? -1 : 1;
|
||||
}
|
||||
|
||||
const rankA = releaseRank(parsedA);
|
||||
const rankB = releaseRank(parsedB);
|
||||
if (rankA !== rankB) {
|
||||
return rankA < rankB ? -1 : 1;
|
||||
}
|
||||
|
||||
if (
|
||||
parsedA.revision != null &&
|
||||
parsedB.revision != null &&
|
||||
parsedA.revision !== parsedB.revision
|
||||
) {
|
||||
return parsedA.revision < parsedB.revision ? -1 : 1;
|
||||
}
|
||||
|
||||
if (parsedA.prerelease || parsedB.prerelease) {
|
||||
return comparePrereleaseIdentifiers(parsedA.prerelease, parsedB.prerelease);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return compareSemver(formatComparableVersion(parsedA), formatComparableVersion(parsedB));
|
||||
}
|
||||
|
||||
export function shouldWarnOnTouchedVersion(
|
||||
@@ -136,3 +127,11 @@ function releaseRank(version: OpenClawVersion): number {
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function formatComparableVersion(version: OpenClawVersion): string {
|
||||
const base = `${version.major}.${version.minor}.${version.patch}`;
|
||||
if (version.revision != null) {
|
||||
return `${base}-${version.revision}`;
|
||||
}
|
||||
return version.prerelease?.length ? `${base}-${version.prerelease.join(".")}` : base;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("clawhub helpers", () => {
|
||||
).toBe("1.2.2");
|
||||
});
|
||||
|
||||
it("checks plugin api ranges without semver dependency", () => {
|
||||
it("checks plugin api ranges with semver precedence", () => {
|
||||
expect(satisfiesPluginApiRange("1.2.3", "^1.2.0")).toBe(true);
|
||||
expect(satisfiesPluginApiRange("1.9.0", ">=1.2.0 <2.0.0")).toBe(true);
|
||||
expect(satisfiesPluginApiRange("2.0.0", "^1.2.0")).toBe(false);
|
||||
|
||||
+8
-10
@@ -9,6 +9,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { parse as parseSemverVersion, prerelease as parseSemverPrerelease } from "semver";
|
||||
import { retryClawHubRead } from "./clawhub-retry.js";
|
||||
import { sha256Base64, sha256Hex as digestSha256Hex } from "./crypto-digest.js";
|
||||
import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js";
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
parseStrictPositiveInteger,
|
||||
} from "./parse-finite-number.js";
|
||||
import { isAtLeast, parseSemver } from "./runtime-guard.js";
|
||||
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
|
||||
import { compareValidSemver } from "./semver.js";
|
||||
import { createTempDownloadTarget } from "./temp-download.js";
|
||||
export { parseClawHubPluginSpec } from "./clawhub-spec.js";
|
||||
|
||||
@@ -549,14 +550,13 @@ function normalizePartialComparableVersion(version: string): {
|
||||
}
|
||||
|
||||
function compareSemver(left: string, right: string): number | null {
|
||||
return compareComparableSemver(
|
||||
parseComparableSemver(normalizePartialComparableVersion(left).version),
|
||||
parseComparableSemver(normalizePartialComparableVersion(right).version),
|
||||
);
|
||||
const normalizedLeft = normalizePartialComparableVersion(left).version;
|
||||
const normalizedRight = normalizePartialComparableVersion(right).version;
|
||||
return compareValidSemver(normalizedLeft, normalizedRight);
|
||||
}
|
||||
|
||||
function upperBoundForCaret(version: string): string | null {
|
||||
const parsed = parseComparableSemver(normalizePartialComparableVersion(version).version);
|
||||
const parsed = parseSemverVersion(normalizePartialComparableVersion(version).version);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
@@ -579,9 +579,7 @@ function matchWildcardComparator(token: string): "any" | "none" | null {
|
||||
}
|
||||
|
||||
function shouldPreservePluginApiPrereleaseFloor(target: string): boolean {
|
||||
return Boolean(
|
||||
parseComparableSemver(normalizePartialComparableVersion(target).version)?.prerelease?.length,
|
||||
);
|
||||
return Boolean(parseSemverPrerelease(normalizePartialComparableVersion(target).version));
|
||||
}
|
||||
|
||||
function normalizePluginApiVersionForComparator(version: string, target: string): string {
|
||||
@@ -601,7 +599,7 @@ function satisfiesComparator(version: string, token: string): boolean {
|
||||
}
|
||||
const wildcard = matchWildcardComparator(trimmed);
|
||||
if (wildcard) {
|
||||
return wildcard === "any" && parseComparableSemver(version) != null;
|
||||
return wildcard === "any" && parseSemverVersion(version) != null;
|
||||
}
|
||||
if (trimmed.startsWith("^")) {
|
||||
const base = trimmed.slice(1).trim();
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
// Parses npm registry specs into package, version, and tag references.
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
parse as parseSemver,
|
||||
prerelease as parseSemverPrerelease,
|
||||
valid as validSemver,
|
||||
} from "semver";
|
||||
|
||||
const EXACT_SEMVER_VERSION_RE =
|
||||
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
||||
const OPENCLAW_STABLE_CORRECTION_VERSION_RE =
|
||||
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-(?<correction>[1-9]\d*)$/;
|
||||
const OPENCLAW_STABLE_VERSION_RE = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)$/;
|
||||
const OPENCLAW_ALPHA_VERSION_RE =
|
||||
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-alpha\.(?<alpha>[1-9]\d*)$/;
|
||||
const OPENCLAW_BETA_VERSION_RE =
|
||||
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-beta\.(?<beta>[1-9]\d*)$/;
|
||||
const OPENCLAW_RELEASE_PREFIX_RE = /^\d{4}\./;
|
||||
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
|
||||
/** Parsed monthly patch OpenClaw release version used for channel-aware ordering. */
|
||||
@@ -91,8 +88,8 @@ function parseRegistryNpmSpecInternal(
|
||||
if (/[\\/]/.test(selector)) {
|
||||
return { ok: false, error: "unsupported npm spec: invalid version/tag" };
|
||||
}
|
||||
const exactVersionMatch = EXACT_SEMVER_VERSION_RE.exec(selector);
|
||||
if (exactVersionMatch) {
|
||||
const exactVersion = validSemver(selector);
|
||||
if (exactVersion) {
|
||||
return {
|
||||
ok: true,
|
||||
parsed: {
|
||||
@@ -101,7 +98,8 @@ function parseRegistryNpmSpecInternal(
|
||||
selector,
|
||||
selectorKind: "exact-version",
|
||||
selectorIsPrerelease:
|
||||
Boolean(exactVersionMatch[4]) && !isOpenClawStableCorrectionVersion(selector),
|
||||
parseSemverPrerelease(exactVersion) !== null &&
|
||||
!isOpenClawStableCorrectionVersion(selector),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -143,60 +141,47 @@ export function validateRegistryNpmSpec(rawSpec: string): string | null {
|
||||
|
||||
/** Returns whether a value is an exact semver selector, with optional leading `v`. */
|
||||
export function isExactSemverVersion(value: string): boolean {
|
||||
return EXACT_SEMVER_VERSION_RE.test(value.trim());
|
||||
return validSemver(value.trim()) !== null;
|
||||
}
|
||||
|
||||
/** Parses OpenClaw's monthly patch stable/alpha/beta/correction version format. */
|
||||
function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | null {
|
||||
const trimmed = value.trim();
|
||||
const candidates = [
|
||||
{ match: OPENCLAW_STABLE_VERSION_RE.exec(trimmed), channel: "stable" as const },
|
||||
{ match: OPENCLAW_STABLE_CORRECTION_VERSION_RE.exec(trimmed), channel: "stable" as const },
|
||||
{ match: OPENCLAW_ALPHA_VERSION_RE.exec(trimmed), channel: "alpha" as const },
|
||||
{ match: OPENCLAW_BETA_VERSION_RE.exec(trimmed), channel: "beta" as const },
|
||||
];
|
||||
const candidate = candidates.find((entry) => entry.match?.groups);
|
||||
if (!candidate?.match?.groups) {
|
||||
const parsed = OPENCLAW_RELEASE_PREFIX_RE.test(trimmed) ? parseSemver(trimmed) : null;
|
||||
if (!parsed || parsed.build.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const { major: year, minor: month, patch } = parsed;
|
||||
if (month < 1 || month > 12 || patch < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number.parseInt(candidate.match.groups.year ?? "", 10);
|
||||
const month = Number.parseInt(candidate.match.groups.month ?? "", 10);
|
||||
const patch = Number.parseInt(candidate.match.groups.patch ?? "", 10);
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(patch) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
patch < 1
|
||||
) {
|
||||
const [label, sequence] = parsed.prerelease;
|
||||
const isStable = parsed.prerelease.length === 0;
|
||||
const isCorrection = parsed.prerelease.length === 1 && typeof label === "number" && label > 0;
|
||||
const isAlpha =
|
||||
parsed.prerelease.length === 2 &&
|
||||
label === "alpha" &&
|
||||
typeof sequence === "number" &&
|
||||
sequence > 0;
|
||||
const isBeta =
|
||||
parsed.prerelease.length === 2 &&
|
||||
label === "beta" &&
|
||||
typeof sequence === "number" &&
|
||||
sequence > 0;
|
||||
if (!isStable && !isCorrection && !isAlpha && !isBeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const correctionNumber =
|
||||
candidate.channel === "stable" && candidate.match.groups.correction
|
||||
? Number.parseInt(candidate.match.groups.correction, 10)
|
||||
: undefined;
|
||||
// Stable correction releases share the stable channel rank; the optional
|
||||
// correction number is compared later so base stable sorts before fixes.
|
||||
const alphaNumber =
|
||||
candidate.channel === "alpha"
|
||||
? Number.parseInt(candidate.match.groups.alpha ?? "", 10)
|
||||
: undefined;
|
||||
const betaNumber =
|
||||
candidate.channel === "beta"
|
||||
? Number.parseInt(candidate.match.groups.beta ?? "", 10)
|
||||
: undefined;
|
||||
const channel = isAlpha ? "alpha" : isBeta ? "beta" : "stable";
|
||||
|
||||
return {
|
||||
channel: candidate.channel,
|
||||
channel,
|
||||
year,
|
||||
month,
|
||||
patch,
|
||||
correctionNumber,
|
||||
alphaNumber,
|
||||
betaNumber,
|
||||
correctionNumber: isCorrection ? label : undefined,
|
||||
alphaNumber: isAlpha ? sequence : undefined,
|
||||
betaNumber: isBeta ? sequence : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,8 +223,7 @@ export function compareOpenClawReleaseVersions(left: string, right: string): num
|
||||
/** Returns whether an exact semver value is a prerelease, excluding stable correction releases. */
|
||||
export function isPrereleaseSemverVersion(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
const match = EXACT_SEMVER_VERSION_RE.exec(trimmed);
|
||||
return Boolean(match?.[4]) && !isOpenClawStableCorrectionVersion(trimmed);
|
||||
return parseSemverPrerelease(trimmed) !== null && !isOpenClawStableCorrectionVersion(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// Compares semantic versions for package and runtime compatibility checks.
|
||||
type ComparableSemver = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease: string[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts legacy OpenClaw `1.2.3.beta.N` tags into semver-compatible `1.2.3-beta.N`.
|
||||
*/
|
||||
export function normalizeLegacyDotBetaVersion(version: string): string {
|
||||
const trimmed = version.trim();
|
||||
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed);
|
||||
if (!dotBetaMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const base = dotBetaMatch[1];
|
||||
const suffix = dotBetaMatch[2];
|
||||
return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an exact semver-like version into fields used by update and plugin version ordering.
|
||||
*/
|
||||
export function parseComparableSemver(
|
||||
version: string | null | undefined,
|
||||
options?: { normalizeLegacyDotBeta?: boolean },
|
||||
): ComparableSemver | null {
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
const normalized = options?.normalizeLegacyDotBeta
|
||||
? normalizeLegacyDotBetaVersion(version)
|
||||
: version.trim();
|
||||
const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
|
||||
normalized,
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, major, minor, patch, prereleaseRaw] = match;
|
||||
if (!major || !minor || !patch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
major: Number.parseInt(major, 10),
|
||||
minor: Number.parseInt(minor, 10),
|
||||
patch: Number.parseInt(patch, 10),
|
||||
prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares semver prerelease identifiers using numeric-before-string semver precedence rules.
|
||||
*/
|
||||
export function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | null): number {
|
||||
if (!a?.length && !b?.length) {
|
||||
return 0;
|
||||
}
|
||||
// A stable release has higher precedence than any prerelease for the same major/minor/patch.
|
||||
if (!a?.length) {
|
||||
return 1;
|
||||
}
|
||||
if (!b?.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const max = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const ai = a[i];
|
||||
const bi = b[i];
|
||||
if (ai == null && bi == null) {
|
||||
return 0;
|
||||
}
|
||||
if (ai == null) {
|
||||
return -1;
|
||||
}
|
||||
if (bi == null) {
|
||||
return 1;
|
||||
}
|
||||
if (ai === bi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aiNumeric = /^[0-9]+$/.test(ai);
|
||||
const biNumeric = /^[0-9]+$/.test(bi);
|
||||
if (aiNumeric && biNumeric) {
|
||||
const aiNum = Number.parseInt(ai, 10);
|
||||
const biNum = Number.parseInt(bi, 10);
|
||||
return aiNum < biNum ? -1 : 1;
|
||||
}
|
||||
if (aiNumeric && !biNumeric) {
|
||||
return -1;
|
||||
}
|
||||
if (!aiNumeric && biNumeric) {
|
||||
return 1;
|
||||
}
|
||||
return ai < bi ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares parsed semver values, returning null when either side could not be parsed.
|
||||
*/
|
||||
export function compareComparableSemver(
|
||||
a: ComparableSemver | null,
|
||||
b: ComparableSemver | null,
|
||||
): number | null {
|
||||
if (!a || !b) {
|
||||
return null;
|
||||
}
|
||||
if (a.major !== b.major) {
|
||||
return a.major < b.major ? -1 : 1;
|
||||
}
|
||||
if (a.minor !== b.minor) {
|
||||
return a.minor < b.minor ? -1 : 1;
|
||||
}
|
||||
if (a.patch !== b.patch) {
|
||||
return a.patch < b.patch ? -1 : 1;
|
||||
}
|
||||
return comparePrereleaseIdentifiers(a.prerelease, b.prerelease);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { compare, valid } from "semver";
|
||||
|
||||
export function compareValidSemver(left: string, right: string): number | null {
|
||||
const validLeft = valid(left);
|
||||
const validRight = valid(right);
|
||||
return validLeft && validRight ? compare(validLeft, validRight) : null;
|
||||
}
|
||||
|
||||
/** Converts legacy OpenClaw `1.2.3.beta.N` tags into valid SemVer prereleases. */
|
||||
export function normalizeLegacyDotBetaVersion(version: string): string {
|
||||
const trimmed = version.trim();
|
||||
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed);
|
||||
if (!dotBetaMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const base = dotBetaMatch[1];
|
||||
const suffix = dotBetaMatch[2];
|
||||
return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Resolves OpenClaw update channels from config, tags, and versions.
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { parseComparableSemver } from "./semver-compare.js";
|
||||
import { parse as parseSemver } from "semver";
|
||||
import { normalizeLegacyDotBetaVersion } from "./semver.js";
|
||||
|
||||
/** Release stream used to choose registry tags and update policy defaults. */
|
||||
export type UpdateChannel = "stable" | "extended-stable" | "beta" | "dev";
|
||||
@@ -67,9 +68,9 @@ export function isBetaTag(tag: string): boolean {
|
||||
|
||||
/** Detects prerelease tags, including legacy dot-beta tags and named prerelease channels. */
|
||||
export function isPrereleaseTag(tag: string): boolean {
|
||||
const parsed = parseComparableSemver(tag, { normalizeLegacyDotBeta: true });
|
||||
const parsed = parseSemver(normalizeLegacyDotBetaVersion(tag));
|
||||
if (parsed) {
|
||||
return Boolean(parsed.prerelease?.some((part) => !/^[0-9]+$/.test(part)));
|
||||
return parsed.prerelease.some((part) => typeof part === "string");
|
||||
}
|
||||
return /(?:^|[.-])(alpha|beta|rc|pre|preview|canary|dev|next|nightly|experimental)(?:[.-]|$)/i.test(
|
||||
tag,
|
||||
|
||||
@@ -27,17 +27,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("compareSemverStrings", () => {
|
||||
it("handles stable and prerelease precedence for both legacy and beta formats", () => {
|
||||
expect(compareSemverStrings("1.0.0", "1.0.0")).toBe(0);
|
||||
expect(compareSemverStrings("v1.0.0", "1.0.0")).toBe(0);
|
||||
|
||||
expect(compareSemverStrings("1.0.0", "1.0.0-beta.1")).toBe(1);
|
||||
expect(compareSemverStrings("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1);
|
||||
|
||||
expect(compareSemverStrings("1.0.0-2", "1.0.0-1")).toBe(1);
|
||||
expect(compareSemverStrings("1.0.0-1", "1.0.0-beta.1")).toBe(-1);
|
||||
expect(compareSemverStrings("1.0.0.beta.2", "1.0.0-beta.1")).toBe(1);
|
||||
expect(compareSemverStrings("1.0.0", "1.0.0.beta.1")).toBe(1);
|
||||
it("orders real stable, prerelease, and legacy dot-beta versions", () => {
|
||||
expect(compareSemverStrings("2026.6.5", "2026.6.6-beta.1")).toBe(-1);
|
||||
expect(compareSemverStrings("2026.6.6", "2026.6.6-beta.1")).toBe(1);
|
||||
expect(compareSemverStrings("2026.6.6-beta.2", "2026.6.6-beta.1")).toBe(1);
|
||||
expect(compareSemverStrings("v2026.6.6", "2026.6.6")).toBe(0);
|
||||
expect(compareSemverStrings("2026.6.6.beta.2", "2026.6.6-beta.1")).toBe(1);
|
||||
});
|
||||
|
||||
it("treats OpenClaw stable correction releases as newer than their base release", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
|
||||
import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js";
|
||||
import { compareOpenClawReleaseVersions } from "./npm-registry-spec.js";
|
||||
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
|
||||
import { compareValidSemver, normalizeLegacyDotBetaVersion } from "./semver.js";
|
||||
import { channelToNpmTag, type UpdateChannel } from "./update-channels.js";
|
||||
|
||||
export type PackageManager = "pnpm" | "bun" | "npm" | "unknown";
|
||||
@@ -677,10 +677,9 @@ export function compareSemverStrings(a: string | null, b: string | null): number
|
||||
return openClawReleaseCmp;
|
||||
}
|
||||
}
|
||||
return compareComparableSemver(
|
||||
parseComparableSemver(a, { normalizeLegacyDotBeta: true }),
|
||||
parseComparableSemver(b, { normalizeLegacyDotBeta: true }),
|
||||
);
|
||||
const normalizedA = a ? normalizeLegacyDotBetaVersion(a) : null;
|
||||
const normalizedB = b ? normalizeLegacyDotBetaVersion(b) : null;
|
||||
return normalizedA && normalizedB ? compareValidSemver(normalizedA, normalizedB) : null;
|
||||
}
|
||||
|
||||
export async function checkUpdateStatus(params: {
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
createSafeNpmInstallArgs,
|
||||
createSafeNpmInstallEnv,
|
||||
} from "../infra/safe-package-install.js";
|
||||
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
|
||||
import { compareValidSemver } from "../infra/semver.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { InstallPolicySource } from "../security/install-policy.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
@@ -317,7 +317,7 @@ function compareNpmSemver(a: string, b: string): number {
|
||||
if (releaseCmp !== null) {
|
||||
return releaseCmp;
|
||||
}
|
||||
return compareComparableSemver(parseComparableSemver(a), parseComparableSemver(b)) ?? 0;
|
||||
return compareValidSemver(a, b) ?? 0;
|
||||
}
|
||||
|
||||
type TrustedOfficialPrereleaseResolution =
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
readInstalledPackagePeerDependencies,
|
||||
readInstalledPackageVersion,
|
||||
} from "../infra/package-update-utils.js";
|
||||
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
|
||||
import { compareValidSemver } from "../infra/semver.js";
|
||||
import type { UpdateChannel } from "../infra/update-channels.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
@@ -397,7 +397,7 @@ function compareNpmSemverForUpdate(left: string, right: string): number {
|
||||
if (releaseCmp !== null) {
|
||||
return releaseCmp;
|
||||
}
|
||||
return compareComparableSemver(parseComparableSemver(left), parseComparableSemver(right)) ?? 0;
|
||||
return compareValidSemver(left, right) ?? 0;
|
||||
}
|
||||
|
||||
async function resolveNewerExactPinnedNpmDefaultLine(params: {
|
||||
@@ -584,10 +584,7 @@ function isBundledVersionNewer(bundledVersion: string, installedVersion: string)
|
||||
if (releaseCmp !== null) {
|
||||
return releaseCmp > 0;
|
||||
}
|
||||
const bundled = parseComparableSemver(bundledVersion);
|
||||
const installed = parseComparableSemver(installedVersion);
|
||||
const cmp = compareComparableSemver(bundled, installed);
|
||||
return cmp !== null && cmp > 0;
|
||||
return (compareValidSemver(bundledVersion, installedVersion) ?? 0) > 0;
|
||||
}
|
||||
|
||||
function pathsEqual(
|
||||
|
||||
Reference in New Issue
Block a user