Skip to content

Commit 2a67a7f

Browse files
committed
fix(plugins): prune managed peers on uninstall
1 parent 0513b28 commit 2a67a7f

3 files changed

Lines changed: 150 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
- Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601)
1515
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
1616
- Plugins/install: preserve third-party peer dependencies in the managed npm root when later plugin installs or updates recalculate the shared dependency tree. Thanks @shakkernerd.
17+
- Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures.
1718
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
1819
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
1920
- Control UI/WebChat: keep short assistant replies clear of in-bubble copy/open action buttons by applying the existing reserved action spacing in the grouped chat renderer. Fixes #79509. (#81244) Thanks @JARVIS-Glasses.

src/plugins/uninstall.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,107 @@ describe("uninstallPlugin", () => {
10851085
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
10861086
});
10871087

1088+
it("prunes managed peer dependencies after their owning npm plugin is uninstalled", async () => {
1089+
const stateDir = path.join(tempDir, "state");
1090+
const npmRoot = path.join(stateDir, "npm");
1091+
const removedPluginDir = path.join(npmRoot, "node_modules", "removed-plugin");
1092+
const runtimePeerDir = path.join(npmRoot, "node_modules", "runtime-peer");
1093+
await fs.mkdir(removedPluginDir, { recursive: true });
1094+
await fs.mkdir(runtimePeerDir, { recursive: true });
1095+
await fs.writeFile(
1096+
path.join(npmRoot, "package.json"),
1097+
`${JSON.stringify(
1098+
{
1099+
private: true,
1100+
dependencies: {
1101+
"removed-plugin": "1.0.0",
1102+
"runtime-peer": "1.0.0",
1103+
},
1104+
openclaw: {
1105+
managedPeerDependencies: ["runtime-peer"],
1106+
},
1107+
},
1108+
null,
1109+
2,
1110+
)}\n`,
1111+
);
1112+
await fs.writeFile(
1113+
path.join(removedPluginDir, "package.json"),
1114+
`${JSON.stringify(
1115+
{
1116+
name: "removed-plugin",
1117+
version: "1.0.0",
1118+
peerDependencies: { "runtime-peer": "^1.0.0" },
1119+
},
1120+
null,
1121+
2,
1122+
)}\n`,
1123+
);
1124+
await fs.writeFile(
1125+
path.join(runtimePeerDir, "package.json"),
1126+
`${JSON.stringify({ name: "runtime-peer", version: "1.0.0" }, null, 2)}\n`,
1127+
);
1128+
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
1129+
if (argv[1] === "uninstall") {
1130+
expect(argv).toContain("--legacy-peer-deps");
1131+
await fs.rm(removedPluginDir, { recursive: true, force: true });
1132+
const rootManifest = JSON.parse(
1133+
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
1134+
) as { dependencies?: Record<string, string> };
1135+
delete rootManifest.dependencies?.["removed-plugin"];
1136+
await fs.writeFile(
1137+
path.join(npmRoot, "package.json"),
1138+
`${JSON.stringify(rootManifest, null, 2)}\n`,
1139+
);
1140+
return {
1141+
code: 0,
1142+
stdout: "",
1143+
stderr: "",
1144+
signal: null,
1145+
killed: false,
1146+
termination: "exit",
1147+
};
1148+
}
1149+
if (argv[1] === "install") {
1150+
expect(argv).toContain("--legacy-peer-deps");
1151+
expect(argv).toContain("--omit=peer");
1152+
await fs.rm(runtimePeerDir, { recursive: true, force: true });
1153+
return {
1154+
code: 0,
1155+
stdout: "",
1156+
stderr: "",
1157+
signal: null,
1158+
killed: false,
1159+
termination: "exit",
1160+
};
1161+
}
1162+
throw new Error(`unexpected command: ${argv.join(" ")}`);
1163+
});
1164+
1165+
const applied = await applyPluginUninstallDirectoryRemoval({
1166+
target: removedPluginDir,
1167+
cleanup: {
1168+
kind: "npm",
1169+
npmRoot,
1170+
packageName: "removed-plugin",
1171+
},
1172+
});
1173+
1174+
expect(applied).toEqual({ directoryRemoved: true, warnings: [] });
1175+
await expectPathAccessState(removedPluginDir, "missing");
1176+
await expectPathAccessState(runtimePeerDir, "missing");
1177+
const rootManifest = JSON.parse(
1178+
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
1179+
) as {
1180+
dependencies?: Record<string, string>;
1181+
openclaw?: { managedPeerDependencies?: string[] };
1182+
};
1183+
expect(rootManifest.dependencies?.["removed-plugin"]).toBeUndefined();
1184+
expect(rootManifest.dependencies?.["runtime-peer"]).toBeUndefined();
1185+
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain("runtime-peer");
1186+
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(2);
1187+
});
1188+
10881189
it("runs npm cleanup when the managed package directory is already absent", async () => {
10891190
const stateDir = path.join(tempDir, "state");
10901191
const npmRoot = path.join(stateDir, "npm");

src/plugins/uninstall.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import path from "node:path";
44
import type { OpenClawConfig } from "../config/types.openclaw.js";
55
import type { PluginInstallRecord } from "../config/types.plugins.js";
66
import { formatErrorMessage } from "../infra/errors.js";
7+
import {
8+
readOpenClawManagedNpmRootOverrides,
9+
syncManagedNpmRootPeerDependencies,
10+
} from "../infra/npm-managed-root.js";
711
import { createSafeNpmInstallEnv } from "../infra/safe-package-install.js";
812
import { runCommandWithTimeout } from "../process/exec.js";
913
import {
@@ -632,6 +636,50 @@ export async function applyPluginUninstallDirectoryRemoval(
632636
}`,
633637
);
634638
}
639+
try {
640+
const managedOverrides = await readOpenClawManagedNpmRootOverrides();
641+
const syncedPeerDependencies = await syncManagedNpmRootPeerDependencies({
642+
npmRoot: removal.cleanup.npmRoot,
643+
managedOverrides,
644+
});
645+
if (syncedPeerDependencies) {
646+
const cleanup = await runCommandWithTimeout(
647+
[
648+
"npm",
649+
"install",
650+
"--omit=dev",
651+
"--omit=peer",
652+
"--loglevel=error",
653+
"--legacy-peer-deps",
654+
"--ignore-scripts",
655+
"--no-audit",
656+
"--no-fund",
657+
],
658+
{
659+
cwd: removal.cleanup.npmRoot,
660+
timeoutMs: 300_000,
661+
env: createSafeNpmInstallEnv(process.env, {
662+
legacyPeerDeps: true,
663+
packageLock: true,
664+
quiet: true,
665+
}),
666+
},
667+
);
668+
if (cleanup.code !== 0) {
669+
warnings.push(
670+
`Failed to prune managed peer dependencies after uninstalling ${removal.cleanup.packageName}: ${
671+
cleanup.stderr.trim() ||
672+
cleanup.stdout.trim() ||
673+
`npm exited with code ${cleanup.code}`
674+
}`,
675+
);
676+
}
677+
}
678+
} catch (error) {
679+
warnings.push(
680+
`Failed to sync managed peer dependencies after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`,
681+
);
682+
}
635683
try {
636684
await relinkOpenClawPeerDependenciesInManagedNpmRoot({
637685
npmRoot: removal.cleanup.npmRoot,

0 commit comments

Comments
 (0)