diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d0baf4557..9ea76f66715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ Docs: https://docs.openclaw.ai - Plugins/catalog: merge official external catalog descriptors into partial package channel config metadata, so lagging WeCom/Yuanbao manifests keep their own schema while still exposing host-supplied labels and setup text. Thanks @vincentkoc. - Plugins/catalog: supplement lagging official external WeCom and Yuanbao npm manifests with channel config descriptors and declared tool contracts from the OpenClaw catalog, so trusted package sweeps no longer fail because external package metadata trails the host contract. Thanks @vincentkoc. -- Plugins/install: let trusted official `@openclaw/*` catalog installs recover when npm `latest` points at a prerelease by falling back to the newest stable version, or by allowing prerelease-only launch packages with a warning instead of making beta/development plugin sweeps fail at install time. Thanks @vincentkoc. +- Plugins/install: let trusted official `@openclaw/*` catalog installs recover when npm `latest` points at a prerelease by falling back to the newest stable version, or by selecting the newest exact prerelease for prerelease-only launch packages with a warning instead of making beta/development plugin sweeps fail at install time. Thanks @vincentkoc. - Google Meet: grant Chrome media permissions against the actual Meet tab, start the local realtime audio bridge only after Meet joins, expose realtime transcripts in status/logs, and force explicit audio responses with current OpenAI realtime output-audio events so BlackHole capture does not keep the OpenClaw participant muted or silent. - Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted. - Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health. diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index c06a9db2739..2d2e7af3e8f 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -938,15 +938,24 @@ describe("installPluginFromNpmSpec", () => { runCommandWithTimeoutMock.mockReset(); const prereleaseOnlyNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); const prereleaseOnlyWarnings: string[] = []; - mockNpmViewAndInstall({ - spec: "@openclaw/voice-call", - packageName: "@openclaw/voice-call", - version: "0.0.2-beta.1", - pluginId: "voice-call", - npmRoot: prereleaseOnlyNpmRoot, - versions: ["0.0.2-beta.1"], - expectedDependencySpec: "0.0.2-beta.1", - }); + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/voice-call", + packageName: "@openclaw/voice-call", + version: "0.0.1-beta.1", + pluginId: "voice-call", + npmRoot: prereleaseOnlyNpmRoot, + versions: ["0.0.1-beta.1", "0.0.2-beta.1"], + }, + { + spec: "@openclaw/voice-call@0.0.2-beta.1", + packageName: "@openclaw/voice-call", + version: "0.0.2-beta.1", + pluginId: "voice-call", + npmRoot: prereleaseOnlyNpmRoot, + expectedDependencySpec: "0.0.2-beta.1", + }, + ]); const prereleaseOnly = await installPluginFromNpmSpec({ spec: "@openclaw/voice-call", @@ -963,7 +972,11 @@ describe("installPluginFromNpmSpec", () => { return; } expect(prereleaseOnly.npmResolution?.version).toBe("0.0.2-beta.1"); + expect(prereleaseOnly.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1"); expect(prereleaseOnlyWarnings.join("\n")).toContain("has no stable npm versions yet"); + expect(prereleaseOnlyWarnings.join("\n")).toContain( + "using newest prerelease @openclaw/voice-call@0.0.2-beta.1", + ); runCommandWithTimeoutMock.mockReset(); const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index c5c6bb55a79..ba0e20eb95b 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -26,6 +26,7 @@ import { createSafeNpmInstallArgs, createSafeNpmInstallEnv, } from "../infra/safe-package-install.js"; +import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -160,18 +161,13 @@ function isNpmPackageNotFoundMessage(error: string): boolean { return /E404|404 not found|not in this registry/i.test(normalized); } -function compareStableSemver(a: string, b: string): number { - const parse = (value: string): [number, number, number] => { - const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value.trim()); - return [Number(match?.[1] ?? 0), Number(match?.[2] ?? 0), Number(match?.[3] ?? 0)]; - }; - const left = parse(a); - const right = parse(b); - return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]; +function compareNpmSemver(a: string, b: string): number { + return compareComparableSemver(parseComparableSemver(a), parseComparableSemver(b)) ?? 0; } type TrustedOfficialPrereleaseResolution = | { kind: "stable"; resolution: NpmSpecResolution } + | { kind: "prerelease-only"; resolution: NpmSpecResolution } | { kind: "allow-prerelease-only" }; async function resolveTrustedOfficialPrereleaseResolution(params: { @@ -208,10 +204,28 @@ async function resolveTrustedOfficialPrereleaseResolution(params: { ); const stableVersion = semverVersions .filter((value) => !isPrereleaseSemverVersion(value)) - .toSorted(compareStableSemver) + .toSorted(compareNpmSemver) .at(-1); if (!stableVersion) { - if (semverVersions.length > 0 && semverVersions.every(isPrereleaseSemverVersion)) { + const prereleaseVersion = semverVersions + .filter(isPrereleaseSemverVersion) + .toSorted(compareNpmSemver) + .at(-1); + if (prereleaseVersion && semverVersions.every(isPrereleaseSemverVersion)) { + if (prereleaseVersion !== params.resolvedPrereleaseVersion) { + const prereleaseSpec = `${params.spec.name}@${prereleaseVersion}`; + const metadataResult = await resolveNpmSpecMetadata({ + spec: prereleaseSpec, + timeoutMs: params.timeoutMs, + }); + if (!metadataResult.ok) { + return null; + } + params.logger.warn?.( + `Resolved ${params.spec.raw} to prerelease version ${params.resolvedPrereleaseVersion}; using newest prerelease ${prereleaseSpec} because this trusted official OpenClaw package has no stable npm versions yet.`, + ); + return { kind: "prerelease-only", resolution: metadataResult.metadata }; + } params.logger.warn?.( `Resolved ${params.spec.raw} to prerelease version ${params.resolvedPrereleaseVersion}; allowing it because this trusted official OpenClaw package has no stable npm versions yet.`, ); @@ -1265,7 +1279,7 @@ export async function installPluginFromNpmSpec( logger, }) : null; - if (trustedResolution?.kind === "stable") { + if (trustedResolution?.kind === "stable" || trustedResolution?.kind === "prerelease-only") { Object.assign(npmResolution, trustedResolution.resolution, { resolvedAt: npmResolution.resolvedAt, });