fix(plugins): fall back to stable official npm versions

This commit is contained in:
Vincent Koc
2026-05-03 16:07:40 -07:00
parent 107aad9742
commit 250be27f64
2 changed files with 139 additions and 7 deletions

View File

@@ -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<string, string>;
expectedDependencySpec?: string;
versions?: string[];
installedVersion?: string;
installedIntegrity?: string;
skipLockfileEntry?: boolean;
@@ -178,6 +183,7 @@ function mockNpmViewAndInstall(params: {
hoistedDependency?: { name: string; version: string };
peerDependencies?: Record<string, string>;
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({

View File

@@ -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<NpmSpecResolution | null> {
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,