diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index 20d06fafcf7..cfa4841d747 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -1,15 +1,18 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs/promises"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { installPluginFromNpmSpec } from "./install.js"; type PackedVersion = { archive: Buffer; integrity: string; + peerDependencies?: Record; + peerDependenciesMeta?: Record; shasum: string; tarballName: string; version: string; @@ -19,6 +22,7 @@ const tempDirs: string[] = []; const servers: http.Server[] = []; const envKeys = ["NPM_CONFIG_REGISTRY", "npm_config_registry"] as const; const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); +const execFileAsync = promisify(execFile); afterEach(async () => { for (const server of servers.splice(0)) { @@ -43,11 +47,19 @@ async function makeTempDir(label: string): Promise { async function packPlugin(params: { packageName: string; + peerDependencies?: Record; + peerDependenciesMeta?: Record; pluginId: string; version: string; rootDir: string; }): Promise { - const packageDir = path.join(params.rootDir, `package-${params.version}`); + const packageDir = path.join(params.rootDir, `package-${params.packageName}-${params.version}`); + const peerDependenciesMeta = params.peerDependencies + ? (params.peerDependenciesMeta ?? + Object.fromEntries( + Object.keys(params.peerDependencies).map((name) => [name, { optional: true }]), + )) + : undefined; await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); await fs.writeFile( path.join(packageDir, "package.json"), @@ -57,6 +69,12 @@ async function packPlugin(params: { version: params.version, type: "module", openclaw: { extensions: ["./dist/index.js"] }, + ...(params.peerDependencies + ? { + peerDependencies: params.peerDependencies, + ...(peerDependenciesMeta ? { peerDependenciesMeta } : {}), + } + : {}), }, null, 2, @@ -92,12 +110,90 @@ async function packPlugin(params: { return { archive, integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`, + ...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}), + ...(peerDependenciesMeta ? { peerDependenciesMeta } : {}), shasum: crypto.createHash("sha1").update(archive).digest("hex"), tarballName, version: params.version, }; } +async function startStaticRegistry( + packages: Array<{ + latest: string; + packageName: string; + versions: PackedVersion[]; + }>, +): Promise { + const packageEntries = packages.map((pkg) => ({ + ...pkg, + encodedPackageName: encodeURIComponent(pkg.packageName).replace("%40", "@"), + versionsByVersion: new Map(pkg.versions.map((entry) => [entry.version, entry])), + })); + const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + if (request.method !== "GET") { + response.writeHead(405, { "content-type": "text/plain" }); + response.end("method not allowed"); + return; + } + + for (const pkg of packageEntries) { + if (url.pathname === `/${pkg.encodedPackageName}`) { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + `${JSON.stringify({ + name: pkg.packageName, + "dist-tags": { latest: pkg.latest }, + versions: Object.fromEntries( + [...pkg.versionsByVersion.entries()].map(([version, entry]) => [ + version, + { + name: pkg.packageName, + version, + ...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}), + ...(entry.peerDependenciesMeta + ? { peerDependenciesMeta: entry.peerDependenciesMeta } + : {}), + dist: { + integrity: entry.integrity, + shasum: entry.shasum, + tarball: `${baseUrl}/${pkg.encodedPackageName}/-/${entry.tarballName}`, + }, + }, + ]), + ), + })}\n`, + ); + return; + } + + const tarballPrefix = `/${pkg.encodedPackageName}/-/`; + if (url.pathname.startsWith(tarballPrefix)) { + const entry = [...pkg.versionsByVersion.values()].find((candidate) => + url.pathname.endsWith(`/${candidate.tarballName}`), + ); + if (entry) { + response.writeHead(200, { + "content-length": String(entry.archive.length), + "content-type": "application/octet-stream", + }); + response.end(entry.archive); + return; + } + } + } + + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${url.pathname}`); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push(server); + return `http://127.0.0.1:${(server.address() as { port: number }).port}`; +} + async function startMutableRegistry(params: { packageName: string; initialLatest: string; @@ -135,6 +231,10 @@ async function startMutableRegistry(params: { { name: params.packageName, version, + ...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}), + ...(entry.peerDependenciesMeta + ? { peerDependenciesMeta: entry.peerDependenciesMeta } + : {}), dist: { integrity: entry.integrity, shasum: entry.shasum, @@ -173,6 +273,119 @@ async function startMutableRegistry(params: { } describe("installPluginFromNpmSpec e2e", () => { + it("repairs npm-installed root openclaw for required plugin peers", async () => { + const rootDir = await makeTempDir("npm-plugin-required-peer-e2e"); + const npmRoot = path.join(rootDir, "managed-npm"); + const packageName = `required-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; + const versions = [ + await packPlugin({ + packageName, + peerDependencies: { openclaw: ">=2026.0.0" }, + peerDependenciesMeta: {}, + pluginId: packageName, + version: "1.0.0", + rootDir, + }), + ]; + const openClawVersions = [ + await packPlugin({ + packageName: "openclaw", + pluginId: "registry-openclaw-copy", + version: "2026.0.0", + rootDir, + }), + ]; + const registry = await startStaticRegistry([ + { packageName, latest: "1.0.0", versions }, + { packageName: "openclaw", latest: "2026.0.0", versions: openClawVersions }, + ]); + process.env.NPM_CONFIG_REGISTRY = registry; + process.env.npm_config_registry = registry; + + const rawNpmRoot = path.join(rootDir, "raw-managed-npm"); + await fs.mkdir(rawNpmRoot, { recursive: true }); + await fs.writeFile( + path.join(rawNpmRoot, "package.json"), + `${JSON.stringify( + { + private: true, + dependencies: { [packageName]: "1.0.0" }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await execFileAsync( + "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"], + { + cwd: rawNpmRoot, + encoding: "utf8", + env: { + ...process.env, + NPM_CONFIG_REGISTRY: registry, + npm_config_registry: registry, + }, + timeout: 120_000, + }, + ); + const rawManifest = JSON.parse( + await fs.readFile(path.join(rawNpmRoot, "package.json"), "utf8"), + ) as { + dependencies?: Record; + }; + expect(rawManifest.dependencies).toEqual({ [packageName]: "1.0.0" }); + const rawLock = JSON.parse( + await fs.readFile(path.join(rawNpmRoot, "package-lock.json"), "utf8"), + ) as { + packages?: Record; + }; + expect(rawLock.packages?.["node_modules/openclaw"]).toMatchObject({ + peer: true, + version: "2026.0.0", + }); + await expect( + fs + .lstat(path.join(rawNpmRoot, "node_modules", "openclaw")) + .then((stat) => stat.isDirectory()), + ).resolves.toBe(true); + + const result = await installPluginFromNpmSpec({ + spec: `${packageName}@1.0.0`, + npmDir: npmRoot, + trustedManagedNpmRoot: true, + logger: { info: () => {}, warn: () => {} }, + timeoutMs: 120_000, + }); + + if (!result.ok) { + throw new Error(result.error); + } + + const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as { + dependencies?: Record; + }; + expect(manifest.dependencies).toEqual({ [packageName]: "1.0.0" }); + + const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as { + packages?: Record; + }; + expect(lock.packages?.[""] as { dependencies?: Record }).toMatchObject({ + dependencies: { [packageName]: "1.0.0" }, + }); + expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined(); + + await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect( + fs + .lstat(path.join(result.targetDir, "node_modules", "openclaw")) + .then((stat) => stat.isSymbolicLink()), + ).resolves.toBe(true); + }); + it("pins a mutable npm tag to the version resolved before install", async () => { const rootDir = await makeTempDir("npm-plugin-e2e"); const npmRoot = path.join(rootDir, "managed-npm");