mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 08:01:23 +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:
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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user