From 401ffb59f538488349664fa42e554dfb36d53a3a Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 19 Mar 2026 12:51:10 -0400 Subject: [PATCH] CLI: support versioned plugin updates (#49998) Merged via squash. Prepared head SHA: 545ea60fa26bb742376237ca83c65665133bcf7c Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/cli/plugins.md | 14 +++- docs/tools/plugin.md | 2 +- src/cli/plugins-cli.test.ts | 134 ++++++++++++++++++++++++++++++++++++ src/cli/plugins-cli.ts | 59 +++++++++++++++- src/plugins/update.test.ts | 123 +++++++++++++++++++++++++++++++++ src/plugins/update.ts | 24 ++++--- 7 files changed, 345 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f4c317fb1..9a37dfe581c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,7 @@ Docs: https://docs.openclaw.ai - WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67. - Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo. - Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus. +- Plugins/update: let `openclaw plugins update ` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo. ### Breaking diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 47ef4930b8a..3d4c482707f 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -138,14 +138,24 @@ state dir extensions root (`$OPENCLAW_STATE_DIR/extensions/`). Use ### Update ```bash -openclaw plugins update +openclaw plugins update openclaw plugins update --all -openclaw plugins update --dry-run +openclaw plugins update --dry-run +openclaw plugins update @openclaw/voice-call@beta ``` Updates apply to tracked installs in `plugins.installs`, currently npm and marketplace installs. +When you pass a plugin id, OpenClaw reuses the recorded install spec for that +plugin. That means previously stored dist-tags such as `@beta` and exact pinned +versions continue to be used on later `update ` runs. + +For npm installs, you can also pass an explicit npm package spec with a dist-tag +or exact version. OpenClaw resolves that package name back to the tracked plugin +record, updates that installed plugin, and records the new npm spec for future +id-based updates. + When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw prints a warning and asks for confirmation before proceeding. Use global `--yes` to bypass prompts in CI/non-interactive runs. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 48b60d3fe1d..16291eab32d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -286,7 +286,7 @@ openclaw plugins install ./plugin.zip # install from a local zip openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev openclaw plugins install @openclaw/voice-call # install from npm openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version -openclaw plugins update +openclaw plugins update openclaw plugins update --all openclaw plugins enable openclaw plugins disable diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts index 50bc8633e70..4efb1990354 100644 --- a/src/cli/plugins-cli.test.ts +++ b/src/cli/plugins-cli.test.ts @@ -379,6 +379,140 @@ describe("plugins cli", () => { expect(runtimeLogs.at(-1)).toBe("No tracked plugins 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: { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 79fca829281..93e3d22c8d5 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -7,6 +7,7 @@ import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveArchiveKind } from "../infra/archive.js"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; @@ -227,6 +228,56 @@ function createPluginInstallLogger(): { info: (msg: string) => void; warn: (msg: }; } +function extractInstalledNpmPackageName(install: PluginInstallRecord): string | undefined { + if (install.source !== "npm") { + return undefined; + } + const resolvedName = install.resolvedName?.trim(); + if (resolvedName) { + return resolvedName; + } + return ( + (install.spec ? parseRegistryNpmSpec(install.spec)?.name : undefined) ?? + (install.resolvedSpec ? parseRegistryNpmSpec(install.resolvedSpec)?.name : undefined) + ); +} + +function resolvePluginUpdateSelection(params: { + installs: Record; + rawId?: string; + all?: boolean; +}): { pluginIds: string[]; specOverrides?: Record } { + if (params.all) { + return { pluginIds: Object.keys(params.installs) }; + } + if (!params.rawId) { + return { pluginIds: [] }; + } + + const parsedSpec = parseRegistryNpmSpec(params.rawId); + if (!parsedSpec || parsedSpec.selectorKind === "none") { + return { pluginIds: [params.rawId] }; + } + + const matches = Object.entries(params.installs).filter(([, install]) => { + return extractInstalledNpmPackageName(install) === parsedSpec.name; + }); + if (matches.length !== 1) { + return { pluginIds: [params.rawId] }; + } + + const [pluginId] = matches[0]; + if (!pluginId) { + return { pluginIds: [params.rawId] }; + } + return { + pluginIds: [pluginId], + specOverrides: { + [pluginId]: parsedSpec.raw, + }, + }; +} + function logSlotWarnings(warnings: string[]) { if (warnings.length === 0) { return; @@ -1032,7 +1083,12 @@ export function registerPluginsCli(program: Command) { .action(async (id: string | undefined, opts: PluginUpdateOptions) => { const cfg = loadConfig(); const installs = cfg.plugins?.installs ?? {}; - const targets = opts.all ? Object.keys(installs) : id ? [id] : []; + const selection = resolvePluginUpdateSelection({ + installs, + rawId: id, + all: opts.all, + }); + const targets = selection.pluginIds; if (targets.length === 0) { if (opts.all) { @@ -1046,6 +1102,7 @@ export function registerPluginsCli(program: Command) { const result = await updateNpmInstalledPlugins({ config: cfg, pluginIds: targets, + specOverrides: selection.specOverrides, dryRun: opts.dryRun, logger: { info: (msg) => defaultRuntime.log(msg), diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 7e93ab7ba50..96c15443ded 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -161,6 +161,129 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("reuses a recorded npm dist-tag spec for id-based updates", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + }); + + const result = await updateNpmInstalledPlugins({ + 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", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.3", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + expectedPluginId: "openclaw-codex-app-server", + }), + ); + expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({ + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + }); + }); + + it("uses and persists an explicit npm spec override during updates", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + npmResolution: { + name: "openclaw-codex-app-server", + version: "0.2.0-beta.4", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4", + }, + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@beta", + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + expectedPluginId: "openclaw-codex-app-server", + }), + ); + expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({ + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4", + }); + }); + + it("skips recorded integrity checks when an explicit npm version override changes the spec", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + }); + + await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@0.2.0-beta.3", + integrity: "sha512-old", + installPath: "/tmp/openclaw-codex-app-server", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4", + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@0.2.0-beta.4", + expectedIntegrity: undefined, + }), + ); + }); + it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 83733159cac..6898135e527 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -291,6 +291,7 @@ export async function updateNpmInstalledPlugins(params: { pluginIds?: string[]; skipIds?: Set; dryRun?: boolean; + specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; @@ -329,7 +330,14 @@ export async function updateNpmInstalledPlugins(params: { continue; } - if (record.source === "npm" && !record.spec) { + const effectiveSpec = + record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : undefined; + const expectedIntegrity = + record.source === "npm" && effectiveSpec === record.spec + ? expectedIntegrityForUpdate(record.spec, record.integrity) + : undefined; + + if (record.source === "npm" && !effectiveSpec) { outcomes.push({ pluginId, status: "skipped", @@ -371,11 +379,11 @@ export async function updateNpmInstalledPlugins(params: { probe = record.source === "npm" ? await installPluginFromNpmSpec({ - spec: record.spec!, + spec: effectiveSpec!, mode: "update", dryRun: true, expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: true, @@ -408,7 +416,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: record.spec!, + spec: effectiveSpec!, phase: "check", result: probe, }) @@ -452,10 +460,10 @@ export async function updateNpmInstalledPlugins(params: { result = record.source === "npm" ? await installPluginFromNpmSpec({ - spec: record.spec!, + spec: effectiveSpec!, mode: "update", expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: false, @@ -487,7 +495,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: record.spec!, + spec: effectiveSpec!, phase: "update", result: result, }) @@ -512,7 +520,7 @@ export async function updateNpmInstalledPlugins(params: { next = recordPluginInstall(next, { pluginId: resolvedPluginId, source: "npm", - spec: record.spec, + spec: effectiveSpec, installPath: result.targetDir, version: nextVersion, ...buildNpmResolutionInstallFields(result.npmResolution),