mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
CLI: add plugins uninstall command (#5985) (openclaw#6141) thanks @JustasMonkev
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test Co-authored-by: JustasMonkev <59362982+JustasMonkev@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/Plugins: add `openclaw plugins uninstall <id>` with `--dry-run`, `--force`, and `--keep-files` options, including safe uninstall path handling and plugin uninstall docs. (#5985) Thanks @JustasMonkev.
|
||||
- CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
|
||||
- Telegram: render blockquotes as native `<blockquote>` tags instead of stripping them. (#14608)
|
||||
- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, enable/disable, doctor)"
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
|
||||
read_when:
|
||||
- You want to install or manage in-process Gateway plugins
|
||||
- You want to debug plugin load failures
|
||||
@@ -23,6 +23,7 @@ openclaw plugins list
|
||||
openclaw plugins info <id>
|
||||
openclaw plugins enable <id>
|
||||
openclaw plugins disable <id>
|
||||
openclaw plugins uninstall <id>
|
||||
openclaw plugins doctor
|
||||
openclaw plugins update <id>
|
||||
openclaw plugins update --all
|
||||
@@ -51,6 +52,24 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
openclaw plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
openclaw plugins uninstall <id>
|
||||
openclaw plugins uninstall <id> --dry-run
|
||||
openclaw plugins uninstall <id> --keep-files
|
||||
```
|
||||
|
||||
`uninstall` removes plugin records from `plugins.entries`, `plugins.installs`,
|
||||
the plugin allowlist, and linked `plugins.load.paths` entries when applicable.
|
||||
For active memory plugins, the memory slot resets to `memory-core`.
|
||||
|
||||
By default, uninstall also removes the plugin install directory under the active
|
||||
state dir extensions root (`$OPENCLAW_STATE_DIR/extensions/<id>`). Use
|
||||
`--keep-files` to keep files on disk.
|
||||
|
||||
`--keep-config` is supported as a deprecated alias for `--keep-files`.
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { Command } from "commander";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginRecord } from "../plugins/registry.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveArchiveKind } from "../infra/archive.js";
|
||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
||||
import { recordPluginInstall } from "../plugins/installs.js";
|
||||
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
|
||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||
import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js";
|
||||
import { updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js";
|
||||
import { promptYesNo } from "./prompt.js";
|
||||
|
||||
export type PluginsListOptions = {
|
||||
json?: boolean;
|
||||
@@ -32,6 +36,13 @@ export type PluginUpdateOptions = {
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export type PluginUninstallOptions = {
|
||||
keepFiles?: boolean;
|
||||
keepConfig?: boolean;
|
||||
force?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
||||
const status =
|
||||
plugin.status === "loaded"
|
||||
@@ -332,6 +343,141 @@ export function registerPluginsCli(program: Command) {
|
||||
defaultRuntime.log(`Disabled plugin "${id}". Restart the gateway to apply.`);
|
||||
});
|
||||
|
||||
plugins
|
||||
.command("uninstall")
|
||||
.description("Uninstall a plugin")
|
||||
.argument("<id>", "Plugin id")
|
||||
.option("--keep-files", "Keep installed files on disk", false)
|
||||
.option("--keep-config", "Deprecated alias for --keep-files", false)
|
||||
.option("--force", "Skip confirmation prompt", false)
|
||||
.option("--dry-run", "Show what would be removed without making changes", false)
|
||||
.action(async (id: string, opts: PluginUninstallOptions) => {
|
||||
const cfg = loadConfig();
|
||||
const report = buildPluginStatusReport({ config: cfg });
|
||||
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
|
||||
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
|
||||
|
||||
if (opts.keepConfig) {
|
||||
defaultRuntime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`."));
|
||||
}
|
||||
|
||||
// Find plugin by id or name
|
||||
const plugin = report.plugins.find((p) => p.id === id || p.name === id);
|
||||
const pluginId = plugin?.id ?? id;
|
||||
|
||||
// Check if plugin exists in config
|
||||
const hasEntry = pluginId in (cfg.plugins?.entries ?? {});
|
||||
const hasInstall = pluginId in (cfg.plugins?.installs ?? {});
|
||||
|
||||
if (!hasEntry && !hasInstall) {
|
||||
if (plugin) {
|
||||
defaultRuntime.error(
|
||||
`Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`,
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.error(`Plugin not found: ${id}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const install = cfg.plugins?.installs?.[pluginId];
|
||||
const isLinked = install?.source === "path";
|
||||
|
||||
// Build preview of what will be removed
|
||||
const preview: string[] = [];
|
||||
if (hasEntry) {
|
||||
preview.push("config entry");
|
||||
}
|
||||
if (hasInstall) {
|
||||
preview.push("install record");
|
||||
}
|
||||
if (cfg.plugins?.allow?.includes(pluginId)) {
|
||||
preview.push("allowlist entry");
|
||||
}
|
||||
if (
|
||||
isLinked &&
|
||||
install?.sourcePath &&
|
||||
cfg.plugins?.load?.paths?.includes(install.sourcePath)
|
||||
) {
|
||||
preview.push("load path");
|
||||
}
|
||||
if (cfg.plugins?.slots?.memory === pluginId) {
|
||||
preview.push(`memory slot (will reset to "memory-core")`);
|
||||
}
|
||||
const deleteTarget = !keepFiles
|
||||
? resolveUninstallDirectoryTarget({
|
||||
pluginId,
|
||||
hasInstall,
|
||||
installRecord: install,
|
||||
extensionsDir,
|
||||
})
|
||||
: null;
|
||||
if (deleteTarget) {
|
||||
preview.push(`directory: ${shortenHomePath(deleteTarget)}`);
|
||||
}
|
||||
|
||||
const pluginName = plugin?.name || pluginId;
|
||||
defaultRuntime.log(
|
||||
`Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`,
|
||||
);
|
||||
defaultRuntime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`);
|
||||
|
||||
if (opts.dryRun) {
|
||||
defaultRuntime.log(theme.muted("Dry run, no changes made."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.force) {
|
||||
const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
|
||||
if (!confirmed) {
|
||||
defaultRuntime.log("Cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await uninstallPlugin({
|
||||
config: cfg,
|
||||
pluginId,
|
||||
deleteFiles: !keepFiles,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const warning of result.warnings) {
|
||||
defaultRuntime.log(theme.warn(warning));
|
||||
}
|
||||
|
||||
await writeConfigFile(result.config);
|
||||
|
||||
const removed: string[] = [];
|
||||
if (result.actions.entry) {
|
||||
removed.push("config entry");
|
||||
}
|
||||
if (result.actions.install) {
|
||||
removed.push("install record");
|
||||
}
|
||||
if (result.actions.allowlist) {
|
||||
removed.push("allowlist");
|
||||
}
|
||||
if (result.actions.loadPath) {
|
||||
removed.push("load path");
|
||||
}
|
||||
if (result.actions.memorySlot) {
|
||||
removed.push("memory slot");
|
||||
}
|
||||
if (result.actions.directory) {
|
||||
removed.push("directory");
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
`Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`,
|
||||
);
|
||||
defaultRuntime.log("Restart the gateway to apply changes.");
|
||||
});
|
||||
|
||||
plugins
|
||||
.command("install")
|
||||
.description("Install a plugin (path, archive, or npm spec)")
|
||||
|
||||
538
src/plugins/uninstall.test.ts
Normal file
538
src/plugins/uninstall.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolvePluginInstallDir } from "./install.js";
|
||||
import {
|
||||
removePluginFromConfig,
|
||||
resolveUninstallDirectoryTarget,
|
||||
uninstallPlugin,
|
||||
} from "./uninstall.js";
|
||||
|
||||
describe("removePluginFromConfig", () => {
|
||||
it("removes plugin from entries", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
"other-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.entries).toEqual({ "other-plugin": { enabled: true } });
|
||||
expect(actions.entry).toBe(true);
|
||||
});
|
||||
|
||||
it("removes plugin from installs", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"my-plugin": { source: "npm", spec: "my-plugin@1.0.0" },
|
||||
"other-plugin": { source: "npm", spec: "other-plugin@1.0.0" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.installs).toEqual({
|
||||
"other-plugin": { source: "npm", spec: "other-plugin@1.0.0" },
|
||||
});
|
||||
expect(actions.install).toBe(true);
|
||||
});
|
||||
|
||||
it("removes plugin from allowlist", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
allow: ["my-plugin", "other-plugin"],
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.allow).toEqual(["other-plugin"]);
|
||||
expect(actions.allowlist).toBe(true);
|
||||
});
|
||||
|
||||
it("removes linked path from load.paths", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"my-plugin": {
|
||||
source: "path",
|
||||
sourcePath: "/path/to/plugin",
|
||||
installPath: "/path/to/plugin",
|
||||
},
|
||||
},
|
||||
load: {
|
||||
paths: ["/path/to/plugin", "/other/path"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.load?.paths).toEqual(["/other/path"]);
|
||||
expect(actions.loadPath).toBe(true);
|
||||
});
|
||||
|
||||
it("cleans up load when removing the only linked path", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"my-plugin": {
|
||||
source: "path",
|
||||
sourcePath: "/path/to/plugin",
|
||||
installPath: "/path/to/plugin",
|
||||
},
|
||||
},
|
||||
load: {
|
||||
paths: ["/path/to/plugin"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.load).toBeUndefined();
|
||||
expect(actions.loadPath).toBe(true);
|
||||
});
|
||||
|
||||
it("clears memory slot when uninstalling active memory plugin", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-plugin": { enabled: true },
|
||||
},
|
||||
slots: {
|
||||
memory: "memory-plugin",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "memory-plugin");
|
||||
|
||||
expect(result.plugins?.slots?.memory).toBe("memory-core");
|
||||
expect(actions.memorySlot).toBe(true);
|
||||
});
|
||||
|
||||
it("does not modify memory slot when uninstalling non-memory plugin", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
slots: {
|
||||
memory: "memory-core",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.slots?.memory).toBe("memory-core");
|
||||
expect(actions.memorySlot).toBe(false);
|
||||
});
|
||||
|
||||
it("removes plugins object when uninstall leaves only empty slots", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
slots: {},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.slots).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cleans up empty slots object", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
slots: {},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles plugin that only exists in entries", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.entries).toBeUndefined();
|
||||
expect(actions.entry).toBe(true);
|
||||
expect(actions.install).toBe(false);
|
||||
});
|
||||
|
||||
it("handles plugin that only exists in installs", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"my-plugin": { source: "npm", spec: "my-plugin@1.0.0" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.installs).toBeUndefined();
|
||||
expect(actions.install).toBe(true);
|
||||
expect(actions.entry).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up empty plugins object", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
// After removing the only entry, entries should be undefined
|
||||
expect(result.plugins?.entries).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves other config values", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
deny: ["denied-plugin"],
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.enabled).toBe(true);
|
||||
expect(result.plugins?.deny).toEqual(["denied-plugin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstallPlugin", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "uninstall-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns error when plugin not found", async () => {
|
||||
const config: OpenClawConfig = {};
|
||||
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId: "nonexistent",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe("Plugin not found: nonexistent");
|
||||
}
|
||||
});
|
||||
|
||||
it("removes config entries", async () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
"my-plugin": { source: "npm", spec: "my-plugin@1.0.0" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId: "my-plugin",
|
||||
deleteFiles: false,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.config.plugins?.entries).toBeUndefined();
|
||||
expect(result.config.plugins?.installs).toBeUndefined();
|
||||
expect(result.actions.entry).toBe(true);
|
||||
expect(result.actions.install).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes directory when deleteFiles is true", async () => {
|
||||
const pluginId = "my-plugin";
|
||||
const extensionsDir = path.join(tempDir, "extensions");
|
||||
const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir);
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
[pluginId]: { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
[pluginId]: {
|
||||
source: "npm",
|
||||
spec: `${pluginId}@1.0.0`,
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId,
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.actions.directory).toBe(true);
|
||||
await expect(fs.access(pluginDir)).rejects.toThrow();
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(pluginDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves directory for linked plugins", async () => {
|
||||
const pluginDir = path.join(tempDir, "my-plugin");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
"my-plugin": {
|
||||
source: "path",
|
||||
sourcePath: pluginDir,
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
load: {
|
||||
paths: [pluginDir],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId: "my-plugin",
|
||||
deleteFiles: true,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.actions.directory).toBe(false);
|
||||
expect(result.actions.loadPath).toBe(true);
|
||||
// Directory should still exist
|
||||
await expect(fs.access(pluginDir)).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not delete directory when deleteFiles is false", async () => {
|
||||
const pluginDir = path.join(tempDir, "my-plugin");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
"my-plugin": {
|
||||
source: "npm",
|
||||
spec: "my-plugin@1.0.0",
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId: "my-plugin",
|
||||
deleteFiles: false,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.actions.directory).toBe(false);
|
||||
// Directory should still exist
|
||||
await expect(fs.access(pluginDir)).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("succeeds even if directory does not exist", async () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
"my-plugin": {
|
||||
source: "npm",
|
||||
spec: "my-plugin@1.0.0",
|
||||
installPath: "/nonexistent/path",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId: "my-plugin",
|
||||
deleteFiles: true,
|
||||
});
|
||||
|
||||
// Should succeed; directory deletion failure is not fatal
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.actions.directory).toBe(false);
|
||||
expect(result.warnings).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a warning when directory deletion fails unexpectedly", async () => {
|
||||
const pluginId = "my-plugin";
|
||||
const extensionsDir = path.join(tempDir, "extensions");
|
||||
const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir);
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
[pluginId]: { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
[pluginId]: {
|
||||
source: "npm",
|
||||
spec: `${pluginId}@1.0.0`,
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied"));
|
||||
try {
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId,
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.actions.directory).toBe(false);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain("Failed to remove plugin directory");
|
||||
}
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never deletes arbitrary configured install paths", async () => {
|
||||
const outsideDir = path.join(tempDir, "outside-dir");
|
||||
const extensionsDir = path.join(tempDir, "extensions");
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(path.join(outsideDir, "index.js"), "// keep me");
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"my-plugin": { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
"my-plugin": {
|
||||
source: "npm",
|
||||
spec: "my-plugin@1.0.0",
|
||||
installPath: outsideDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await uninstallPlugin({
|
||||
config,
|
||||
pluginId: "my-plugin",
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.actions.directory).toBe(false);
|
||||
await expect(fs.access(outsideDir)).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUninstallDirectoryTarget", () => {
|
||||
it("returns null for linked plugins", () => {
|
||||
expect(
|
||||
resolveUninstallDirectoryTarget({
|
||||
pluginId: "my-plugin",
|
||||
hasInstall: true,
|
||||
installRecord: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/my-plugin",
|
||||
installPath: "/tmp/my-plugin",
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to default path when configured installPath is untrusted", () => {
|
||||
const extensionsDir = path.join(os.tmpdir(), "openclaw-uninstall-safe");
|
||||
const target = resolveUninstallDirectoryTarget({
|
||||
pluginId: "my-plugin",
|
||||
hasInstall: true,
|
||||
installRecord: {
|
||||
source: "npm",
|
||||
spec: "my-plugin@1.0.0",
|
||||
installPath: "/tmp/not-openclaw-extensions/my-plugin",
|
||||
},
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(target).toBe(resolvePluginInstallDir("my-plugin", extensionsDir));
|
||||
});
|
||||
});
|
||||
237
src/plugins/uninstall.ts
Normal file
237
src/plugins/uninstall.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { resolvePluginInstallDir } from "./install.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
|
||||
export type UninstallActions = {
|
||||
entry: boolean;
|
||||
install: boolean;
|
||||
allowlist: boolean;
|
||||
loadPath: boolean;
|
||||
memorySlot: boolean;
|
||||
directory: boolean;
|
||||
};
|
||||
|
||||
export type UninstallPluginResult =
|
||||
| {
|
||||
ok: true;
|
||||
config: OpenClawConfig;
|
||||
pluginId: string;
|
||||
actions: UninstallActions;
|
||||
warnings: string[];
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function resolveUninstallDirectoryTarget(params: {
|
||||
pluginId: string;
|
||||
hasInstall: boolean;
|
||||
installRecord?: PluginInstallRecord;
|
||||
extensionsDir?: string;
|
||||
}): string | null {
|
||||
if (!params.hasInstall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (params.installRecord?.source === "path") {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Never trust configured installPath blindly for recursive deletes.
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove plugin references from config (pure config mutation).
|
||||
* Returns a new config with the plugin removed from entries, installs, allow, load.paths, and slots.
|
||||
*/
|
||||
export function removePluginFromConfig(
|
||||
cfg: OpenClawConfig,
|
||||
pluginId: string,
|
||||
): { config: OpenClawConfig; actions: Omit<UninstallActions, "directory"> } {
|
||||
const actions: Omit<UninstallActions, "directory"> = {
|
||||
entry: false,
|
||||
install: false,
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
};
|
||||
|
||||
const pluginsConfig = cfg.plugins ?? {};
|
||||
|
||||
// Remove from entries
|
||||
let entries = pluginsConfig.entries;
|
||||
if (entries && pluginId in entries) {
|
||||
const { [pluginId]: _, ...rest } = entries;
|
||||
entries = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.entry = true;
|
||||
}
|
||||
|
||||
// Remove from installs
|
||||
let installs = pluginsConfig.installs;
|
||||
const installRecord = installs?.[pluginId];
|
||||
if (installs && pluginId in installs) {
|
||||
const { [pluginId]: _, ...rest } = installs;
|
||||
installs = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.install = true;
|
||||
}
|
||||
|
||||
// Remove from allowlist
|
||||
let allow = pluginsConfig.allow;
|
||||
if (Array.isArray(allow) && allow.includes(pluginId)) {
|
||||
allow = allow.filter((id) => id !== pluginId);
|
||||
if (allow.length === 0) {
|
||||
allow = undefined;
|
||||
}
|
||||
actions.allowlist = true;
|
||||
}
|
||||
|
||||
// Remove linked path from load.paths (for source === "path" plugins)
|
||||
let load = pluginsConfig.load;
|
||||
if (installRecord?.source === "path" && installRecord.sourcePath) {
|
||||
const sourcePath = installRecord.sourcePath;
|
||||
const loadPaths = load?.paths;
|
||||
if (Array.isArray(loadPaths) && loadPaths.includes(sourcePath)) {
|
||||
const nextLoadPaths = loadPaths.filter((p) => p !== sourcePath);
|
||||
load = nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined;
|
||||
actions.loadPath = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset memory slot if this plugin was selected
|
||||
let slots = pluginsConfig.slots;
|
||||
if (slots?.memory === pluginId) {
|
||||
slots = {
|
||||
...slots,
|
||||
memory: defaultSlotIdForKey("memory"),
|
||||
};
|
||||
actions.memorySlot = true;
|
||||
}
|
||||
if (slots && Object.keys(slots).length === 0) {
|
||||
slots = undefined;
|
||||
}
|
||||
|
||||
const newPlugins = {
|
||||
...pluginsConfig,
|
||||
entries,
|
||||
installs,
|
||||
allow,
|
||||
load,
|
||||
slots,
|
||||
};
|
||||
|
||||
// Clean up undefined properties from newPlugins
|
||||
const cleanedPlugins: typeof newPlugins = { ...newPlugins };
|
||||
if (cleanedPlugins.entries === undefined) {
|
||||
delete cleanedPlugins.entries;
|
||||
}
|
||||
if (cleanedPlugins.installs === undefined) {
|
||||
delete cleanedPlugins.installs;
|
||||
}
|
||||
if (cleanedPlugins.allow === undefined) {
|
||||
delete cleanedPlugins.allow;
|
||||
}
|
||||
if (cleanedPlugins.load === undefined) {
|
||||
delete cleanedPlugins.load;
|
||||
}
|
||||
if (cleanedPlugins.slots === undefined) {
|
||||
delete cleanedPlugins.slots;
|
||||
}
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
...cfg,
|
||||
plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined,
|
||||
};
|
||||
|
||||
return { config, actions };
|
||||
}
|
||||
|
||||
export type UninstallPluginParams = {
|
||||
config: OpenClawConfig;
|
||||
pluginId: string;
|
||||
deleteFiles?: boolean;
|
||||
extensionsDir?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uninstall a plugin by removing it from config and optionally deleting installed files.
|
||||
* Linked plugins (source === "path") never have their source directory deleted.
|
||||
*/
|
||||
export async function uninstallPlugin(
|
||||
params: UninstallPluginParams,
|
||||
): Promise<UninstallPluginResult> {
|
||||
const { config, pluginId, deleteFiles = true, extensionsDir } = params;
|
||||
|
||||
// Validate plugin exists
|
||||
const hasEntry = pluginId in (config.plugins?.entries ?? {});
|
||||
const hasInstall = pluginId in (config.plugins?.installs ?? {});
|
||||
|
||||
if (!hasEntry && !hasInstall) {
|
||||
return { ok: false, error: `Plugin not found: ${pluginId}` };
|
||||
}
|
||||
|
||||
const installRecord = config.plugins?.installs?.[pluginId];
|
||||
const isLinked = installRecord?.source === "path";
|
||||
|
||||
// Remove from config
|
||||
const { config: newConfig, actions: configActions } = removePluginFromConfig(config, pluginId);
|
||||
|
||||
const actions: UninstallActions = {
|
||||
...configActions,
|
||||
directory: false,
|
||||
};
|
||||
const warnings: string[] = [];
|
||||
|
||||
const deleteTarget =
|
||||
deleteFiles && !isLinked
|
||||
? resolveUninstallDirectoryTarget({
|
||||
pluginId,
|
||||
hasInstall,
|
||||
installRecord,
|
||||
extensionsDir,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Delete installed directory if requested and safe.
|
||||
if (deleteTarget) {
|
||||
const existed =
|
||||
(await fs
|
||||
.access(deleteTarget)
|
||||
.then(() => true)
|
||||
.catch(() => false)) ?? false;
|
||||
try {
|
||||
await fs.rm(deleteTarget, { recursive: true, force: true });
|
||||
actions.directory = existed;
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Failed to remove plugin directory ${deleteTarget}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
// Directory deletion failure is not fatal; config is the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: newConfig,
|
||||
pluginId,
|
||||
actions,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user