// Removes installed plugins and updates plugin index records. import { lstatSync } from "node:fs"; import fs from "node:fs/promises"; 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 } from "../infra/npm-managed-root.js"; import { createSafeNpmInstallEnv } from "../infra/safe-package-install.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveDefaultPluginGitDir, resolveDefaultPluginNpmDir, resolvePluginInstallDir, resolvePluginNpmProjectsDir, } from "./install-paths.js"; import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-link.js"; import { defaultSlotIdForKey } from "./slots.js"; import { isUninstallPathInsideOrEqual, removePluginFromConfig, resolveComparableUninstallPath, type PluginConfigUninstallActions, } from "./uninstall-config.js"; import { pruneManagedNpmPeerDependenciesAfterUninstall } from "./uninstall-managed-npm.js"; export { resolveUninstallChannelConfigKeys } from "./uninstall-config.js"; type UninstallActions = PluginConfigUninstallActions & { directory: boolean; }; export const UNINSTALL_ACTION_LABELS = { entry: "config entry", install: "install record", allowlist: "allowlist entry", denylist: "denylist entry", loadPath: "load path", memorySlot: "memory slot", contextEngineSlot: "context engine slot", channelConfig: "channel config", directory: "directory", } satisfies Record; const UNINSTALL_ACTION_ORDER = [ "entry", "install", "allowlist", "denylist", "loadPath", "memorySlot", "contextEngineSlot", "channelConfig", "directory", ] as const satisfies ReadonlyArray; export function formatUninstallActionLabels(actions: UninstallActions): string[] { return UNINSTALL_ACTION_ORDER.flatMap((key) => actions[key] ? [UNINSTALL_ACTION_LABELS[key]] : [], ); } /** Keep a staged plugin disabled until its managed directory is removed. */ export function prepareConfigForPendingPluginDirectoryRemoval( config: OpenClawConfig, pluginId: string, ): OpenClawConfig { return { ...config, plugins: { ...config.plugins, entries: { ...config.plugins?.entries, [pluginId]: { ...config.plugins?.entries?.[pluginId], enabled: false, }, }, }, }; } function hasUninstallAction(actions: PluginConfigUninstallActions): boolean { return Object.values(actions).some(Boolean); } export function formatUninstallSlotResetPreview(slotKey: "memory" | "contextEngine"): string { const actionKey = slotKey === "memory" ? "memorySlot" : "contextEngineSlot"; return `${UNINSTALL_ACTION_LABELS[actionKey]} (will reset to "${defaultSlotIdForKey(slotKey)}")`; } export type PluginUninstallDirectoryRemoval = { target: string; cleanup?: | { kind: "npm"; npmRoot: string; packageName: string; } | { kind: "git"; parentDir: string; }; }; type PluginUninstallPlanResult = | { ok: true; config: OpenClawConfig; pluginId: string; actions: UninstallActions; directoryRemoval: PluginUninstallDirectoryRemoval | null; } | { ok: false; error: string }; function resolveUninstallDirectoryTarget(params: { pluginId: string; hasInstall: boolean; installRecord?: PluginInstallRecord; extensionsDir?: string; }): string | null { if (!params.hasInstall) { return null; } if (isLinkedPathInstallRecord(params.installRecord)) { return null; } const npmManagedInstall = resolveNpmManagedInstall({ installRecord: params.installRecord, extensionsDir: params.extensionsDir, }); if (npmManagedInstall) { return npmManagedInstall.installPath; } const gitManagedInstall = resolveGitManagedInstall({ installRecord: params.installRecord, extensionsDir: params.extensionsDir, }); if (gitManagedInstall) { return gitManagedInstall.installPath; } let defaultPath: string; try { defaultPath = resolvePluginInstallDir(params.pluginId, params.extensionsDir); } catch { return null; } const configuredPath = params.installRecord?.installPath; if (!configuredPath) { return defaultPath; } if (path.resolve(configuredPath) === path.resolve(defaultPath)) { return configuredPath; } if (params.extensionsDir && isUninstallPathInsideOrEqual(params.extensionsDir, configuredPath)) { return configuredPath; } const recordedManagedPath = resolveRecordedManagedInstallPath({ pluginId: params.pluginId, installPath: configuredPath, }); if (recordedManagedPath) { return recordedManagedPath; } // Never trust configured installPath blindly for recursive deletes outside // the managed extensions directory. return defaultPath; } function resolveNpmManagedInstall(params: { installRecord?: PluginInstallRecord; extensionsDir?: string; }): { installPath: string; npmRoot: string; packageName: string } | null { const installPath = params.installRecord?.installPath?.trim(); if (params.installRecord?.source !== "npm" || !installPath) { return null; } const npmRoots = new Set(); if (params.extensionsDir) { npmRoots.add(path.join(path.dirname(path.resolve(params.extensionsDir)), "npm")); } npmRoots.add(resolveDefaultPluginNpmDir()); for (const npmRoot of npmRoots) { const nodeModulesRoot = path.join(npmRoot, "node_modules"); if ( isUninstallPathInsideOrEqual(nodeModulesRoot, installPath) && resolveComparableUninstallPath(nodeModulesRoot) !== resolveComparableUninstallPath(installPath) ) { const packageName = resolveNpmPackageNameFromInstallPath({ installPath, nodeModulesRoot }); return packageName ? { installPath, npmRoot, packageName } : null; } const projectMatch = resolveNpmManagedProjectInstall({ installPath, projectsDir: resolvePluginNpmProjectsDir(npmRoot), }); if (projectMatch) { return projectMatch; } } return null; } function resolveNpmManagedProjectInstall(params: { installPath: string; projectsDir: string; }): { installPath: string; npmRoot: string; packageName: string } | null { if ( !isUninstallPathInsideOrEqual(params.projectsDir, params.installPath) || resolveComparableUninstallPath(params.projectsDir) === resolveComparableUninstallPath(params.installPath) ) { return null; } const relativePath = path.relative( path.resolve(params.projectsDir), path.resolve(params.installPath), ); const segments = relativePath.split(path.sep).filter(Boolean); if (segments.length < 3 || segments[1] !== "node_modules") { return null; } const npmRoot = path.join(params.projectsDir, segments[0] ?? ""); const nodeModulesRoot = path.join(npmRoot, "node_modules"); const packageName = resolveNpmPackageNameFromInstallPath({ installPath: params.installPath, nodeModulesRoot, }); return packageName ? { installPath: params.installPath, npmRoot, packageName } : null; } function resolveNpmPackageNameFromInstallPath(params: { installPath: string; nodeModulesRoot: string; }): string | null { const relativePath = path.relative( path.resolve(params.nodeModulesRoot), path.resolve(params.installPath), ); if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { return null; } const segments = relativePath.split(path.sep).filter(Boolean); if (segments.length < 1) { return null; } if (segments[0]?.startsWith("@")) { return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null; } return segments[0] ?? null; } function resolveGitManagedInstall(params: { installRecord?: PluginInstallRecord; extensionsDir?: string; }): { installPath: string; parentDir: string } | null { const installPath = params.installRecord?.installPath?.trim(); if (params.installRecord?.source !== "git" || !installPath) { return null; } const gitRoots = new Set(); if (params.extensionsDir) { gitRoots.add(path.join(path.dirname(path.resolve(params.extensionsDir)), "git")); } gitRoots.add(resolveDefaultPluginGitDir()); for (const gitRoot of gitRoots) { if ( isUninstallPathInsideOrEqual(gitRoot, installPath) && resolveComparableUninstallPath(gitRoot) !== resolveComparableUninstallPath(installPath) ) { return { installPath, parentDir: path.dirname(installPath) }; } } return null; } function resolveRecordedManagedInstallPath(params: { pluginId: string; installPath: string; }): string | null { const resolvedInstallPath = path.resolve(params.installPath); const recordedExtensionsDir = path.dirname(resolvedInstallPath); if (path.basename(recordedExtensionsDir) !== "extensions") { return null; } try { const canonicalInstallPath = path.resolve( resolvePluginInstallDir(params.pluginId, recordedExtensionsDir), ); return canonicalInstallPath === resolvedInstallPath ? params.installPath : null; } catch { return null; } } function isLinkedPathInstallRecord(installRecord: PluginInstallRecord | undefined): boolean { if (installRecord?.source !== "path") { return false; } if (!installRecord.sourcePath || !installRecord.installPath) { return true; } return ( resolveComparableUninstallPath(installRecord.sourcePath) === resolveComparableUninstallPath(installRecord.installPath) ); } type UninstallPluginParams = { config: OpenClawConfig; pluginId: string; channelIds?: string[]; deleteFiles?: boolean; extensionsDir?: string; }; /** * Plan a plugin uninstall by removing it from config and resolving a safe file-removal target. * Linked path plugins never have their source directory deleted. Copied path installs still remove * their managed install directory. */ export function planPluginUninstall(params: UninstallPluginParams): PluginUninstallPlanResult { const { config, pluginId, channelIds, deleteFiles = true, extensionsDir } = params; const entries = config.plugins?.entries ?? {}; const installs = config.plugins?.installs ?? {}; const hasEntry = Object.hasOwn(entries, pluginId); const hasInstall = Object.hasOwn(installs, pluginId); const installRecord = hasInstall ? installs[pluginId] : undefined; const isLinked = isLinkedPathInstallRecord(installRecord); // Remove from config const { config: newConfig, actions: configActions } = removePluginFromConfig(config, pluginId, { channelIds, }); if (!hasEntry && !hasInstall && !hasUninstallAction(configActions)) { return { ok: false, error: `Plugin not found: ${pluginId}` }; } const actions: UninstallActions = { ...configActions, directory: false, }; const npmManagedInstall = deleteFiles && !isLinked ? resolveNpmManagedInstall({ installRecord, extensionsDir, }) : null; const gitManagedInstall = deleteFiles && !isLinked ? resolveGitManagedInstall({ installRecord, extensionsDir, }) : null; const deleteTarget = deleteFiles && !isLinked ? resolveUninstallDirectoryTarget({ pluginId, hasInstall, installRecord, extensionsDir, }) : null; return { ok: true, config: newConfig, pluginId, actions, directoryRemoval: deleteTarget ? { target: deleteTarget, ...(npmManagedInstall ? { cleanup: { kind: "npm", npmRoot: npmManagedInstall.npmRoot, packageName: npmManagedInstall.packageName, }, } : gitManagedInstall && deleteTarget === gitManagedInstall.installPath ? { cleanup: { kind: "git", parentDir: gitManagedInstall.parentDir, }, } : {}), } : null, }; } export function pluginUninstallTargetExists(target: string): boolean { try { lstatSync(target); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== "ENOENT"; } } export async function applyPluginUninstallDirectoryRemoval( removal: PluginUninstallDirectoryRemoval | null, ): Promise<{ directoryRemoved: boolean; warnings: string[] }> { if (!removal) { return { directoryRemoved: false, warnings: [] }; } const existed = pluginUninstallTargetExists(removal.target); const warnings: string[] = []; if (!existed && removal.cleanup?.kind !== "npm") { return { directoryRemoved: false, warnings }; } const npmCleanupManifestExists = removal.cleanup?.kind === "npm" ? await fs .access(path.join(removal.cleanup.npmRoot, "package.json")) .then(() => true) .catch(() => false) : false; if (!existed && removal.cleanup?.kind === "npm" && !npmCleanupManifestExists) { return { directoryRemoved: false, warnings }; } if (removal.cleanup?.kind === "npm" && npmCleanupManifestExists) { const uninstall = await runCommandWithTimeout( [ "npm", "uninstall", "--loglevel=error", "--legacy-peer-deps", "--ignore-scripts", "--no-audit", "--no-fund", removal.cleanup.packageName, ], { cwd: removal.cleanup.npmRoot, timeoutMs: 300_000, env: createSafeNpmInstallEnv(process.env, { legacyPeerDeps: true, npmConfigCwd: removal.cleanup.npmRoot, packageLock: true, quiet: true, }), }, ); if (uninstall.code !== 0) { warnings.push( `Failed to prune npm dependencies for plugin package ${removal.cleanup.packageName}: ${ uninstall.stderr.trim() || uninstall.stdout.trim() || `npm exited with code ${uninstall.code}` }`, ); } try { const managedOverrides = await readOpenClawManagedNpmRootOverrides(); const warning = await pruneManagedNpmPeerDependenciesAfterUninstall({ npmRoot: removal.cleanup.npmRoot, packageName: removal.cleanup.packageName, managedOverrides, }); if (warning) { warnings.push(warning); } } catch (error) { warnings.push( `Failed to sync managed peer dependencies after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`, ); } try { await relinkOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot: removal.cleanup.npmRoot, logger: { warn: (message) => warnings.push(message), }, }); } catch (error) { warnings.push( `Failed to repair managed npm peer links after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`, ); } } try { await fs.rm(removal.target, { recursive: true, force: true }); if (removal.cleanup?.kind === "git") { try { await fs.rmdir(removal.cleanup.parentDir); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTEMPTY") { warnings.push( `Failed to remove empty git plugin install parent ${removal.cleanup.parentDir}: ${formatErrorMessage(error)}`, ); } } } return { directoryRemoved: existed, warnings }; } catch (error) { return { directoryRemoved: false, warnings: [ ...warnings, `Failed to remove plugin directory ${removal.target}: ${formatErrorMessage(error)}`, ], }; } }