From 6f30d1a294daa8dad45ee3a71b8b724902d17e51 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Tue, 14 Apr 2026 13:03:06 +0800 Subject: [PATCH] fix: stage patched WhatsApp Baileys runtime deps --- extensions/whatsapp/package.json | 3 + scripts/stage-bundled-plugin-runtime-deps.mjs | 120 +++++++++++++++++- ...bundled-plugin-staged-runtime-deps.test.ts | 16 +++ .../stage-bundled-plugin-runtime-deps.test.ts | 109 ++++++++++++++++ 4 files changed, 243 insertions(+), 5 deletions(-) diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 4a37a7360b8..e204eb886ae 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -47,6 +47,9 @@ "compat": { "pluginApi": ">=2026.4.12" }, + "bundle": { + "stageRuntimeDependencies": true + }, "build": { "openclawVersion": "2026.4.12" }, diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 68b1abc4fbf..ae1e8a6febe 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -15,6 +15,13 @@ function writeJson(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +function readOptionalUtf8(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + return fs.readFileSync(filePath, "utf8"); +} + function removePathIfExists(targetPath) { fs.rmSync(targetPath, { recursive: true, force: true }); } @@ -147,6 +154,81 @@ function collectInstalledRuntimeClosure(rootNodeModulesDir, dependencySpecs) { return [...closure]; } +function resolveInstalledDirectDependencyNames(rootNodeModulesDir, dependencySpecs) { + const directDependencyNames = []; + for (const [depName, spec] of Object.entries(dependencySpecs)) { + const installedVersion = readInstalledDependencyVersion(rootNodeModulesDir, depName); + if (installedVersion === null || !dependencyVersionSatisfied(spec, installedVersion)) { + return null; + } + directDependencyNames.push(depName); + } + return directDependencyNames; +} + +function appendDirectoryFingerprint(hash, rootDir, currentDir = rootDir) { + const entries = fs + .readdirSync(currentDir, { withFileTypes: true }) + .toSorted((left, right) => left.name.localeCompare(right.name)); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + const stat = fs.statSync(fullPath); + const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/"); + if (stat.isDirectory()) { + hash.update(`dir:${relativePath}\n`); + appendDirectoryFingerprint(hash, rootDir, fullPath); + continue; + } + if (!stat.isFile()) { + continue; + } + hash.update(`file:${relativePath}:${stat.size}\n`); + hash.update(fs.readFileSync(fullPath)); + } +} + +function createInstalledRuntimeClosureFingerprint(rootNodeModulesDir, dependencyNames) { + const hash = createHash("sha256"); + for (const depName of [...dependencyNames].toSorted((left, right) => left.localeCompare(right))) { + const depRoot = dependencyNodeModulesPath(rootNodeModulesDir, depName); + if (!fs.existsSync(depRoot)) { + return null; + } + hash.update(`package:${depName}\n`); + appendDirectoryFingerprint(hash, depRoot); + } + return hash.digest("hex"); +} + +function resolveInstalledRuntimeClosureFingerprint(params) { + const dependencySpecs = { + ...params.packageJson.dependencies, + ...params.packageJson.optionalDependencies, + }; + if (Object.keys(dependencySpecs).length === 0 || !fs.existsSync(params.rootNodeModulesDir)) { + return null; + } + const directDependencyNames = resolveInstalledDirectDependencyNames( + params.rootNodeModulesDir, + dependencySpecs, + ); + if (directDependencyNames === null) { + return null; + } + const dependencyNames = new Set(directDependencyNames); + const transitiveClosure = collectInstalledRuntimeClosure( + params.rootNodeModulesDir, + dependencySpecs, + ); + if (transitiveClosure !== null) { + for (const depName of transitiveClosure) { + dependencyNames.add(depName); + } + } + return createInstalledRuntimeClosureFingerprint(params.rootNodeModulesDir, dependencyNames); +} + function walkFiles(rootDir, visitFile) { if (!fs.existsSync(rootDir)) { return; @@ -272,13 +354,22 @@ function resolveRuntimeDepsStampPath(pluginDir) { return path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"); } -function createRuntimeDepsFingerprint(packageJson, pruneConfig) { +function createRuntimeDepsFingerprint(packageJson, pruneConfig, params = {}) { + const repoRoot = params.repoRoot; + const lockfilePath = + typeof repoRoot === "string" && repoRoot.length > 0 + ? path.join(repoRoot, "pnpm-lock.yaml") + : null; + const rootLockfile = lockfilePath ? readOptionalUtf8(lockfilePath) : null; return createHash("sha256") .update( JSON.stringify({ globalPruneSuffixes: pruneConfig.globalPruneSuffixes, packageJson, pruneRules: [...pruneConfig.pruneRules.entries()], + rootInstalledRuntimeFingerprint: params.rootInstalledRuntimeFingerprint ?? null, + rootLockfile, + pruneRules: [...pruneConfig.pruneRules.entries()], version: runtimeDepsStagingVersion, }), ) @@ -307,10 +398,20 @@ function stageInstalledRootRuntimeDeps(params) { return false; } - const dependencyNames = collectInstalledRuntimeClosure(rootNodeModulesDir, dependencySpecs); - if (dependencyNames === null) { + const directDependencyNames = resolveInstalledDirectDependencyNames( + rootNodeModulesDir, + dependencySpecs, + ); + if (directDependencyNames === null) { return false; } + const dependencyNames = new Set(directDependencyNames); + const transitiveClosure = collectInstalledRuntimeClosure(rootNodeModulesDir, dependencySpecs); + if (transitiveClosure !== null) { + for (const depName of transitiveClosure) { + dependencyNames.add(depName); + } + } const nodeModulesDir = path.join(pluginDir, "node_modules"); const stampPath = resolveRuntimeDepsStampPath(pluginDir); @@ -323,7 +424,9 @@ function stageInstalledRootRuntimeDeps(params) { ); try { - for (const depName of dependencyNames) { + for (const depName of [...dependencyNames].toSorted((left, right) => + left.localeCompare(right), + )) { const sourcePath = dependencyNodeModulesPath(rootNodeModulesDir, depName); const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, depName); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); @@ -435,7 +538,14 @@ export function stageBundledPluginRuntimeDeps(params = {}) { removePathIfExists(stampPath); continue; } - const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig); + const rootInstalledRuntimeFingerprint = resolveInstalledRuntimeClosureFingerprint({ + packageJson, + rootNodeModulesDir: path.join(repoRoot, "node_modules"), + }); + const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig, { + repoRoot, + rootInstalledRuntimeFingerprint, + }); const stamp = readRuntimeDepsStamp(stampPath); if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) { continue; diff --git a/test/scripts/bundled-plugin-staged-runtime-deps.test.ts b/test/scripts/bundled-plugin-staged-runtime-deps.test.ts index e6d72636d8a..501de4cc4a7 100644 --- a/test/scripts/bundled-plugin-staged-runtime-deps.test.ts +++ b/test/scripts/bundled-plugin-staged-runtime-deps.test.ts @@ -62,4 +62,20 @@ describe("collectBuiltBundledPluginStagedRuntimeDependencyErrors", () => { }), ).toEqual([]); }); + + it("keeps the WhatsApp bundled plugin opted into staged runtime dependencies", () => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "extensions/whatsapp/package.json"), "utf8"), + ) as { + dependencies?: Record; + openclaw?: { + bundle?: { + stageRuntimeDependencies?: boolean; + }; + }; + }; + + expect(packageJson.dependencies?.["@whiskeysockets/baileys"]).toBe("7.0.0-rc.9"); + expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); + }); }); diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index 1363a19cf3b..eff90b0a6f6 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -123,6 +123,77 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n"); }); + it("restages when the root pnpm lockfile changes", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "left-pad": "1.3.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + fs.writeFileSync(path.join(repoRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); + + 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(); + fs.writeFileSync( + path.join(repoRoot, "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\npatchedDependencies:\n left-pad@1.3.0: patches/left-pad.patch\n", + "utf8", + ); + stageOnce(); + + expect(installCount).toBe(2); + expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n"); + }); + + it("restages when installed root runtime dependency contents change", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + fs.mkdirSync(directDir, { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'first';\n", "utf8"); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + expect( + fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), + ).toBe("module.exports = 'first';\n"); + + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'second';\n", "utf8"); + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect( + fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), + ).toBe("module.exports = 'second';\n"); + }); + it("stages runtime deps from the root node_modules when already installed", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: { @@ -189,6 +260,44 @@ describe("stageBundledPluginRuntimeDeps", () => { ).toBe("module.exports = 'transitive';\n"); }); + it("stages nested dependency trees from installed direct package roots", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + const nestedDir = path.join(directDir, "node_modules", "nested"); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0", "dependencies": { "nested": "^1.0.0" } }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); + fs.writeFileSync( + path.join(nestedDir, "package.json"), + '{ "name": "nested", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(nestedDir, "index.js"), "module.exports = 'nested';\n", "utf8"); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect( + fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), + ).toBe("module.exports = 'direct';\n"); + expect( + fs.readFileSync( + path.join(pluginDir, "node_modules", "direct", "node_modules", "nested", "index.js"), + "utf8", + ), + ).toBe("module.exports = 'nested';\n"); + }); + it("removes global non-runtime suffixes from staged runtime dependencies", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {