From d456b5f996e7287c7af3311a48d5e99df407db64 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 3 Apr 2026 17:34:19 -0400 Subject: [PATCH] Plugins: add install force flag --- CHANGELOG.md | 1 + docs/cli/index.md | 2 +- docs/cli/plugins.md | 5 + docs/tools/plugin.md | 3 + src/cli/plugins-cli.install.test.ts | 155 ++++++++++++++++++++++++++++ src/cli/plugins-cli.ts | 2 + src/cli/plugins-install-command.ts | 18 ++++ 7 files changed, 185 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a6310d46f..a59639e1602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd. - Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd. - Providers/Ollama: add bundled Ollama Web Search provider for key-free web_search via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD. +- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. ### Fixes diff --git a/docs/cli/index.md b/docs/cli/index.md index 64eedf9ea33..a5526d45bda 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -308,7 +308,7 @@ Manage extensions and their config: - `openclaw plugins list` — discover plugins (use `--json` for machine output). - `openclaw plugins inspect ` — show details for a plugin (`info` is an alias). -- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). +- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`; use `--force` to overwrite an existing install target). - `openclaw plugins marketplace list ` — list marketplace entries before install. - `openclaw plugins enable ` / `disable ` — toggle `plugins.entries..enabled`. - `openclaw plugins doctor` — report plugin load errors. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 9765a8e1353..9571325ed2a 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -48,6 +48,7 @@ capabilities. ```bash openclaw plugins install # ClawHub first, then npm openclaw plugins install clawhub: # ClawHub only +openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin version openclaw plugins install --dangerously-force-unsafe-install openclaw plugins install # local path @@ -58,6 +59,10 @@ openclaw plugins install --marketplace # marketplace (explicit) Bare package names are checked against ClawHub first, then npm. Security note: treat plugin installs like running code. Prefer pinned versions. +`--force` reuses the existing install target and overwrites an already-installed +plugin or hook pack in place. Use it when you are intentionally reinstalling +the same id from a new local path, archive, ClawHub package, or npm artifact. + `--dangerously-force-unsafe-install` is a break-glass option for false positives in the built-in dangerous-code scanner. It allows the install to continue even when the built-in scanner reports `critical` findings, but it does **not** diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 762917756ed..c67b1a65669 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -209,6 +209,7 @@ openclaw plugins doctor # diagnostics openclaw plugins install # install (ClawHub first, then npm) openclaw plugins install clawhub: # install from ClawHub only +openclaw plugins install --force # overwrite existing install openclaw plugins install # install from local path openclaw plugins install -l # link (no copy) for dev openclaw plugins install --dangerously-force-unsafe-install @@ -220,6 +221,8 @@ openclaw plugins enable openclaw plugins disable ``` +`--force` overwrites an existing installed plugin or hook pack in place. + `--dangerously-force-unsafe-install` is a break-glass override for false positives from the built-in dangerous-code scanner. It allows plugin installs and plugin updates to continue past built-in `critical` findings, but it still diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 8de96a91d3a..ee403243653 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -86,6 +86,21 @@ describe("plugins cli install", () => { resetPluginsCliTestState(); }); + it("shows the force overwrite option in install help", async () => { + const { Command } = await import("commander"); + const { registerPluginsCli } = await import("./plugins-cli.js"); + const program = new Command(); + registerPluginsCli(program); + + const pluginsCommand = program.commands.find((command) => command.name() === "plugins"); + const installCommand = pluginsCommand?.commands.find((command) => command.name() === "install"); + const helpText = installCommand?.helpInformation() ?? ""; + + expect(helpText).toContain("--force"); + expect(helpText).toContain("Overwrite an existing installed plugin or"); + expect(helpText).toContain("hook pack"); + }); + it("exits when --marketplace is combined with --link", async () => { await expect( runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), @@ -197,6 +212,20 @@ describe("plugins cli install", () => { expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); }); + it("passes force through as overwrite mode for marketplace installs", async () => { + await expect( + runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--force"]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + marketplace: "local/repo", + plugin: "alpha", + mode: "update", + }), + ); + }); + it("installs ClawHub plugins and persists source metadata", async () => { const cfg = { plugins: { @@ -256,6 +285,41 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("passes force through as overwrite mode for ClawHub installs", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + }), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo", "--force"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + mode: "update", + }), + ); + }); + it("prefers ClawHub before npm for bare plugin specs", async () => { const cfg = { plugins: { @@ -417,6 +481,48 @@ describe("plugins cli install", () => { ); }); + it("passes force through as overwrite mode for npm installs", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + + 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: cliInstallPath("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", "--force"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + mode: "update", + }), + ); + }); + it("does not fall back to npm when ClawHub rejects a real package", async () => { installPluginFromClawHub.mockResolvedValue({ ok: false, @@ -486,4 +592,53 @@ describe("plugins cli install", () => { expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); }); + + it("passes force through as overwrite mode for hook-pack npm fallback installs", 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", "--force"]); + + expect(installHooksFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo-hooks", + mode: "update", + }), + ); + }); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 65c2fd7b087..48ef9724d9d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -770,6 +770,7 @@ export function registerPluginsCli(program: Command) { "Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name", ) .option("-l, --link", "Link a local path instead of copying", false) + .option("--force", "Overwrite an existing installed plugin or hook pack", false) .option("--pin", "Record npm installs as exact resolved @", false) .option( "--dangerously-force-unsafe-install", @@ -785,6 +786,7 @@ export function registerPluginsCli(program: Command) { raw: string, opts: { dangerouslyForceUnsafeInstall?: boolean; + force?: boolean; link?: boolean; pin?: boolean; marketplace?: string; diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index d9c97e9bef4..82bba1344cd 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -38,6 +38,10 @@ import { } from "./plugins-command-helpers.js"; import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; +function resolveInstallMode(force?: boolean): "install" | "update" { + return force ? "update" : "install"; +} + async function installBundledPluginSource(params: { config: OpenClawConfig; rawSpec: string; @@ -71,6 +75,7 @@ async function installBundledPluginSource(params: { async function tryInstallHookPackFromLocalPath(params: { config: OpenClawConfig; resolvedPath: string; + installMode: "install" | "update"; link?: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { if (params.link) { @@ -122,6 +127,7 @@ async function tryInstallHookPackFromLocalPath(params: { const result = await installHooksFromPath({ path: params.resolvedPath, + mode: params.installMode, logger: createHookPackInstallLogger(), }); if (!result.ok) { @@ -145,11 +151,13 @@ async function tryInstallHookPackFromLocalPath(params: { async function tryInstallHookPackFromNpmSpec(params: { config: OpenClawConfig; + installMode: "install" | "update"; spec: string; pin?: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { const result = await installHooksFromNpmSpec({ spec: params.spec, + mode: params.installMode, logger: createHookPackInstallLogger(), }); if (!result.ok) { @@ -245,6 +253,7 @@ export async function loadConfigForInstall( export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { + force?: boolean; link?: boolean; pin?: boolean; marketplace?: string; @@ -290,11 +299,13 @@ export async function runPluginInstallCommand(params: { if (!cfg) { return defaultRuntime.exit(1); } + const installMode = resolveInstallMode(opts.force); if (opts.marketplace) { const result = await installPluginFromMarketplace({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, marketplace: opts.marketplace, + mode: installMode, plugin: raw, logger: createPluginInstallLogger(), }); @@ -329,6 +340,7 @@ export async function runPluginInstallCommand(params: { if (!probe.ok) { const hookFallback = await tryInstallHookPackFromLocalPath({ config: cfg, + installMode, resolvedPath: resolved, link: true, }); @@ -366,12 +378,14 @@ export async function runPluginInstallCommand(params: { const result = await installPluginFromPath({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, path: resolved, logger: createPluginInstallLogger(), }); if (!result.ok) { const hookFallback = await tryInstallHookPackFromLocalPath({ config: cfg, + installMode, resolvedPath: resolved, }); if (hookFallback.ok) { @@ -437,6 +451,7 @@ export async function runPluginInstallCommand(params: { if (clawhubSpec) { const result = await installPluginFromClawHub({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, spec: raw, logger: createPluginInstallLogger(), }); @@ -472,6 +487,7 @@ export async function runPluginInstallCommand(params: { if (preferredClawHubSpec) { const clawhubResult = await installPluginFromClawHub({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, spec: preferredClawHubSpec, logger: createPluginInstallLogger(), }); @@ -506,6 +522,7 @@ export async function runPluginInstallCommand(params: { const result = await installPluginFromNpmSpec({ dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + mode: installMode, spec: raw, logger: createPluginInstallLogger(), }); @@ -518,6 +535,7 @@ export async function runPluginInstallCommand(params: { if (!bundledFallbackPlan) { const hookFallback = await tryInstallHookPackFromNpmSpec({ config: cfg, + installMode, spec: raw, pin: opts.pin, });