| 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"); |
|
|
| |
| 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); |
| |
| 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); |
| |
| 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, |
| }); |
|
|
| |
| 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)); |
| }); |
| }); |
|
|