diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 0c0fe422b21..27d728bf298 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -17,6 +18,10 @@ function removePathIfExists(targetPath) { fs.rmSync(targetPath, { recursive: true, force: true }); } +function makeTempDir(parentDir, prefix) { + return fs.mkdtempSync(path.join(parentDir, prefix)); +} + function listBundledPluginRuntimeDirs(repoRoot) { const extensionsRoot = path.join(repoRoot, "dist", "extensions"); if (!fs.existsSync(extensionsRoot)) { @@ -82,6 +87,27 @@ function sanitizeBundledManifestForRuntimeInstall(pluginDir) { if (changed) { writeJson(manifestPath, packageJson); } + + return packageJson; +} + +function resolveRuntimeDepsStampPath(pluginDir) { + return path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"); +} + +function createRuntimeDepsFingerprint(packageJson) { + return createHash("sha256").update(JSON.stringify(packageJson)).digest("hex"); +} + +function readRuntimeDepsStamp(stampPath) { + if (!fs.existsSync(stampPath)) { + return null; + } + try { + return readJson(stampPath); + } catch { + return null; + } } export function resolveNpmRunner(params = {}) { @@ -190,8 +216,11 @@ function buildCmdExeCommandLine(command, args) { return [escapeForCmdExe(command), ...args.map(escapeForCmdExe)].join(" "); } -function installPluginRuntimeDeps(pluginDir, pluginId) { - sanitizeBundledManifestForRuntimeInstall(pluginDir); +function installPluginRuntimeDeps(params) { + const { fingerprint, packageJson, pluginDir, pluginId } = params; + const nodeModulesDir = path.join(pluginDir, "node_modules"); + const stampPath = resolveRuntimeDepsStampPath(pluginDir); + const tempInstallDir = makeTempDir(pluginDir, ".runtime-deps-"); const npmRunner = resolveNpmRunner({ npmArgs: [ "install", @@ -202,34 +231,66 @@ function installPluginRuntimeDeps(pluginDir, pluginId) { "--package-lock=false", ], }); - const result = spawnSync(npmRunner.command, npmRunner.args, { - cwd: pluginDir, - encoding: "utf8", - env: npmRunner.env, - stdio: "pipe", - shell: npmRunner.shell, - windowsVerbatimArguments: npmRunner.windowsVerbatimArguments, - }); - if (result.status === 0) { - return; + try { + writeJson(path.join(tempInstallDir, "package.json"), packageJson); + const result = spawnSync(npmRunner.command, npmRunner.args, { + cwd: tempInstallDir, + encoding: "utf8", + env: npmRunner.env, + stdio: "pipe", + shell: npmRunner.shell, + windowsVerbatimArguments: npmRunner.windowsVerbatimArguments, + }); + if (result.status !== 0) { + const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); + throw new Error( + `failed to stage bundled runtime deps for ${pluginId}: ${output || "npm install failed"}`, + ); + } + + const stagedNodeModulesDir = path.join(tempInstallDir, "node_modules"); + if (!fs.existsSync(stagedNodeModulesDir)) { + throw new Error( + `failed to stage bundled runtime deps for ${pluginId}: npm install produced no node_modules directory`, + ); + } + + removePathIfExists(nodeModulesDir); + fs.renameSync(stagedNodeModulesDir, nodeModulesDir); + writeJson(stampPath, { + fingerprint, + generatedAt: new Date().toISOString(), + }); + } finally { + removePathIfExists(tempInstallDir); } - const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); - throw new Error( - `failed to stage bundled runtime deps for ${pluginId}: ${output || "npm install failed"}`, - ); } export function stageBundledPluginRuntimeDeps(params = {}) { const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const installPluginRuntimeDepsImpl = + params.installPluginRuntimeDepsImpl ?? installPluginRuntimeDeps; for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) { const pluginId = path.basename(pluginDir); - const packageJson = readJson(path.join(pluginDir, "package.json")); + const packageJson = sanitizeBundledManifestForRuntimeInstall(pluginDir); const nodeModulesDir = path.join(pluginDir, "node_modules"); - removePathIfExists(nodeModulesDir); + const stampPath = resolveRuntimeDepsStampPath(pluginDir); if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) { + removePathIfExists(nodeModulesDir); + removePathIfExists(stampPath); continue; } - installPluginRuntimeDeps(pluginDir, pluginId); + const fingerprint = createRuntimeDepsFingerprint(packageJson); + const stamp = readRuntimeDepsStamp(stampPath); + if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) { + continue; + } + installPluginRuntimeDepsImpl({ + fingerprint, + packageJson, + pluginDir, + pluginId, + }); } } diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index 0c675bdec66..d6f7d2acb8b 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -1,6 +1,11 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveNpmRunner } from "../../scripts/stage-bundled-plugin-runtime-deps.mjs"; +import { + resolveNpmRunner, + stageBundledPluginRuntimeDeps, +} from "../../scripts/stage-bundled-plugin-runtime-deps.mjs"; describe("resolveNpmRunner", () => { it("anchors npm staging to the active node toolchain when npm-cli.js exists", () => { @@ -118,3 +123,109 @@ describe("resolveNpmRunner", () => { ).toThrow("OpenClaw refuses to shell out to bare npm on Windows"); }); }); + +describe("stageBundledPluginRuntimeDeps", () => { + function createBundledPluginFixture(params: { + packageJson: Record; + pluginId?: string; + }) { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-deps-")); + const pluginId = params.pluginId ?? "fixture-plugin"; + const pluginDir = path.join(repoRoot, "dist", "extensions", pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + `${JSON.stringify(params.packageJson, null, 2)}\n`, + "utf8", + ); + return { pluginDir, repoRoot }; + } + + it("skips restaging when runtime deps stamp matches the sanitized manifest", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "left-pad": "1.3.0" }, + peerDependencies: { openclaw: "^1.0.0" }, + peerDependenciesMeta: { openclaw: { optional: true } }, + devDependencies: { openclaw: "^1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "present\n", "utf8"); + + let installCount = 0; + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => { + installCount += 1; + fs.writeFileSync( + path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"), + `${JSON.stringify({ fingerprint }, null, 2)}\n`, + "utf8", + ); + }, + }); + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + installPluginRuntimeDepsImpl: () => { + installCount += 1; + }, + }); + + expect(installCount).toBe(1); + expect(fs.existsSync(path.join(nodeModulesDir, "marker.txt"))).toBe(true); + expect(JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8"))).toEqual({ + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "left-pad": "1.3.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }); + }); + + it("restages when the manifest-owned runtime deps change", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "left-pad": "1.3.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + + let installCount = 0; + const stageOnce = () => + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => { + installCount += 1; + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), `${installCount}\n`, "utf8"); + fs.writeFileSync( + path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"), + `${JSON.stringify({ fingerprint }, null, 2)}\n`, + "utf8", + ); + }, + }); + + stageOnce(); + const updatedPackageJson = JSON.parse( + fs.readFileSync(path.join(pluginDir, "package.json"), "utf8"), + ); + updatedPackageJson.dependencies["is-odd"] = "3.0.1"; + fs.writeFileSync( + path.join(pluginDir, "package.json"), + `${JSON.stringify(updatedPackageJson, null, 2)}\n`, + "utf8", + ); + stageOnce(); + + expect(installCount).toBe(2); + expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n"); + }); +});