diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd2faa816c..6732ebbae5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - 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. - Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear. diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 481a1085d05..c06a9db2739 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -935,6 +935,36 @@ describe("installPluginFromNpmSpec", () => { expect(officialFallback.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1"); expect(warnings.join("\n")).toContain("falling back to stable @openclaw/voice-call@0.0.1"); + 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", + }); + + const prereleaseOnly = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call", + npmDir: prereleaseOnlyNpmRoot, + expectedPluginId: "voice-call", + trustedSourceLinkedOfficialInstall: true, + logger: { + info: () => {}, + warn: (msg: string) => prereleaseOnlyWarnings.push(msg), + }, + }); + expect(prereleaseOnly.ok).toBe(true); + if (!prereleaseOnly.ok) { + return; + } + expect(prereleaseOnly.npmResolution?.version).toBe("0.0.2-beta.1"); + expect(prereleaseOnlyWarnings.join("\n")).toContain("has no stable npm versions yet"); + runCommandWithTimeoutMock.mockReset(); const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); mockNpmViewAndInstall({ diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 7e2f9085914..7896926487f 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -170,12 +170,16 @@ function compareStableSemver(a: string, b: string): number { return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]; } -async function resolveTrustedOfficialStableNpmResolution(params: { +type TrustedOfficialPrereleaseResolution = + | { kind: "stable"; resolution: NpmSpecResolution } + | { kind: "allow-prerelease-only" }; + +async function resolveTrustedOfficialPrereleaseResolution(params: { spec: ParsedRegistryNpmSpec; resolvedPrereleaseVersion: string; timeoutMs: number; logger: PluginInstallLogger; -}): Promise { +}): Promise { if (!params.spec.name.startsWith("@openclaw/")) { return null; } @@ -199,12 +203,20 @@ async function resolveTrustedOfficialStableNpmResolution(params: { } catch { return null; } - const stableVersion = (Array.isArray(parsed) ? parsed : [parsed]) - .filter((value): value is string => typeof value === "string") - .filter((value) => isExactSemverVersion(value) && !isPrereleaseSemverVersion(value)) + const semverVersions = (Array.isArray(parsed) ? parsed : [parsed]).filter( + (value): value is string => typeof value === "string" && isExactSemverVersion(value), + ); + const stableVersion = semverVersions + .filter((value) => !isPrereleaseSemverVersion(value)) .sort(compareStableSemver) .at(-1); if (!stableVersion) { + if (semverVersions.length > 0 && semverVersions.every(isPrereleaseSemverVersion)) { + 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.`, + ); + return { kind: "allow-prerelease-only" }; + } return null; } @@ -219,7 +231,7 @@ async function resolveTrustedOfficialStableNpmResolution(params: { params.logger.warn?.( `Resolved ${params.spec.raw} to prerelease version ${params.resolvedPrereleaseVersion}; falling back to stable ${stableSpec} for this trusted official OpenClaw install.`, ); - return metadataResult.metadata; + return { kind: "stable", resolution: metadataResult.metadata }; } function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult { @@ -1245,18 +1257,20 @@ export async function installPluginFromNpmSpec( resolvedVersion: npmResolution.version, }) ) { - const stableResolution = params.trustedSourceLinkedOfficialInstall - ? await resolveTrustedOfficialStableNpmResolution({ + const trustedResolution = params.trustedSourceLinkedOfficialInstall + ? await resolveTrustedOfficialPrereleaseResolution({ spec: parsedSpec, resolvedPrereleaseVersion: npmResolution.version, timeoutMs, logger, }) : null; - if (stableResolution) { - Object.assign(npmResolution, stableResolution, { + if (trustedResolution?.kind === "stable") { + Object.assign(npmResolution, trustedResolution.resolution, { resolvedAt: npmResolution.resolvedAt, }); + } else if (trustedResolution?.kind === "allow-prerelease-only") { + // Keep the original prerelease resolution. The package has no stable line yet. } else { return { ok: false,