diff --git a/CHANGELOG.md b/CHANGELOG.md index beb258afcc9..98c4e0f4973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - 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) - Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987. - 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. +- Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures. - Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79. - 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. - 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. diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 92a4039ab4b..dee84a4fc8e 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -1085,6 +1085,107 @@ describe("uninstallPlugin", () => { await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true); }); + it("prunes managed peer dependencies after their owning npm plugin is uninstalled", async () => { + const stateDir = path.join(tempDir, "state"); + const npmRoot = path.join(stateDir, "npm"); + const removedPluginDir = path.join(npmRoot, "node_modules", "removed-plugin"); + const runtimePeerDir = path.join(npmRoot, "node_modules", "runtime-peer"); + await fs.mkdir(removedPluginDir, { recursive: true }); + await fs.mkdir(runtimePeerDir, { recursive: true }); + await fs.writeFile( + path.join(npmRoot, "package.json"), + `${JSON.stringify( + { + private: true, + dependencies: { + "removed-plugin": "1.0.0", + "runtime-peer": "1.0.0", + }, + openclaw: { + managedPeerDependencies: ["runtime-peer"], + }, + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(removedPluginDir, "package.json"), + `${JSON.stringify( + { + name: "removed-plugin", + version: "1.0.0", + peerDependencies: { "runtime-peer": "^1.0.0" }, + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(runtimePeerDir, "package.json"), + `${JSON.stringify({ name: "runtime-peer", version: "1.0.0" }, null, 2)}\n`, + ); + runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => { + if (argv[1] === "uninstall") { + expect(argv).toContain("--legacy-peer-deps"); + await fs.rm(removedPluginDir, { recursive: true, force: true }); + const rootManifest = JSON.parse( + await fs.readFile(path.join(npmRoot, "package.json"), "utf8"), + ) as { dependencies?: Record }; + delete rootManifest.dependencies?.["removed-plugin"]; + await fs.writeFile( + path.join(npmRoot, "package.json"), + `${JSON.stringify(rootManifest, null, 2)}\n`, + ); + return { + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit", + }; + } + if (argv[1] === "install") { + expect(argv).toContain("--legacy-peer-deps"); + expect(argv).toContain("--omit=peer"); + await fs.rm(runtimePeerDir, { recursive: true, force: true }); + return { + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit", + }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }); + + const applied = await applyPluginUninstallDirectoryRemoval({ + target: removedPluginDir, + cleanup: { + kind: "npm", + npmRoot, + packageName: "removed-plugin", + }, + }); + + expect(applied).toEqual({ directoryRemoved: true, warnings: [] }); + await expectPathAccessState(removedPluginDir, "missing"); + await expectPathAccessState(runtimePeerDir, "missing"); + const rootManifest = JSON.parse( + await fs.readFile(path.join(npmRoot, "package.json"), "utf8"), + ) as { + dependencies?: Record; + openclaw?: { managedPeerDependencies?: string[] }; + }; + expect(rootManifest.dependencies?.["removed-plugin"]).toBeUndefined(); + expect(rootManifest.dependencies?.["runtime-peer"]).toBeUndefined(); + expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain("runtime-peer"); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(2); + }); + it("runs npm cleanup when the managed package directory is already absent", async () => { const stateDir = path.join(tempDir, "state"); const npmRoot = path.join(stateDir, "npm"); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 02e98ab8cfb..415f15f3a0c 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -4,6 +4,10 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { + readOpenClawManagedNpmRootOverrides, + syncManagedNpmRootPeerDependencies, +} from "../infra/npm-managed-root.js"; import { createSafeNpmInstallEnv } from "../infra/safe-package-install.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { @@ -632,6 +636,50 @@ export async function applyPluginUninstallDirectoryRemoval( }`, ); } + try { + const managedOverrides = await readOpenClawManagedNpmRootOverrides(); + const syncedPeerDependencies = await syncManagedNpmRootPeerDependencies({ + npmRoot: removal.cleanup.npmRoot, + managedOverrides, + }); + if (syncedPeerDependencies) { + const cleanup = await runCommandWithTimeout( + [ + "npm", + "install", + "--omit=dev", + "--omit=peer", + "--loglevel=error", + "--legacy-peer-deps", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], + { + cwd: removal.cleanup.npmRoot, + timeoutMs: 300_000, + env: createSafeNpmInstallEnv(process.env, { + legacyPeerDeps: true, + packageLock: true, + quiet: true, + }), + }, + ); + if (cleanup.code !== 0) { + warnings.push( + `Failed to prune managed peer dependencies after uninstalling ${removal.cleanup.packageName}: ${ + cleanup.stderr.trim() || + cleanup.stdout.trim() || + `npm exited with code ${cleanup.code}` + }`, + ); + } + } + } catch (error) { + warnings.push( + `Failed to sync managed peer dependencies after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`, + ); + } try { await relinkOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot: removal.cleanup.npmRoot,