diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts new file mode 100644 index 00000000000..aedd10be6f8 --- /dev/null +++ b/src/cli/plugins-cli-test-helpers.ts @@ -0,0 +1,224 @@ +import { Command } from "commander"; +import { vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +export const loadConfig = vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig); +export const writeConfigFile = vi.fn<(config: OpenClawConfig) => Promise>( + async () => undefined, +); +export const resolveStateDir = vi.fn(() => "/tmp/openclaw-state"); +export const installPluginFromMarketplace = vi.fn(); +export const listMarketplacePlugins = vi.fn(); +export const resolveMarketplaceInstallShortcut = vi.fn(); +export const enablePluginInConfig = vi.fn(); +export const recordPluginInstall = vi.fn(); +export const clearPluginManifestRegistryCache = vi.fn(); +export const buildPluginStatusReport = vi.fn(); +export const applyExclusiveSlotSelection = vi.fn(); +export const uninstallPlugin = vi.fn(); +export const updateNpmInstalledPlugins = vi.fn(); +export const updateNpmInstalledHookPacks = vi.fn(); +export const promptYesNo = vi.fn(); +export const installPluginFromNpmSpec = vi.fn(); +export const installPluginFromPath = vi.fn(); +export const installPluginFromClawHub = vi.fn(); +export const parseClawHubPluginSpec = vi.fn(); +export const installHooksFromNpmSpec = vi.fn(); +export const installHooksFromPath = vi.fn(); +export const recordHookInstall = vi.fn(); + +const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = + createCliRuntimeCapture(); + +export { runtimeErrors, runtimeLogs }; + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfig(), + writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config), +})); + +vi.mock("../config/paths.js", () => ({ + resolveStateDir: () => resolveStateDir(), +})); + +vi.mock("../plugins/marketplace.js", () => ({ + installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args), + listMarketplacePlugins: (...args: unknown[]) => listMarketplacePlugins(...args), + resolveMarketplaceInstallShortcut: (...args: unknown[]) => + resolveMarketplaceInstallShortcut(...args), +})); + +vi.mock("../plugins/enable.js", () => ({ + enablePluginInConfig: (...args: unknown[]) => enablePluginInConfig(...args), +})); + +vi.mock("../plugins/installs.js", () => ({ + recordPluginInstall: (...args: unknown[]) => recordPluginInstall(...args), +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(), +})); + +vi.mock("../plugins/status.js", () => ({ + buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args), +})); + +vi.mock("../plugins/slots.js", () => ({ + applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args), +})); + +vi.mock("../plugins/uninstall.js", () => ({ + uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args), + resolveUninstallDirectoryTarget: ({ + installRecord, + }: { + installRecord?: { installPath?: string; sourcePath?: string }; + }) => installRecord?.installPath ?? installRecord?.sourcePath ?? null, +})); + +vi.mock("../plugins/update.js", () => ({ + updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), +})); + +vi.mock("../hooks/update.js", () => ({ + updateNpmInstalledHookPacks: (...args: unknown[]) => updateNpmInstalledHookPacks(...args), +})); + +vi.mock("./prompt.js", () => ({ + promptYesNo: (...args: unknown[]) => promptYesNo(...args), +})); + +vi.mock("../plugins/install.js", () => ({ + PLUGIN_INSTALL_ERROR_CODE: { + NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", + }, + installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), + installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args), +})); + +vi.mock("../hooks/install.js", () => ({ + installHooksFromNpmSpec: (...args: unknown[]) => installHooksFromNpmSpec(...args), + installHooksFromPath: (...args: unknown[]) => installHooksFromPath(...args), + resolveHookInstallDir: (hookId: string) => `/tmp/hooks/${hookId}`, +})); + +vi.mock("../hooks/installs.js", () => ({ + recordHookInstall: (...args: unknown[]) => recordHookInstall(...args), +})); + +vi.mock("../plugins/clawhub.js", () => ({ + CLAWHUB_INSTALL_ERROR_CODE: { + PACKAGE_NOT_FOUND: "package_not_found", + VERSION_NOT_FOUND: "version_not_found", + }, + installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHub(...args), + formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) => + `clawhub:${name}${version ? `@${version}` : ""}`, +})); + +vi.mock("../infra/clawhub.js", () => ({ + parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpec(...args), +})); + +const { registerPluginsCli } = await import("./plugins-cli.js"); + +export function runPluginsCommand(argv: string[]) { + const program = new Command(); + program.exitOverride(); + registerPluginsCli(program); + return program.parseAsync(argv, { from: "user" }); +} + +export function resetPluginsCliTestState() { + resetRuntimeCapture(); + loadConfig.mockReset(); + writeConfigFile.mockReset(); + resolveStateDir.mockReset(); + installPluginFromMarketplace.mockReset(); + listMarketplacePlugins.mockReset(); + resolveMarketplaceInstallShortcut.mockReset(); + enablePluginInConfig.mockReset(); + recordPluginInstall.mockReset(); + clearPluginManifestRegistryCache.mockReset(); + buildPluginStatusReport.mockReset(); + applyExclusiveSlotSelection.mockReset(); + uninstallPlugin.mockReset(); + updateNpmInstalledPlugins.mockReset(); + updateNpmInstalledHookPacks.mockReset(); + promptYesNo.mockReset(); + installPluginFromNpmSpec.mockReset(); + installPluginFromPath.mockReset(); + installPluginFromClawHub.mockReset(); + parseClawHubPluginSpec.mockReset(); + installHooksFromNpmSpec.mockReset(); + installHooksFromPath.mockReset(); + recordHookInstall.mockReset(); + + loadConfig.mockReturnValue({} as OpenClawConfig); + writeConfigFile.mockResolvedValue(undefined); + resolveStateDir.mockReturnValue("/tmp/openclaw-state"); + resolveMarketplaceInstallShortcut.mockResolvedValue(null); + installPluginFromMarketplace.mockResolvedValue({ + ok: false, + error: "marketplace install failed", + }); + enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg })); + recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg); + buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({ + config, + warnings: [], + })); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: {} as OpenClawConfig, + warnings: [], + actions: { + entry: false, + install: false, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [], + changed: false, + config: {} as OpenClawConfig, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: {} as OpenClawConfig, + }); + promptYesNo.mockResolvedValue(true); + installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "npm install disabled in test", + }); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: "clawhub install disabled in test", + }); + parseClawHubPluginSpec.mockReturnValue(null); + installHooksFromPath.mockResolvedValue({ + ok: false, + error: "hook path install disabled in test", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: false, + error: "hook npm install disabled in test", + }); + recordHookInstall.mockImplementation((cfg: OpenClawConfig) => cfg); +} diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts new file mode 100644 index 00000000000..cba645233ba --- /dev/null +++ b/src/cli/plugins-cli.install.test.ts @@ -0,0 +1,378 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + applyExclusiveSlotSelection, + buildPluginStatusReport, + clearPluginManifestRegistryCache, + enablePluginInConfig, + installHooksFromNpmSpec, + installPluginFromClawHub, + installPluginFromMarketplace, + installPluginFromNpmSpec, + loadConfig, + parseClawHubPluginSpec, + recordHookInstall, + recordPluginInstall, + resetPluginsCliTestState, + runPluginsCommand, + runtimeErrors, + runtimeLogs, + writeConfigFile, +} from "./plugins-cli-test-helpers.js"; + +describe("plugins cli install", () => { + beforeEach(() => { + resetPluginsCliTestState(); + }); + + it("exits when --marketplace is combined with --link", async () => { + await expect( + runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`."); + expect(installPluginFromMarketplace).not.toHaveBeenCalled(); + }); + + it("exits when marketplace install fails", async () => { + await expect( + runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + marketplace: "local/repo", + plugin: "alpha", + }), + ); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("installs marketplace plugins and persists config", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = { + plugins: { + entries: { + alpha: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + const installedCfg = { + ...enabledCfg, + plugins: { + ...enabledCfg.plugins, + installs: { + alpha: { + source: "marketplace", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromMarketplace.mockResolvedValue({ + ok: true, + pluginId: "alpha", + targetDir: "/tmp/openclaw-state/extensions/alpha", + version: "1.2.3", + marketplaceName: "Claude", + marketplaceSource: "local/repo", + marketplacePlugin: "alpha", + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(installedCfg); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", kind: "provider" }], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockReturnValue({ + config: installedCfg, + warnings: ["slot adjusted"], + }); + + await runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]); + + expect(clearPluginManifestRegistryCache).toHaveBeenCalledTimes(1); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true); + expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); + }); + + it("installs ClawHub plugins and persists source metadata", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + const installedCfg = { + ...enabledCfg, + plugins: { + ...enabledCfg.plugins, + installs: { + demo: { + source: "clawhub", + spec: "clawhub:demo@1.2.3", + installPath: "/tmp/openclaw-state/extensions/demo", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/openclaw-state/extensions/demo", + version: "1.2.3", + packageName: "demo", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + version: "1.2.3", + integrity: "sha256-abc", + resolvedAt: "2026-03-22T00:00:00.000Z", + }, + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(installedCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: installedCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + }), + ); + expect(recordPluginInstall).toHaveBeenCalledWith( + enabledCfg, + expect.objectContaining({ + pluginId: "demo", + source: "clawhub", + spec: "clawhub:demo@1.2.3", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + }); + + it("prefers ClawHub before npm for bare plugin specs", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + const installedCfg = { + ...enabledCfg, + plugins: { + ...enabledCfg.plugins, + installs: { + demo: { + source: "clawhub", + spec: "clawhub:demo@1.2.3", + installPath: "/tmp/openclaw-state/extensions/demo", + clawhubPackage: "demo", + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/openclaw-state/extensions/demo", + version: "1.2.3", + packageName: "demo", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "community", + version: "1.2.3", + integrity: "sha256-abc", + resolvedAt: "2026-03-22T00:00:00.000Z", + }, + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(installedCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: installedCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "demo"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + }), + ); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + }); + + it("falls back to npm when ClawHub does not have the package", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: "ClawHub /api/v1/packages/demo failed (404): Package not found", + code: "package_not_found", + }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/openclaw-state/extensions/demo", + version: "1.2.3", + npmResolution: { + packageName: "demo", + resolvedVersion: "1.2.3", + tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz", + }, + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "demo"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + }), + ); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + }), + ); + }); + + it("does not fall back to npm when ClawHub rejects a real package", async () => { + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: 'Use "openclaw skills install demo" instead.', + code: "skill_package", + }); + + await expect(runPluginsCommand(["plugins", "install", "demo"])).rejects.toThrow("__exit__:1"); + + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.'); + }); + + it("falls back to installing hook packs from npm specs", async () => { + const cfg = {} as OpenClawConfig; + const installedCfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.2.3", + }, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found", + code: "package_not_found", + }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.plugin.json", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["command-audit"], + targetDir: "/tmp/hooks/demo-hooks", + version: "1.2.3", + npmResolution: { + name: "@acme/demo-hooks", + spec: "@acme/demo-hooks@1.2.3", + integrity: "sha256-demo", + }, + }); + recordHookInstall.mockReturnValue(installedCfg); + + await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]); + + expect(installHooksFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo-hooks", + }), + ); + expect(recordHookInstall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + hookId: "demo-hooks", + hooks: ["command-audit"], + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + }); +}); diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts deleted file mode 100644 index 7c15fcb6b85..00000000000 --- a/src/cli/plugins-cli.test.ts +++ /dev/null @@ -1,945 +0,0 @@ -import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { createCliRuntimeCapture } from "./test-runtime-capture.js"; - -const loadConfig = vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig); -const writeConfigFile = vi.fn<(config: OpenClawConfig) => Promise>(async () => undefined); -const resolveStateDir = vi.fn(() => "/tmp/openclaw-state"); -const installPluginFromMarketplace = vi.fn(); -const listMarketplacePlugins = vi.fn(); -const resolveMarketplaceInstallShortcut = vi.fn(); -const enablePluginInConfig = vi.fn(); -const recordPluginInstall = vi.fn(); -const clearPluginManifestRegistryCache = vi.fn(); -const buildPluginStatusReport = vi.fn(); -const applyExclusiveSlotSelection = vi.fn(); -const uninstallPlugin = vi.fn(); -const updateNpmInstalledPlugins = vi.fn(); -const updateNpmInstalledHookPacks = vi.fn(); -const promptYesNo = vi.fn(); -const installPluginFromNpmSpec = vi.fn(); -const installPluginFromPath = vi.fn(); -const installPluginFromClawHub = vi.fn(); -const parseClawHubPluginSpec = vi.fn(); -const installHooksFromNpmSpec = vi.fn(); -const installHooksFromPath = vi.fn(); -const recordHookInstall = vi.fn(); - -const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = - createCliRuntimeCapture(); - -vi.mock("../runtime.js", () => ({ - defaultRuntime, -})); - -vi.mock("../config/config.js", () => ({ - loadConfig: () => loadConfig(), - writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config), -})); - -vi.mock("../config/paths.js", () => ({ - resolveStateDir: () => resolveStateDir(), -})); - -vi.mock("../plugins/marketplace.js", () => ({ - installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args), - listMarketplacePlugins: (...args: unknown[]) => listMarketplacePlugins(...args), - resolveMarketplaceInstallShortcut: (...args: unknown[]) => - resolveMarketplaceInstallShortcut(...args), -})); - -vi.mock("../plugins/enable.js", () => ({ - enablePluginInConfig: (...args: unknown[]) => enablePluginInConfig(...args), -})); - -vi.mock("../plugins/installs.js", () => ({ - recordPluginInstall: (...args: unknown[]) => recordPluginInstall(...args), -})); - -vi.mock("../plugins/manifest-registry.js", () => ({ - clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(), -})); - -vi.mock("../plugins/status.js", () => ({ - buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args), -})); - -vi.mock("../plugins/slots.js", () => ({ - applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args), -})); - -vi.mock("../plugins/uninstall.js", () => ({ - uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args), - resolveUninstallDirectoryTarget: ({ - installRecord, - }: { - installRecord?: { installPath?: string; sourcePath?: string }; - }) => installRecord?.installPath ?? installRecord?.sourcePath ?? null, -})); - -vi.mock("../plugins/update.js", () => ({ - updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), -})); - -vi.mock("../hooks/update.js", () => ({ - updateNpmInstalledHookPacks: (...args: unknown[]) => updateNpmInstalledHookPacks(...args), -})); - -vi.mock("./prompt.js", () => ({ - promptYesNo: (...args: unknown[]) => promptYesNo(...args), -})); - -vi.mock("../plugins/install.js", () => ({ - PLUGIN_INSTALL_ERROR_CODE: { - NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", - }, - installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), - installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args), -})); - -vi.mock("../hooks/install.js", () => ({ - installHooksFromNpmSpec: (...args: unknown[]) => installHooksFromNpmSpec(...args), - installHooksFromPath: (...args: unknown[]) => installHooksFromPath(...args), - resolveHookInstallDir: (hookId: string) => `/tmp/hooks/${hookId}`, -})); - -vi.mock("../hooks/installs.js", () => ({ - recordHookInstall: (...args: unknown[]) => recordHookInstall(...args), -})); - -vi.mock("../plugins/clawhub.js", () => ({ - CLAWHUB_INSTALL_ERROR_CODE: { - PACKAGE_NOT_FOUND: "package_not_found", - VERSION_NOT_FOUND: "version_not_found", - }, - installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHub(...args), - formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) => - `clawhub:${name}${version ? `@${version}` : ""}`, -})); - -vi.mock("../infra/clawhub.js", () => ({ - parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpec(...args), -})); - -const { registerPluginsCli } = await import("./plugins-cli.js"); - -describe("plugins cli", () => { - const createProgram = () => { - const program = new Command(); - program.exitOverride(); - registerPluginsCli(program); - return program; - }; - - const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" }); - - beforeEach(() => { - resetRuntimeCapture(); - loadConfig.mockReset(); - writeConfigFile.mockReset(); - resolveStateDir.mockReset(); - installPluginFromMarketplace.mockReset(); - listMarketplacePlugins.mockReset(); - resolveMarketplaceInstallShortcut.mockReset(); - enablePluginInConfig.mockReset(); - recordPluginInstall.mockReset(); - clearPluginManifestRegistryCache.mockReset(); - buildPluginStatusReport.mockReset(); - applyExclusiveSlotSelection.mockReset(); - uninstallPlugin.mockReset(); - updateNpmInstalledPlugins.mockReset(); - promptYesNo.mockReset(); - updateNpmInstalledHookPacks.mockReset(); - installPluginFromNpmSpec.mockReset(); - installPluginFromPath.mockReset(); - installPluginFromClawHub.mockReset(); - parseClawHubPluginSpec.mockReset(); - installHooksFromNpmSpec.mockReset(); - installHooksFromPath.mockReset(); - recordHookInstall.mockReset(); - - loadConfig.mockReturnValue({} as OpenClawConfig); - writeConfigFile.mockResolvedValue(undefined); - resolveStateDir.mockReturnValue("/tmp/openclaw-state"); - resolveMarketplaceInstallShortcut.mockResolvedValue(null); - installPluginFromMarketplace.mockResolvedValue({ - ok: false, - error: "marketplace install failed", - }); - enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg })); - recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg); - buildPluginStatusReport.mockReturnValue({ - plugins: [], - diagnostics: [], - }); - applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({ - config, - warnings: [], - })); - uninstallPlugin.mockResolvedValue({ - ok: true, - config: {} as OpenClawConfig, - warnings: [], - actions: { - entry: false, - install: false, - allowlist: false, - loadPath: false, - memorySlot: false, - directory: false, - }, - }); - updateNpmInstalledPlugins.mockResolvedValue({ - outcomes: [], - changed: false, - config: {} as OpenClawConfig, - }); - updateNpmInstalledHookPacks.mockResolvedValue({ - outcomes: [], - changed: false, - config: {} as OpenClawConfig, - }); - promptYesNo.mockResolvedValue(true); - installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); - installPluginFromNpmSpec.mockResolvedValue({ - ok: false, - error: "npm install disabled in test", - }); - installPluginFromClawHub.mockResolvedValue({ - ok: false, - error: "clawhub install disabled in test", - }); - parseClawHubPluginSpec.mockReturnValue(null); - installHooksFromPath.mockResolvedValue({ - ok: false, - error: "hook path install disabled in test", - }); - installHooksFromNpmSpec.mockResolvedValue({ - ok: false, - error: "hook npm install disabled in test", - }); - recordHookInstall.mockImplementation((cfg: OpenClawConfig) => cfg); - }); - - it("exits when --marketplace is combined with --link", async () => { - await expect( - runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), - ).rejects.toThrow("__exit__:1"); - - expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`."); - expect(installPluginFromMarketplace).not.toHaveBeenCalled(); - }); - - it("exits when marketplace install fails", async () => { - await expect( - runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), - ).rejects.toThrow("__exit__:1"); - - expect(installPluginFromMarketplace).toHaveBeenCalledWith( - expect.objectContaining({ - marketplace: "local/repo", - plugin: "alpha", - }), - ); - expect(writeConfigFile).not.toHaveBeenCalled(); - }); - - it("installs marketplace plugins and persists config", async () => { - const cfg = { - plugins: { - entries: {}, - }, - } as OpenClawConfig; - const enabledCfg = { - plugins: { - entries: { - alpha: { - enabled: true, - }, - }, - }, - } as OpenClawConfig; - const installedCfg = { - ...enabledCfg, - plugins: { - ...enabledCfg.plugins, - installs: { - alpha: { - source: "marketplace", - installPath: "/tmp/openclaw-state/extensions/alpha", - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - installPluginFromMarketplace.mockResolvedValue({ - ok: true, - pluginId: "alpha", - targetDir: "/tmp/openclaw-state/extensions/alpha", - version: "1.2.3", - marketplaceName: "Claude", - marketplaceSource: "local/repo", - marketplacePlugin: "alpha", - }); - enablePluginInConfig.mockReturnValue({ config: enabledCfg }); - recordPluginInstall.mockReturnValue(installedCfg); - buildPluginStatusReport.mockReturnValue({ - plugins: [{ id: "alpha", kind: "provider" }], - diagnostics: [], - }); - applyExclusiveSlotSelection.mockReturnValue({ - config: installedCfg, - warnings: ["slot adjusted"], - }); - - await runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]); - - expect(clearPluginManifestRegistryCache).toHaveBeenCalledTimes(1); - expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); - expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true); - expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); - }); - - it("installs ClawHub plugins and persists source metadata", async () => { - const cfg = { - plugins: { - entries: {}, - }, - } as OpenClawConfig; - const enabledCfg = { - plugins: { - entries: { - demo: { - enabled: true, - }, - }, - }, - } as OpenClawConfig; - const installedCfg = { - ...enabledCfg, - plugins: { - ...enabledCfg.plugins, - installs: { - demo: { - source: "clawhub", - spec: "clawhub:demo@1.2.3", - installPath: "/tmp/openclaw-state/extensions/demo", - clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "official", - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); - installPluginFromClawHub.mockResolvedValue({ - ok: true, - pluginId: "demo", - targetDir: "/tmp/openclaw-state/extensions/demo", - version: "1.2.3", - packageName: "demo", - clawhub: { - source: "clawhub", - clawhubUrl: "https://clawhub.ai", - clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "official", - version: "1.2.3", - integrity: "sha256-abc", - resolvedAt: "2026-03-22T00:00:00.000Z", - }, - }); - enablePluginInConfig.mockReturnValue({ config: enabledCfg }); - recordPluginInstall.mockReturnValue(installedCfg); - applyExclusiveSlotSelection.mockReturnValue({ - config: installedCfg, - warnings: [], - }); - - await runCommand(["plugins", "install", "clawhub:demo"]); - - expect(installPluginFromClawHub).toHaveBeenCalledWith( - expect.objectContaining({ - spec: "clawhub:demo", - }), - ); - expect(recordPluginInstall).toHaveBeenCalledWith( - enabledCfg, - expect.objectContaining({ - pluginId: "demo", - source: "clawhub", - spec: "clawhub:demo@1.2.3", - clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "official", - }), - ); - expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); - expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true); - expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); - }); - - it("prefers ClawHub before npm for bare plugin specs", async () => { - const cfg = { - plugins: { - entries: {}, - }, - } as OpenClawConfig; - const enabledCfg = { - plugins: { - entries: { - demo: { - enabled: true, - }, - }, - }, - } as OpenClawConfig; - const installedCfg = { - ...enabledCfg, - plugins: { - ...enabledCfg.plugins, - installs: { - demo: { - source: "clawhub", - spec: "clawhub:demo@1.2.3", - installPath: "/tmp/openclaw-state/extensions/demo", - clawhubPackage: "demo", - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - installPluginFromClawHub.mockResolvedValue({ - ok: true, - pluginId: "demo", - targetDir: "/tmp/openclaw-state/extensions/demo", - version: "1.2.3", - packageName: "demo", - clawhub: { - source: "clawhub", - clawhubUrl: "https://clawhub.ai", - clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "community", - version: "1.2.3", - integrity: "sha256-abc", - resolvedAt: "2026-03-22T00:00:00.000Z", - }, - }); - enablePluginInConfig.mockReturnValue({ config: enabledCfg }); - recordPluginInstall.mockReturnValue(installedCfg); - applyExclusiveSlotSelection.mockReturnValue({ - config: installedCfg, - warnings: [], - }); - - await runCommand(["plugins", "install", "demo"]); - - expect(installPluginFromClawHub).toHaveBeenCalledWith( - expect.objectContaining({ - spec: "clawhub:demo", - }), - ); - expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); - expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); - }); - - it("falls back to npm when ClawHub does not have the package", async () => { - const cfg = { - plugins: { - entries: {}, - }, - } as OpenClawConfig; - const enabledCfg = { - plugins: { - entries: { - demo: { - enabled: true, - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - installPluginFromClawHub.mockResolvedValue({ - ok: false, - error: "ClawHub /api/v1/packages/demo failed (404): Package not found", - code: "package_not_found", - }); - installPluginFromNpmSpec.mockResolvedValue({ - ok: true, - pluginId: "demo", - targetDir: "/tmp/openclaw-state/extensions/demo", - version: "1.2.3", - npmResolution: { - packageName: "demo", - resolvedVersion: "1.2.3", - tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz", - }, - }); - enablePluginInConfig.mockReturnValue({ config: enabledCfg }); - recordPluginInstall.mockReturnValue(enabledCfg); - applyExclusiveSlotSelection.mockReturnValue({ - config: enabledCfg, - warnings: [], - }); - - await runCommand(["plugins", "install", "demo"]); - - expect(installPluginFromClawHub).toHaveBeenCalledWith( - expect.objectContaining({ - spec: "clawhub:demo", - }), - ); - expect(installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ - spec: "demo", - }), - ); - }); - - it("does not fall back to npm when ClawHub rejects a real package", async () => { - installPluginFromClawHub.mockResolvedValue({ - ok: false, - error: 'Use "openclaw skills install demo" instead.', - code: "skill_package", - }); - - await expect(runCommand(["plugins", "install", "demo"])).rejects.toThrow("__exit__:1"); - - expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); - expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.'); - }); - it("falls back to installing hook packs from npm specs", async () => { - const cfg = {} as OpenClawConfig; - const installedCfg = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.2.3", - }, - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - installPluginFromClawHub.mockResolvedValue({ - ok: false, - error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found", - code: "package_not_found", - }); - installPluginFromNpmSpec.mockResolvedValue({ - ok: false, - error: "package.json missing openclaw.plugin.json", - }); - installHooksFromNpmSpec.mockResolvedValue({ - ok: true, - hookPackId: "demo-hooks", - hooks: ["command-audit"], - targetDir: "/tmp/hooks/demo-hooks", - version: "1.2.3", - npmResolution: { - name: "@acme/demo-hooks", - spec: "@acme/demo-hooks@1.2.3", - integrity: "sha256-demo", - }, - }); - recordHookInstall.mockReturnValue(installedCfg); - - await runCommand(["plugins", "install", "@acme/demo-hooks"]); - - expect(installHooksFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ - spec: "@acme/demo-hooks", - }), - ); - expect(recordHookInstall).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - hookId: "demo-hooks", - hooks: ["command-audit"], - }), - ); - expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); - expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); - }); - - it("updates tracked hook packs through plugins update", async () => { - const cfg = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.0.0", - installPath: "/tmp/hooks/demo-hooks", - resolvedName: "@acme/demo-hooks", - }, - }, - }, - }, - } as OpenClawConfig; - const nextConfig = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.1.0", - installPath: "/tmp/hooks/demo-hooks", - }, - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - updateNpmInstalledPlugins.mockResolvedValue({ - config: cfg, - changed: false, - outcomes: [], - }); - updateNpmInstalledHookPacks.mockResolvedValue({ - config: nextConfig, - changed: true, - outcomes: [ - { - hookId: "demo-hooks", - status: "updated", - message: 'Updated hook pack "demo-hooks": 1.0.0 -> 1.1.0.', - }, - ], - }); - - await runCommand(["plugins", "update", "demo-hooks"]); - - expect(updateNpmInstalledHookPacks).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - hookIds: ["demo-hooks"], - }), - ); - expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); - expect( - runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), - ).toBe(true); - }); - it("shows uninstall dry-run preview without mutating config", async () => { - loadConfig.mockReturnValue({ - plugins: { - entries: { - alpha: { - enabled: true, - }, - }, - installs: { - alpha: { - source: "path", - sourcePath: "/tmp/openclaw-state/extensions/alpha", - installPath: "/tmp/openclaw-state/extensions/alpha", - }, - }, - }, - } as OpenClawConfig); - buildPluginStatusReport.mockReturnValue({ - plugins: [{ id: "alpha", name: "alpha" }], - diagnostics: [], - }); - - await runCommand(["plugins", "uninstall", "alpha", "--dry-run"]); - - expect(uninstallPlugin).not.toHaveBeenCalled(); - expect(writeConfigFile).not.toHaveBeenCalled(); - expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); - }); - - it("uninstalls with --force and --keep-files without prompting", async () => { - const baseConfig = { - plugins: { - entries: { - alpha: { enabled: true }, - }, - installs: { - alpha: { - source: "path", - sourcePath: "/tmp/openclaw-state/extensions/alpha", - installPath: "/tmp/openclaw-state/extensions/alpha", - }, - }, - }, - } as OpenClawConfig; - const nextConfig = { - plugins: { - entries: {}, - installs: {}, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(baseConfig); - buildPluginStatusReport.mockReturnValue({ - plugins: [{ id: "alpha", name: "alpha" }], - diagnostics: [], - }); - uninstallPlugin.mockResolvedValue({ - ok: true, - config: nextConfig, - warnings: [], - actions: { - entry: true, - install: true, - allowlist: false, - loadPath: false, - memorySlot: false, - directory: false, - }, - }); - - await runCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); - - expect(promptYesNo).not.toHaveBeenCalled(); - expect(uninstallPlugin).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "alpha", - deleteFiles: false, - }), - ); - expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); - }); - - it("exits when uninstall target is not managed by plugin install records", async () => { - loadConfig.mockReturnValue({ - plugins: { - entries: {}, - installs: {}, - }, - } as OpenClawConfig); - buildPluginStatusReport.mockReturnValue({ - plugins: [{ id: "alpha", name: "alpha" }], - diagnostics: [], - }); - - await expect(runCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow( - "__exit__:1", - ); - - expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records"); - expect(uninstallPlugin).not.toHaveBeenCalled(); - }); - - it("exits when update is called without id and without --all", async () => { - loadConfig.mockReturnValue({ - plugins: { - installs: {}, - }, - } as OpenClawConfig); - - await expect(runCommand(["plugins", "update"])).rejects.toThrow("__exit__:1"); - - expect(runtimeErrors.at(-1)).toContain("Provide a plugin or hook-pack id, or use --all."); - expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); - }); - - it("reports no tracked plugins or hook packs when update --all has empty install records", async () => { - loadConfig.mockReturnValue({ - plugins: { - installs: {}, - }, - } as OpenClawConfig); - - await runCommand(["plugins", "update", "--all"]); - - expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); - expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); - expect(runtimeLogs.at(-1)).toBe("No tracked plugins or hook packs to update."); - }); - - it("maps an explicit unscoped npm dist-tag update to the tracked plugin id", async () => { - const config = { - plugins: { - installs: { - "openclaw-codex-app-server": { - source: "npm", - spec: "openclaw-codex-app-server", - installPath: "/tmp/openclaw-codex-app-server", - resolvedName: "openclaw-codex-app-server", - }, - }, - }, - } as OpenClawConfig; - loadConfig.mockReturnValue(config); - updateNpmInstalledPlugins.mockResolvedValue({ - config, - changed: false, - outcomes: [], - }); - - await runCommand(["plugins", "update", "openclaw-codex-app-server@beta"]); - - expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config, - pluginIds: ["openclaw-codex-app-server"], - specOverrides: { - "openclaw-codex-app-server": "openclaw-codex-app-server@beta", - }, - }), - ); - }); - - it("maps an explicit scoped npm dist-tag update to the tracked plugin id", async () => { - const config = { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call", - installPath: "/tmp/voice-call", - resolvedName: "@openclaw/voice-call", - }, - }, - }, - } as OpenClawConfig; - loadConfig.mockReturnValue(config); - updateNpmInstalledPlugins.mockResolvedValue({ - config, - changed: false, - outcomes: [], - }); - - await runCommand(["plugins", "update", "@openclaw/voice-call@beta"]); - - expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config, - pluginIds: ["voice-call"], - specOverrides: { - "voice-call": "@openclaw/voice-call@beta", - }, - }), - ); - }); - - it("maps an explicit npm version update to the tracked plugin id", async () => { - const config = { - plugins: { - installs: { - "openclaw-codex-app-server": { - source: "npm", - spec: "openclaw-codex-app-server", - installPath: "/tmp/openclaw-codex-app-server", - resolvedName: "openclaw-codex-app-server", - }, - }, - }, - } as OpenClawConfig; - loadConfig.mockReturnValue(config); - updateNpmInstalledPlugins.mockResolvedValue({ - config, - changed: false, - outcomes: [], - }); - - await runCommand(["plugins", "update", "openclaw-codex-app-server@0.2.0-beta.4"]); - - expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config, - pluginIds: ["openclaw-codex-app-server"], - specOverrides: { - "openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4", - }, - }), - ); - }); - - it("keeps using the recorded npm tag when update is invoked by plugin id", async () => { - const config = { - plugins: { - installs: { - "openclaw-codex-app-server": { - source: "npm", - spec: "openclaw-codex-app-server@beta", - installPath: "/tmp/openclaw-codex-app-server", - resolvedName: "openclaw-codex-app-server", - }, - }, - }, - } as OpenClawConfig; - loadConfig.mockReturnValue(config); - updateNpmInstalledPlugins.mockResolvedValue({ - config, - changed: false, - outcomes: [], - }); - - await runCommand(["plugins", "update", "openclaw-codex-app-server"]); - - expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config, - pluginIds: ["openclaw-codex-app-server"], - }), - ); - expect(updateNpmInstalledPlugins).not.toHaveBeenCalledWith( - expect.objectContaining({ - specOverrides: expect.anything(), - }), - ); - }); - - it("writes updated config when updater reports changes", async () => { - const cfg = { - plugins: { - installs: { - alpha: { - source: "npm", - spec: "@openclaw/alpha@1.0.0", - }, - }, - }, - } as OpenClawConfig; - const nextConfig = { - plugins: { - installs: { - alpha: { - source: "npm", - spec: "@openclaw/alpha@1.1.0", - }, - }, - }, - } as OpenClawConfig; - loadConfig.mockReturnValue(cfg); - updateNpmInstalledPlugins.mockResolvedValue({ - outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }], - changed: true, - config: nextConfig, - }); - updateNpmInstalledHookPacks.mockResolvedValue({ - outcomes: [], - changed: false, - config: nextConfig, - }); - - await runCommand(["plugins", "update", "alpha"]); - - expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - pluginIds: ["alpha"], - dryRun: false, - }), - ); - expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); - expect( - runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), - ).toBe(true); - }); -}); diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts new file mode 100644 index 00000000000..a038eff6492 --- /dev/null +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + buildPluginStatusReport, + loadConfig, + promptYesNo, + resetPluginsCliTestState, + runPluginsCommand, + runtimeErrors, + runtimeLogs, + uninstallPlugin, + writeConfigFile, +} from "./plugins-cli-test-helpers.js"; + +describe("plugins cli uninstall", () => { + beforeEach(() => { + resetPluginsCliTestState(); + }); + + it("shows uninstall dry-run preview without mutating config", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + alpha: { + enabled: true, + }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await runPluginsCommand(["plugins", "uninstall", "alpha", "--dry-run"]); + + expect(uninstallPlugin).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); + }); + + it("uninstalls with --force and --keep-files without prompting", async () => { + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(baseConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: nextConfig, + warnings: [], + actions: { + entry: true, + install: true, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + + await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); + + expect(promptYesNo).not.toHaveBeenCalled(); + expect(uninstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "alpha", + deleteFiles: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + }); + + it("exits when uninstall target is not managed by plugin install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records"); + expect(uninstallPlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts new file mode 100644 index 00000000000..2d37a9823b9 --- /dev/null +++ b/src/cli/plugins-cli.update.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + loadConfig, + resetPluginsCliTestState, + runPluginsCommand, + runtimeErrors, + runtimeLogs, + updateNpmInstalledHookPacks, + updateNpmInstalledPlugins, + writeConfigFile, +} from "./plugins-cli-test-helpers.js"; + +describe("plugins cli update", () => { + beforeEach(() => { + resetPluginsCliTestState(); + }); + + it("updates tracked hook packs through plugins update", async () => { + const cfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + resolvedName: "@acme/demo-hooks", + }, + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.1.0", + installPath: "/tmp/hooks/demo-hooks", + }, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + updateNpmInstalledPlugins.mockResolvedValue({ + config: cfg, + changed: false, + outcomes: [], + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + config: nextConfig, + changed: true, + outcomes: [ + { + hookId: "demo-hooks", + status: "updated", + message: 'Updated hook pack "demo-hooks": 1.0.0 -> 1.1.0.', + }, + ], + }); + + await runPluginsCommand(["plugins", "update", "demo-hooks"]); + + expect(updateNpmInstalledHookPacks).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + hookIds: ["demo-hooks"], + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect( + runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), + ).toBe(true); + }); + + it("exits when update is called without id and without --all", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await expect(runPluginsCommand(["plugins", "update"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("Provide a plugin or hook-pack id, or use --all."); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + }); + + it("reports no tracked plugins or hook packs when update --all has empty install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await runPluginsCommand(["plugins", "update", "--all"]); + + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toBe("No tracked plugins or hook packs to update."); + }); + + it("maps an explicit unscoped npm dist-tag update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server@beta"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@beta", + }, + }), + ); + }); + + it("maps an explicit scoped npm dist-tag update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + installPath: "/tmp/voice-call", + resolvedName: "@openclaw/voice-call", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "@openclaw/voice-call@beta"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["voice-call"], + specOverrides: { + "voice-call": "@openclaw/voice-call@beta", + }, + }), + ); + }); + + it("maps an explicit npm version update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server@0.2.0-beta.4"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4", + }, + }), + ); + }); + + it("keeps using the recorded npm tag when update is invoked by plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + }), + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalledWith( + expect.objectContaining({ + specOverrides: expect.anything(), + }), + ); + }); + + it("writes updated config when updater reports changes", async () => { + const cfg = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.1.0", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }], + changed: true, + config: nextConfig, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: nextConfig, + }); + + await runPluginsCommand(["plugins", "update", "alpha"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + pluginIds: ["alpha"], + dryRun: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect( + runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), + ).toBe(true); + }); +});