diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts new file mode 100644 index 00000000000..715b92fffe4 --- /dev/null +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureBundledPluginRuntimeDeps, + resolveBundledRuntimeDepsNpmRunner, +} from "./bundled-runtime-deps.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-deps-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveBundledRuntimeDepsNpmRunner", () => { + it("uses the Node-adjacent npm CLI on Windows", () => { + const execPath = "C:\\Program Files\\nodejs\\node.exe"; + const npmCliPath = path.win32.resolve( + path.win32.dirname(execPath), + "node_modules/npm/bin/npm-cli.js", + ); + + const runner = resolveBundledRuntimeDepsNpmRunner({ + env: {}, + execPath, + existsSync: (candidate) => candidate === npmCliPath, + npmArgs: ["install", "acpx@0.5.3"], + platform: "win32", + }); + + expect(runner).toEqual({ + command: execPath, + args: [npmCliPath, "install", "acpx@0.5.3"], + }); + }); + + it("does not fall back to bare npm on Windows", () => { + expect(() => + resolveBundledRuntimeDepsNpmRunner({ + env: {}, + execPath: "C:\\Program Files\\nodejs\\node.exe", + existsSync: () => false, + npmArgs: ["install"], + platform: "win32", + }), + ).toThrow("failed to resolve a toolchain-local npm"); + }); + + it("prefixes PATH with the active Node directory on POSIX", () => { + const runner = resolveBundledRuntimeDepsNpmRunner({ + env: { + PATH: "/usr/bin:/bin", + }, + execPath: "/opt/node/bin/node", + existsSync: () => false, + npmArgs: ["install", "acpx@0.5.3"], + platform: "linux", + }); + + expect(runner).toEqual({ + command: "npm", + args: ["install", "acpx@0.5.3"], + env: { + PATH: `/opt/node/bin${path.delimiter}/usr/bin:/bin`, + }, + }); + }); + + it("installs all direct plugin runtime deps when one is missing", () => { + const packageRoot = makeTempDir(); + const extensionsRoot = path.join(packageRoot, "dist", "extensions"); + const pluginRoot = path.join(extensionsRoot, "bedrock"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ + dependencies: { + "already-present": "1.0.0", + missing: "2.0.0", + }, + }), + ); + fs.mkdirSync(path.join(extensionsRoot, "node_modules", "already-present"), { + recursive: true, + }); + fs.writeFileSync( + path.join(extensionsRoot, "node_modules", "already-present", "package.json"), + JSON.stringify({ name: "already-present", version: "1.0.0" }), + ); + + const calls: Array<{ + installRoot: string; + missingSpecs: string[]; + installSpecs?: string[]; + }> = []; + + const retainedSpecs = ensureBundledPluginRuntimeDeps({ + env: {}, + installDeps: (params) => { + calls.push(params); + }, + pluginId: "bedrock", + pluginRoot, + retainSpecs: ["previous@3.0.0"], + }); + + expect(retainedSpecs).toEqual(["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"]); + expect(calls).toEqual([ + { + installRoot: extensionsRoot, + missingSpecs: ["missing@2.0.0"], + installSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"], + }, + ]); + }); +}); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index d183e396530..38bd7c75522 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -26,6 +26,12 @@ export type BundledRuntimeDepsInstallParams = { type JsonObject = Record; +export type BundledRuntimeDepsNpmRunner = { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; +}; + function dependencySentinelPath(depName: string): string { return path.join("node_modules", ...depName.split("/"), "package.json"); } @@ -73,6 +79,68 @@ function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return nextEnv; } +function resolvePathEnvKey(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string { + if (platform !== "win32") { + return "PATH"; + } + return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "Path"; +} + +export function resolveBundledRuntimeDepsNpmRunner(params: { + npmArgs: string[]; + env?: NodeJS.ProcessEnv; + execPath?: string; + existsSync?: typeof fs.existsSync; + platform?: NodeJS.Platform; +}): BundledRuntimeDepsNpmRunner { + const env = params.env ?? process.env; + const execPath = params.execPath ?? process.execPath; + const existsSync = params.existsSync ?? fs.existsSync; + const platform = params.platform ?? process.platform; + const pathImpl = platform === "win32" ? path.win32 : path.posix; + const nodeDir = pathImpl.dirname(execPath); + + const npmCliCandidates = [ + pathImpl.resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), + pathImpl.resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"), + ]; + const npmCliPath = npmCliCandidates.find((candidate) => existsSync(candidate)); + if (npmCliPath) { + return { + command: execPath, + args: [npmCliPath, ...params.npmArgs], + }; + } + + if (platform === "win32") { + const npmExePath = pathImpl.resolve(nodeDir, "npm.exe"); + if (existsSync(npmExePath)) { + return { + command: npmExePath, + args: params.npmArgs, + }; + } + throw new Error( + `failed to resolve a toolchain-local npm next to ${execPath}. ` + + `Checked: ${[...npmCliCandidates, npmExePath].join(", ")}.`, + ); + } + + const pathKey = resolvePathEnvKey(env, platform); + const currentPath = env[pathKey]; + return { + command: "npm", + args: params.npmArgs, + env: { + ...env, + [pathKey]: + typeof currentPath === "string" && currentPath.length > 0 + ? `${nodeDir}${path.delimiter}${currentPath}` + : nodeDir, + }, + }; +} + function readBundledPluginChannels(pluginDir: string): string[] { const manifest = readJsonObject(path.join(pluginDir, "openclaw.plugin.json")); const channels = manifest?.channels; @@ -255,7 +323,13 @@ export function scanBundledPluginRuntimeDeps(params: { pluginIds: normalizePluginIdSet(params.pluginIds), }); const missing = deps.filter( - (dep) => !fs.existsSync(path.join(params.packageRoot, dependencySentinelPath(dep.name))), + (dep) => + !fs.existsSync(path.join(params.packageRoot, dependencySentinelPath(dep.name))) && + !fs.existsSync(path.join(extensionsDir, dependencySentinelPath(dep.name))) && + dep.pluginIds.every( + (pluginId) => + !fs.existsSync(path.join(extensionsDir, pluginId, dependencySentinelPath(dep.name))), + ), ); return { missing, conflicts }; } @@ -267,7 +341,7 @@ export function resolveBundledRuntimeDependencyInstallRoot(pluginRoot: string): path.basename(extensionsDir) === "extensions" && (path.basename(buildDir) === "dist" || path.basename(buildDir) === "dist-runtime") ) { - return path.dirname(buildDir); + return extensionsDir; } return extensionsDir; } @@ -277,10 +351,12 @@ export function installBundledRuntimeDeps(params: { missingSpecs: string[]; env: NodeJS.ProcessEnv; }): void { - const result = spawnSync( - "npm", - [ + const npmRunner = resolveBundledRuntimeDepsNpmRunner({ + env: params.env, + npmArgs: [ "install", + "--prefix", + params.installRoot, "--omit=dev", "--no-save", "--package-lock=false", @@ -288,14 +364,17 @@ export function installBundledRuntimeDeps(params: { "--legacy-peer-deps", ...params.missingSpecs, ], - { - cwd: params.installRoot, - encoding: "utf8", - env: createNestedNpmInstallEnv(params.env), - stdio: "pipe", - shell: false, - }, - ); + }); + const result = spawnSync(npmRunner.command, npmRunner.args, { + cwd: params.installRoot, + encoding: "utf8", + env: createNestedNpmInstallEnv(npmRunner.env ?? params.env), + stdio: "pipe", + shell: false, + }); + if (result.error) { + throw result.error; + } if (result.status !== 0) { const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); throw new Error(output || "npm install failed"); @@ -339,6 +418,9 @@ export function ensureBundledPluginRuntimeDeps(params: { } const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot); + const dependencySpecs = deps + .map((dep) => `${dep.name}@${dep.version}`) + .toSorted((left, right) => left.localeCompare(right)); const missingSpecs = deps .filter((dep) => !fs.existsSync(path.join(installRoot, dependencySentinelPath(dep.name)))) .map((dep) => `${dep.name}@${dep.version}`) @@ -346,7 +428,7 @@ export function ensureBundledPluginRuntimeDeps(params: { if (missingSpecs.length === 0) { return []; } - const installSpecs = [...new Set([...(params.retainSpecs ?? []), ...missingSpecs])].toSorted( + const installSpecs = [...new Set([...(params.retainSpecs ?? []), ...dependencySpecs])].toSorted( (left, right) => left.localeCompare(right), ); @@ -359,5 +441,5 @@ export function ensureBundledPluginRuntimeDeps(params: { env: params.env, })); install({ installRoot, missingSpecs, installSpecs }); - return missingSpecs; + return installSpecs; }