fix(plugins): restore missing native runtime deps

This commit is contained in:
Peter Steinberger
2026-04-12 19:25:01 +01:00
parent bb064d359a
commit d77360c076
3 changed files with 128 additions and 1 deletions

View File

@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp: centralize per-account connection ownership so reconnects, login recovery, and outbound readiness stay attached to the live socket instead of drifting across monitor and login paths. (#65290) Thanks @mcaxtr and @vincentkoc.
- iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, and sanitize startup error logging so brief local transport stalls do not immediately bounce the channel or leak raw imsg RPC payloads into logs. (#65393) Thanks @vincentkoc.
- CLI/audio providers: report env-authenticated providers as configured in `openclaw infer audio providers --json`, while keeping trusted workspace provider env lookup defaults stable during auth setup. (#65491)
- Plugins/install: reinstall bundled runtime packages when the matching platform native optional child is missing, so packaged Windows installs can recover dependencies that were packed on another host OS.
## 2026.4.11

View File

@@ -26,6 +26,54 @@ function dependencySentinelPath(depName) {
return join("node_modules", ...depName.split("/"), "package.json");
}
const KNOWN_NATIVE_PLATFORMS = new Set([
"aix",
"android",
"darwin",
"freebsd",
"linux",
"openbsd",
"sunos",
"win32",
]);
const KNOWN_NATIVE_ARCHES = new Set(["arm", "arm64", "ia32", "ppc64", "riscv64", "s390x", "x64"]);
function packageNameTokens(name) {
return name
.toLowerCase()
.split(/[/@._-]+/u)
.filter(Boolean);
}
function optionalDependencyTargetsRuntime(name, params = {}) {
const platform = params.platform ?? process.platform;
const arch = params.arch ?? process.arch;
const tokens = new Set(packageNameTokens(name));
const hasNativePlatformToken = [...tokens].some((token) => KNOWN_NATIVE_PLATFORMS.has(token));
const hasNativeArchToken = [...tokens].some((token) => KNOWN_NATIVE_ARCHES.has(token));
return hasNativePlatformToken && hasNativeArchToken && tokens.has(platform) && tokens.has(arch);
}
function runtimeDepNeedsInstall(params) {
const packageJsonPath = join(params.packageRoot, params.dep.sentinelPath);
if (!params.existsSync(packageJsonPath)) {
return true;
}
try {
const packageJson = params.readJson(packageJsonPath);
return Object.keys(packageJson.optionalDependencies ?? {}).some(
(childName) =>
optionalDependencyTargetsRuntime(childName, {
arch: params.arch,
platform: params.platform,
}) && !params.existsSync(join(params.packageRoot, dependencySentinelPath(childName))),
);
} catch {
return true;
}
}
function collectRuntimeDeps(packageJson) {
return {
...packageJson.dependencies,
@@ -184,7 +232,16 @@ export function runBundledPluginPostinstall(params = {}) {
params.runtimeDeps ??
discoverBundledPluginRuntimeDeps({ extensionsDir, existsSync: pathExists });
const missingSpecs = runtimeDeps
.filter((dep) => !pathExists(join(packageRoot, dep.sentinelPath)))
.filter((dep) =>
runtimeDepNeedsInstall({
dep,
existsSync: pathExists,
packageRoot,
arch: params.arch,
platform: params.platform,
readJson: params.readJson ?? readJson,
}),
)
.map((dep) => `${dep.name}@${dep.version}`);
if (missingSpecs.length === 0) {

View File

@@ -227,6 +227,75 @@ describe("bundled plugin postinstall", () => {
expect(spawnSync).not.toHaveBeenCalled();
});
it("reinstalls bundled runtime deps when optional native children are missing", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writePluginPackage(extensionsDir, "discord", {
dependencies: {
"@snazzah/davey": "0.1.11",
},
});
await fs.mkdir(path.join(packageRoot, "node_modules", "@snazzah", "davey"), {
recursive: true,
});
await fs.writeFile(
path.join(packageRoot, "node_modules", "@snazzah", "davey", "package.json"),
JSON.stringify({
optionalDependencies: {
"@snazzah/davey-win32-arm64-msvc": "0.1.11",
},
}),
);
const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" }));
runBundledPluginPostinstall({
env: { HOME: "/tmp/home" },
extensionsDir,
packageRoot,
arch: "arm64",
npmRunner: createBareNpmRunner(["@snazzah/davey@0.1.11"]),
platform: "win32",
spawnSync,
log: { log: vi.fn(), warn: vi.fn() },
});
expectNpmInstallSpawn(spawnSync, packageRoot, ["@snazzah/davey@0.1.11"]);
});
it("does not reinstall when only another platform optional native child is missing", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writePluginPackage(extensionsDir, "discord", {
dependencies: {
"@snazzah/davey": "0.1.11",
},
});
await fs.mkdir(path.join(packageRoot, "node_modules", "@snazzah", "davey"), {
recursive: true,
});
await fs.writeFile(
path.join(packageRoot, "node_modules", "@snazzah", "davey", "package.json"),
JSON.stringify({
optionalDependencies: {
"@snazzah/davey-win32-arm64-msvc": "0.1.11",
},
}),
);
const spawnSync = vi.fn();
runBundledPluginPostinstall({
env: { HOME: "/tmp/home" },
extensionsDir,
packageRoot,
arch: "arm64",
platform: "darwin",
spawnSync,
log: { log: vi.fn(), warn: vi.fn() },
});
expect(spawnSync).not.toHaveBeenCalled();
});
it("discovers bundled plugin runtime deps from extension manifests", async () => {
const extensionsDir = await createExtensionsDir();
await writePluginPackage(extensionsDir, "slack", {