diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 29e5ff8b3d6..481a1085d05 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -34,6 +34,10 @@ function npmViewArgv(spec: string): string[] { return ["npm", "view", spec, "name", "version", "dist.integrity", "dist.shasum", "--json"]; } +function npmViewVersionsArgv(spec: string): string[] { + return ["npm", "view", spec, "versions", "--json"]; +} + function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string }) { const installCalls = params.calls.filter( (call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install", @@ -134,6 +138,7 @@ type MockNpmPackage = { hoistedDependency?: { name: string; version: string }; peerDependencies?: Record; expectedDependencySpec?: string; + versions?: string[]; installedVersion?: string; installedIntegrity?: string; skipLockfileEntry?: boolean; @@ -178,6 +183,7 @@ function mockNpmViewAndInstall(params: { hoistedDependency?: { name: string; version: string }; peerDependencies?: Record; expectedDependencySpec?: string; + versions?: string[]; installedVersion?: string; installedIntegrity?: string; skipLockfileEntry?: boolean; @@ -204,6 +210,14 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) { }), ); } + const versionsPackage = packages.find( + (pkg) => JSON.stringify(argv) === JSON.stringify(npmViewVersionsArgv(pkg.packageName)), + ); + if (versionsPackage) { + return successfulSpawn( + JSON.stringify(versionsPackage.versions ?? [versionsPackage.version]), + ); + } if (argv[0] === "npm" && argv[1] === "install") { const prefixIndex = argv.indexOf("--prefix"); const prefixValue = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined; @@ -882,6 +896,45 @@ describe("installPluginFromNpmSpec", () => { expect(rejected.error).toContain('"@openclaw/voice-call@beta"'); } + runCommandWithTimeoutMock.mockReset(); + const officialNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); + const warnings: string[] = []; + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/voice-call", + packageName: "@openclaw/voice-call", + version: "0.0.2-beta.1", + npmRoot: officialNpmRoot, + versions: ["0.0.1", "0.0.2-beta.1"], + }, + { + spec: "@openclaw/voice-call@0.0.1", + packageName: "@openclaw/voice-call", + version: "0.0.1", + pluginId: "voice-call", + npmRoot: officialNpmRoot, + expectedDependencySpec: "0.0.1", + }, + ]); + + const officialFallback = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call", + npmDir: officialNpmRoot, + expectedPluginId: "voice-call", + trustedSourceLinkedOfficialInstall: true, + logger: { + info: () => {}, + warn: (msg: string) => warnings.push(msg), + }, + }); + expect(officialFallback.ok).toBe(true); + if (!officialFallback.ok) { + return; + } + expect(officialFallback.npmResolution?.version).toBe("0.0.1"); + 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 npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); mockNpmViewAndInstall({ diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 7f90bb458b3..7e2f9085914 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -16,8 +16,11 @@ import { } from "../infra/npm-managed-root.js"; import { formatPrereleaseResolutionError, + isExactSemverVersion, + isPrereleaseSemverVersion, isPrereleaseResolutionAllowed, parseRegistryNpmSpec, + type ParsedRegistryNpmSpec, } from "../infra/npm-registry-spec.js"; import { createSafeNpmInstallArgs, @@ -157,6 +160,68 @@ 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]; +} + +async function resolveTrustedOfficialStableNpmResolution(params: { + spec: ParsedRegistryNpmSpec; + resolvedPrereleaseVersion: string; + timeoutMs: number; + logger: PluginInstallLogger; +}): Promise { + if (!params.spec.name.startsWith("@openclaw/")) { + return null; + } + const versions = await runCommandWithTimeout( + ["npm", "view", params.spec.name, "versions", "--json"], + { + timeoutMs: Math.max(params.timeoutMs, 60_000), + env: { + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + }, + }, + ); + if (versions.code !== 0) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(versions.stdout.trim()); + } catch { + return null; + } + const stableVersion = (Array.isArray(parsed) ? parsed : [parsed]) + .filter((value): value is string => typeof value === "string") + .filter((value) => isExactSemverVersion(value) && !isPrereleaseSemverVersion(value)) + .sort(compareStableSemver) + .at(-1); + if (!stableVersion) { + return null; + } + + const stableSpec = `${params.spec.name}@${stableVersion}`; + const metadataResult = await resolveNpmSpecMetadata({ + spec: stableSpec, + timeoutMs: params.timeoutMs, + }); + if (!metadataResult.ok) { + return null; + } + 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; +} + function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult { return { ok: true, @@ -1180,13 +1245,27 @@ export async function installPluginFromNpmSpec( resolvedVersion: npmResolution.version, }) ) { - return { - ok: false, - error: formatPrereleaseResolutionError({ - spec: parsedSpec, - resolvedVersion: npmResolution.version, - }), - }; + const stableResolution = params.trustedSourceLinkedOfficialInstall + ? await resolveTrustedOfficialStableNpmResolution({ + spec: parsedSpec, + resolvedPrereleaseVersion: npmResolution.version, + timeoutMs, + logger, + }) + : null; + if (stableResolution) { + Object.assign(npmResolution, stableResolution, { + resolvedAt: npmResolution.resolvedAt, + }); + } else { + return { + ok: false, + error: formatPrereleaseResolutionError({ + spec: parsedSpec, + resolvedVersion: npmResolution.version, + }), + }; + } } const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ spec,