From f969ae45a30e6974b614a25a338c8e1fe345fa3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 20:18:51 +0100 Subject: [PATCH] fix(plugins): follow beta channel for plugin updates --- CHANGELOG.md | 1 + docs/cli/plugins.md | 4 + docs/cli/update.md | 7 +- docs/plugins/manage-plugins.md | 5 + docs/tools/plugin.md | 3 + src/cli/update-cli/update-command.ts | 1 + src/plugins/update.test.ts | 236 +++++++++++++++++++++++++- src/plugins/update.ts | 239 ++++++++++++++++++++++++++- 8 files changed, 491 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e81ad2cfa25..b5a85092b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins. +- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists. ### Fixes diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 028d0eb1e88..26bdc1792a4 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -322,6 +322,10 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked Passing the npm package name without a version or tag also resolves back to the tracked plugin record. Use this when a plugin was pinned to an exact version and you want to move it back to the registry's default release line. + + + `openclaw plugins update` reuses the tracked plugin spec unless you pass a new spec. `openclaw update` additionally knows the active OpenClaw update channel: on the beta channel, default-line npm and ClawHub plugin records try `@beta` first, then fall back to the recorded default/latest spec if no plugin beta release exists. Exact versions and explicit tags stay pinned to that selector. + Before a live npm update, OpenClaw checks the installed package version against the npm registry metadata. If the installed version and recorded artifact identity already match the resolved target, the update is skipped without downloading, reinstalling, or rewriting `openclaw.json`. diff --git a/docs/cli/update.md b/docs/cli/update.md index e72d271c0f8..db2affd06c0 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -144,10 +144,15 @@ it manually. `openclaw doctor` runs as the final safe-update check. - Syncs plugins to the active channel. Dev uses bundled plugins; stable and beta use npm. Updates npm-installed plugins. + Syncs plugins to the active channel. Dev uses bundled plugins; stable and beta use npm. Updates tracked plugin installs. +On the beta update channel, tracked npm and ClawHub plugin installs that follow +the default/latest line try a plugin `@beta` release first. If the plugin has no +beta release, OpenClaw falls back to the recorded default/latest spec. Exact +versions and explicit tags are not rewritten. + If an exact pinned npm plugin update resolves to an artifact whose integrity differs from the stored install record, `openclaw update` aborts that plugin artifact update instead of installing it. Reinstall or update the plugin explicitly only after verifying that you trust the new artifact. diff --git a/docs/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md index 822178974dc..7879f34cca0 100644 --- a/docs/plugins/manage-plugins.md +++ b/docs/plugins/manage-plugins.md @@ -89,6 +89,11 @@ openclaw plugins update @scope/openclaw-plugin The second command moves a plugin back to the registry's default release line when it was previously pinned to an exact version or tag. +When `openclaw update` runs on the beta channel, default-line npm and ClawHub +plugin records try the matching plugin `@beta` release first. If that beta +release does not exist, OpenClaw falls back to the recorded default/latest spec. +Exact versions and explicit tags such as `@rc` or `@beta` are preserved. + ## Uninstall plugins ```bash diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 78551be2e29..adfca3a2c9e 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -529,6 +529,9 @@ Passing the package name without a version moves an exact pinned install back to the registry's default release line. If the installed npm plugin already matches the resolved version and recorded artifact identity, OpenClaw skips the update without downloading, reinstalling, or rewriting config. +When `openclaw update` runs on the beta channel, default-line npm and ClawHub +plugin records try `@beta` first and fall back to default/latest when no plugin +beta release exists. Exact versions and explicit tags stay pinned. `--pin` is npm-only. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec. diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d4730da3ac4..90593779dcc 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -848,6 +848,7 @@ async function updatePluginsAfterCoreUpdate(params: { const npmResult = await updateNpmInstalledPlugins({ config: pluginConfig, timeoutMs: params.timeoutMs, + updateChannel: params.channel, skipIds: new Set(syncResult.summary.switchedToNpm), skipDisabledPlugins: true, logger: pluginLogger, diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 508940cb4d2..446aa345fe2 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -170,13 +170,14 @@ function createClawHubInstallConfig(params: { clawhubPackage: string; clawhubFamily: "bundle-plugin" | "code-plugin"; clawhubChannel: "community" | "official" | "private"; + spec?: string; }): OpenClawConfig { return { plugins: { installs: { [params.pluginId]: { source: "clawhub" as const, - spec: `clawhub:${params.clawhubPackage}`, + spec: params.spec ?? `clawhub:${params.clawhubPackage}`, installPath: params.installPath, clawhubUrl: params.clawhubUrl, clawhubPackage: params.clawhubPackage, @@ -1029,6 +1030,115 @@ describe("updateNpmInstalledPlugins", () => { }, ); + it("tries npm beta for default npm specs on beta channel without persisting the beta tag", async () => { + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + 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: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + }); + + expectNpmUpdateCall({ + spec: "openclaw-codex-app-server@beta", + expectedPluginId: "openclaw-codex-app-server", + }); + expectCodexAppServerInstallState({ + result, + spec: "openclaw-codex-app-server", + version: "0.2.0-beta.4", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4", + }); + }); + + it("falls back to the default npm spec when a beta tag is unavailable", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: + "npm ERR! code ETARGET\nnpm ERR! No matching version found for openclaw-codex-app-server@beta.", + }) + .mockResolvedValueOnce( + createSuccessfulNpmUpdateResult({ + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.6", + npmResolution: { + name: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }, + }), + ); + + const warnMessages: string[] = []; + const result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + logger: { warn: (msg) => warnMessages.push(msg) }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + }), + ); + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + spec: "openclaw-codex-app-server", + }), + ); + expect(warnMessages).toEqual([expect.stringContaining("has no beta npm release")]); + expectCodexAppServerInstallState({ + result, + spec: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }); + }); + + it("preserves explicit npm tags when updating on the beta channel", async () => { + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-rc.1", + }), + ); + + await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server@rc", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + dryRun: true, + }); + + expectNpmUpdateCall({ + spec: "openclaw-codex-app-server@rc", + expectedPluginId: "openclaw-codex-app-server", + }); + }); + it("updates ClawHub-installed plugins via recorded package metadata", async () => { installPluginFromClawHubMock.mockResolvedValue({ ok: true, @@ -1098,6 +1208,130 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("tries ClawHub beta for default ClawHub specs on beta channel without persisting the beta tag", async () => { + installPluginFromClawHubMock.mockResolvedValue( + createSuccessfulClawHubUpdateResult({ + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.3.0-beta.1", + clawhubPackage: "demo", + }), + ); + + const result = await updateNpmInstalledPlugins({ + config: createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }), + pluginIds: ["demo"], + updateChannel: "beta", + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo@beta", + baseUrl: "https://clawhub.ai", + expectedPluginId: "demo", + }), + ); + expect(result.config.plugins?.installs?.demo).toMatchObject({ + source: "clawhub", + spec: "clawhub:demo", + installPath: "/tmp/demo", + version: "1.3.0-beta.1", + clawhubPackage: "demo", + }); + }); + + it("falls back to the default ClawHub spec when a beta release is unavailable", async () => { + installPluginFromClawHubMock + .mockResolvedValueOnce({ + ok: false, + code: "version_not_found", + error: "version not found: beta", + }) + .mockResolvedValueOnce( + createSuccessfulClawHubUpdateResult({ + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.2.4", + clawhubPackage: "demo", + }), + ); + + const warnMessages: string[] = []; + const result = await updateNpmInstalledPlugins({ + config: createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }), + pluginIds: ["demo"], + updateChannel: "beta", + logger: { warn: (msg) => warnMessages.push(msg) }, + }); + + expect(installPluginFromClawHubMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + spec: "clawhub:demo@beta", + }), + ); + expect(installPluginFromClawHubMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + spec: "clawhub:demo", + }), + ); + expect(warnMessages).toEqual([expect.stringContaining("has no beta ClawHub release")]); + expect(result.config.plugins?.installs?.demo).toMatchObject({ + source: "clawhub", + spec: "clawhub:demo", + installPath: "/tmp/demo", + version: "1.2.4", + clawhubPackage: "demo", + }); + }); + + it("preserves explicit ClawHub tags when updating on the beta channel", async () => { + installPluginFromClawHubMock.mockResolvedValue( + createSuccessfulClawHubUpdateResult({ + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.3.0-rc.1", + clawhubPackage: "demo", + }), + ); + + await updateNpmInstalledPlugins({ + config: createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + spec: "clawhub:demo@rc", + }), + pluginIds: ["demo"], + updateChannel: "beta", + dryRun: true, + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo@rc", + }), + ); + }); + it("skips ClawHub plugin update when bundled version is newer", async () => { resolveBundledPluginSourcesMock.mockReturnValue( new Map([ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index a8edb9be9f4..c20aca14198 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -1,8 +1,10 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import type { NpmSpecResolution } from "../infra/install-source-utils.js"; import { resolveNpmSpecMetadata } from "../infra/install-source-utils.js"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { expectedIntegrityForUpdate, readInstalledPackageVersion, @@ -395,6 +397,113 @@ function shouldFallbackClawHubBridgeToNpm(result: { ok: false; code?: string }): ); } +function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): boolean { + return shouldFallbackClawHubBridgeToNpm(result); +} + +function shouldFallbackBetaNpmUpdate(result: { ok: false; code?: string; error: string }): boolean { + if (result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) { + return true; + } + return /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test( + result.error, + ); +} + +function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null { + const parsed = parseRegistryNpmSpec(spec); + if (!parsed) { + return null; + } + if (parsed.selectorKind === "none") { + return { name: parsed.name }; + } + if (parsed.selectorKind === "tag" && parsed.selector?.toLowerCase() === "latest") { + return { name: parsed.name }; + } + return null; +} + +function resolveNpmUpdateSpecs(params: { + record: PluginInstallRecord; + specOverride?: string; + updateChannel?: UpdateChannel; +}): { + installSpec?: string; + recordSpec?: string; + fallbackSpec?: string; + fallbackLabel?: string; +} { + const recordSpec = params.specOverride ?? params.record.spec; + if (!recordSpec) { + return {}; + } + if (params.specOverride || params.updateChannel !== "beta") { + return { + installSpec: recordSpec, + recordSpec, + }; + } + const betaTarget = isDefaultNpmSpecForBetaUpdate(recordSpec); + if (!betaTarget) { + return { + installSpec: recordSpec, + recordSpec, + }; + } + return { + installSpec: `${betaTarget.name}@beta`, + recordSpec, + fallbackSpec: recordSpec, + fallbackLabel: `${betaTarget.name}@beta`, + }; +} + +function isDefaultClawHubSpecForBetaUpdate(spec: string): { name: string } | null { + const parsed = parseClawHubPluginSpec(spec); + if (!parsed) { + return null; + } + if (!parsed.version || parsed.version.toLowerCase() === "latest") { + return { name: parsed.name }; + } + return null; +} + +function resolveClawHubUpdateSpecs(params: { + record: PluginInstallRecord; + updateChannel?: UpdateChannel; +}): { + installSpec?: string; + recordSpec?: string; + fallbackSpec?: string; + fallbackLabel?: string; +} { + if (!params.record.clawhubPackage) { + return {}; + } + const recordSpec = params.record.spec ?? `clawhub:${params.record.clawhubPackage}`; + if (params.updateChannel !== "beta") { + return { + installSpec: recordSpec, + recordSpec, + }; + } + const betaTarget = isDefaultClawHubSpecForBetaUpdate(recordSpec); + if (!betaTarget) { + return { + installSpec: recordSpec, + recordSpec, + }; + } + return { + installSpec: `clawhub:${betaTarget.name}@beta`, + recordSpec, + fallbackSpec: recordSpec, + fallbackLabel: `clawhub:${betaTarget.name}@beta`, + }; +} + function isBridgeAlreadyInstalledFromPreferredSource(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; @@ -516,6 +625,7 @@ export async function updateNpmInstalledPlugins(params: { skipDisabledPlugins?: boolean; timeoutMs?: number; dryRun?: boolean; + updateChannel?: UpdateChannel; dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; @@ -582,12 +692,41 @@ export async function updateNpmInstalledPlugins(params: { continue; } + const npmSpecs = + record.source === "npm" + ? resolveNpmUpdateSpecs({ + record, + specOverride: params.specOverrides?.[pluginId], + updateChannel: params.updateChannel, + }) + : undefined; + const clawhubSpecs = + record.source === "clawhub" + ? resolveClawHubUpdateSpecs({ + record, + updateChannel: params.updateChannel, + }) + : undefined; const effectiveSpec = - record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : record.spec; + record.source === "npm" + ? npmSpecs?.installSpec + : record.source === "clawhub" + ? clawhubSpecs?.installSpec + : record.spec; + const recordSpec = + record.source === "npm" + ? npmSpecs?.recordSpec + : record.source === "clawhub" + ? clawhubSpecs?.recordSpec + : record.spec; const expectedIntegrity = record.source === "npm" && effectiveSpec === record.spec ? expectedIntegrityForUpdate(record.spec, record.integrity) : undefined; + const fallbackExpectedIntegrity = + record.source === "npm" && npmSpecs?.fallbackSpec === record.spec + ? expectedIntegrityForUpdate(record.spec, record.integrity) + : undefined; if (record.source === "npm" && !effectiveSpec) { outcomes.push({ @@ -764,6 +903,54 @@ export async function updateNpmInstalledPlugins(params: { }); continue; } + if ( + !probe.ok && + record.source === "npm" && + npmSpecs?.fallbackSpec && + shouldFallbackBetaNpmUpdate(probe) + ) { + logger.warn?.( + `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + ); + probe = await installPluginFromNpmSpec({ + spec: npmSpecs.fallbackSpec, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dryRun: true, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + expectedIntegrity: fallbackExpectedIntegrity, + onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ + pluginId, + dryRun: true, + logger, + onIntegrityDrift: params.onIntegrityDrift, + }), + logger, + }); + } + if ( + !probe.ok && + record.source === "clawhub" && + clawhubSpecs?.fallbackSpec && + shouldFallbackBetaClawHubUpdate(probe) + ) { + logger.warn?.( + `Plugin "${pluginId}" has no beta ClawHub release for ${clawhubSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${clawhubSpecs.fallbackSpec}.`, + ); + probe = await installPluginFromClawHub({ + spec: clawhubSpecs.fallbackSpec, + baseUrl: record.clawhubUrl, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dryRun: true, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + logger, + }); + } if (!probe.ok) { outcomes.push({ pluginId, @@ -895,6 +1082,52 @@ export async function updateNpmInstalledPlugins(params: { }); continue; } + if ( + !result.ok && + record.source === "npm" && + npmSpecs?.fallbackSpec && + shouldFallbackBetaNpmUpdate(result) + ) { + logger.warn?.( + `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + ); + result = await installPluginFromNpmSpec({ + spec: npmSpecs.fallbackSpec, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + expectedIntegrity: fallbackExpectedIntegrity, + onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ + pluginId, + dryRun: false, + logger, + onIntegrityDrift: params.onIntegrityDrift, + }), + logger, + }); + } + if ( + !result.ok && + record.source === "clawhub" && + clawhubSpecs?.fallbackSpec && + shouldFallbackBetaClawHubUpdate(result) + ) { + logger.warn?.( + `Plugin "${pluginId}" has no beta ClawHub release for ${clawhubSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${clawhubSpecs.fallbackSpec}.`, + ); + result = await installPluginFromClawHub({ + spec: clawhubSpecs.fallbackSpec, + baseUrl: record.clawhubUrl, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + logger, + }); + } if (!result.ok) { outcomes.push({ pluginId, @@ -942,7 +1175,7 @@ export async function updateNpmInstalledPlugins(params: { next = recordPluginInstall(next, { pluginId: resolvedPluginId, source: "npm", - spec: effectiveSpec, + spec: recordSpec, installPath: result.targetDir, version: nextVersion, ...buildNpmResolutionInstallFields(result.npmResolution), @@ -955,7 +1188,7 @@ export async function updateNpmInstalledPlugins(params: { next = recordPluginInstall(next, { pluginId: resolvedPluginId, ...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub), - spec: effectiveSpec ?? record.spec ?? `clawhub:${record.clawhubPackage!}`, + spec: recordSpec ?? record.spec ?? `clawhub:${record.clawhubPackage!}`, installPath: result.targetDir, version: nextVersion, });