fix: stage patched WhatsApp Baileys runtime deps

This commit is contained in:
Frank Yang
2026-04-14 13:03:06 +08:00
parent 8a72ebc6d0
commit 6f30d1a294
4 changed files with 243 additions and 5 deletions

View File

@@ -47,6 +47,9 @@
"compat": {
"pluginApi": ">=2026.4.12"
},
"bundle": {
"stageRuntimeDependencies": true
},
"build": {
"openclawVersion": "2026.4.12"
},

View File

@@ -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;

View File

@@ -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<string, string>;
openclaw?: {
bundle?: {
stageRuntimeDependencies?: boolean;
};
};
};
expect(packageJson.dependencies?.["@whiskeysockets/baileys"]).toBe("7.0.0-rc.9");
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
});
});

View File

@@ -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: {