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