From b0f841ef37dbf3313487a3068a51c2751ddf4fb3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 21:33:46 -0700 Subject: [PATCH] fix(plugins): honor beta channel for auto installs --- CHANGELOG.md | 1 + .../channel-setup/plugin-install.test.ts | 48 ++++++++++ .../missing-configured-plugin-install.test.ts | 87 +++++++++++++++++++ .../missing-configured-plugin-install.ts | 59 +++++++++---- src/commands/onboarding-plugin-install.ts | 62 +++++++++---- src/plugins/install-channel-specs.ts | 87 +++++++++++++++++++ src/plugins/update.ts | 71 +++------------ 7 files changed, 325 insertions(+), 90 deletions(-) create mode 100644 src/plugins/install-channel-specs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3908b5165a..0c85ba4e0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc. - WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc. - Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc. - Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91. diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 00ae250acf5..178472a08d4 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -437,6 +437,54 @@ describe("ensureChannelSetupPluginInstalled", () => { expect(await runInitialValueForChannel("beta")).toBe("npm"); }); + it("installs npm beta on the beta channel without persisting the beta tag", async () => { + const runtime = makeRuntime(); + const { prompter, select } = makeSkipInstallPrompter(); + const cfg: OpenClawConfig = { update: { channel: "beta" } }; + vi.mocked(fs.existsSync).mockReturnValue(false); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "wecom-openclaw-plugin", + targetDir: "/tmp/wecom-openclaw-plugin", + version: "2026.5.4-beta.1", + npmResolution: { + name: "@openclaw/wecom", + version: "2026.5.4-beta.1", + resolvedSpec: "@openclaw/wecom@2026.5.4-beta.1", + }, + }); + + const result = await ensureChannelSetupPluginInstalled({ + cfg, + entry: { + id: "wecom", + pluginId: "wecom-openclaw-plugin", + meta: { + id: "wecom", + label: "WeCom", + selectionLabel: "WeCom", + docsPath: "/channels/wecom", + blurb: "WeCom channel", + }, + install: { + npmSpec: "@openclaw/wecom", + }, + }, + prompter, + runtime, + promptInstall: false, + }); + + expect(select).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/wecom@beta", + expectedPluginId: "wecom-openclaw-plugin", + }), + ); + expect(result.cfg.plugins?.installs?.["wecom-openclaw-plugin"]?.spec).toBe("@openclaw/wecom"); + }); + it("defaults to bundled local path on beta channel when available", async () => { const runtime = makeRuntime(); const { prompter, select } = makeSkipInstallPrompter(); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index ba649aa7ed4..db88cb0273d 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -2015,6 +2015,93 @@ describe("repairMissingConfiguredPluginInstalls", () => { ]); }); + it("installs configured external web search plugins from beta on the beta channel", async () => { + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "brave", + label: "Brave", + install: { + npmSpec: "@openclaw/brave-plugin", + defaultChoice: "npm", + }, + openclaw: { + plugin: { id: "brave", label: "Brave" }, + webSearchProviders: [ + { + id: "brave", + label: "Brave Search", + hint: "Brave Search", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://example.test/brave", + credentialPath: "plugins.entries.brave.config.webSearch.apiKey", + }, + ], + install: { + npmSpec: "@openclaw/brave-plugin", + defaultChoice: "npm", + }, + }, + }, + ]); + mocks.resolveOfficialExternalPluginId.mockImplementation( + (entry: { id?: string; openclaw?: { plugin?: { id?: string } } }) => + entry.openclaw?.plugin?.id ?? entry.id, + ); + mocks.resolveOfficialExternalPluginInstall.mockImplementation( + (entry: { install?: unknown; openclaw?: { install?: unknown } }) => + entry.openclaw?.install ?? entry.install ?? null, + ); + mocks.resolveOfficialExternalPluginLabel.mockImplementation( + (entry: { label?: string; openclaw?: { plugin?: { label?: string } } }) => + entry.openclaw?.plugin?.label ?? entry.label ?? "plugin", + ); + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "brave", + targetDir: "/tmp/openclaw-plugins/brave", + version: "2026.5.4-beta.1", + npmResolution: { + name: "@openclaw/brave-plugin", + version: "2026.5.4-beta.1", + resolvedSpec: "@openclaw/brave-plugin@2026.5.4-beta.1", + }, + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + update: { channel: "beta" }, + tools: { + web: { + search: { + provider: "brave", + }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/brave-plugin@beta", + expectedPluginId: "brave", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + expect.objectContaining({ + brave: expect.objectContaining({ spec: "@openclaw/brave-plugin" }), + }), + { env: {} }, + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "brave" from @openclaw/brave-plugin@beta.', + ]); + }); + it("does not install a configured external web search plugin when search is disabled", async () => { mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 4542893e39e..f6fea05a6e2 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -9,9 +9,18 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../../../infra/clawhub-spec.js"; import { parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js"; +import { + normalizeUpdateChannel, + resolveRegistryUpdateChannel, + type UpdateChannel, +} from "../../../infra/update-channels.js"; import { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.js"; import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js"; import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js"; +import { + resolveClawHubInstallSpecsForUpdateChannel, + resolveNpmInstallSpecsForUpdateChannel, +} from "../../../plugins/install-channel-specs.js"; import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js"; import { installPluginFromNpmSpec } from "../../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; @@ -32,6 +41,7 @@ import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { resolveUserPath } from "../../../utils.js"; +import { VERSION } from "../../../version.js"; import { asObjectRecord } from "./object.js"; type DownloadableInstallCandidate = { @@ -457,6 +467,7 @@ function recordClawHubPackageName(value: string | undefined): string | undefined async function installCandidate(params: { candidate: DownloadableInstallCandidate; records: Record; + updateChannel?: UpdateChannel; }): Promise<{ records: Record; changes: string[]; @@ -465,9 +476,23 @@ async function installCandidate(params: { const { candidate } = params; const extensionsDir = resolveDefaultPluginExtensionsDir(); const changes: string[] = []; - if (candidate.clawhubSpec && candidate.defaultChoice !== "npm") { + const clawhubSpecs = candidate.clawhubSpec + ? resolveClawHubInstallSpecsForUpdateChannel({ + spec: candidate.clawhubSpec, + updateChannel: params.updateChannel, + }) + : null; + const npmSpecs = candidate.npmSpec + ? resolveNpmInstallSpecsForUpdateChannel({ + spec: candidate.npmSpec, + updateChannel: params.updateChannel, + }) + : null; + const clawhubInstallSpec = clawhubSpecs?.installSpec ?? candidate.clawhubSpec; + const npmInstallSpec = npmSpecs?.installSpec ?? candidate.npmSpec; + if (clawhubInstallSpec && candidate.defaultChoice !== "npm") { const clawhubResult = await installPluginFromClawHub({ - spec: candidate.clawhubSpec, + spec: clawhubInstallSpec, extensionsDir, expectedPluginId: candidate.pluginId, mode: "install", @@ -479,31 +504,29 @@ async function installCandidate(params: { ...params.records, [pluginId]: { ...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub), - spec: candidate.clawhubSpec, + spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec, installPath: clawhubResult.targetDir, installedAt: new Date().toISOString(), }, }, - changes: [ - `Installed missing configured plugin "${pluginId}" from ${candidate.clawhubSpec}.`, - ], + changes: [`Installed missing configured plugin "${pluginId}" from ${clawhubInstallSpec}.`], warnings: [], }; } - if (!candidate.npmSpec || !shouldFallbackClawHubToNpm(clawhubResult)) { + if (!npmInstallSpec || !shouldFallbackClawHubToNpm(clawhubResult)) { return { records: params.records, changes: [], warnings: [ - `Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.clawhubSpec}: ${clawhubResult.error}`, + `Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`, ], }; } changes.push( - `ClawHub ${candidate.clawhubSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${candidate.npmSpec}.`, + `ClawHub ${clawhubInstallSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${npmInstallSpec}.`, ); } - if (!candidate.npmSpec) { + if (!npmInstallSpec) { return { records: params.records, changes: [], @@ -513,7 +536,7 @@ async function installCandidate(params: { }; } const result = await installPluginFromNpmSpec({ - spec: candidate.npmSpec, + spec: npmInstallSpec, extensionsDir, expectedPluginId: candidate.pluginId, expectedIntegrity: candidate.expectedIntegrity, @@ -527,7 +550,7 @@ async function installCandidate(params: { records: params.records, changes: [], warnings: [ - `Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.npmSpec}: ${result.error}`, + `Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`, ], }; } @@ -537,7 +560,7 @@ async function installCandidate(params: { ...params.records, [pluginId]: { source: "npm", - spec: candidate.npmSpec, + spec: npmSpecs?.recordSpec ?? npmInstallSpec, installPath: result.targetDir, version: result.version, installedAt: new Date().toISOString(), @@ -546,7 +569,7 @@ async function installCandidate(params: { }, changes: [ ...changes, - `Installed missing configured plugin "${pluginId}" from ${candidate.npmSpec}.`, + `Installed missing configured plugin "${pluginId}" from ${npmInstallSpec}.`, ], warnings: [], }; @@ -642,6 +665,10 @@ async function repairMissingPluginInstalls(params: { const changes: string[] = []; const warnings: string[] = []; const deferredPluginIds = new Set(); + const updateChannel = resolveRegistryUpdateChannel({ + configChannel: normalizeUpdateChannel(params.cfg.update?.channel), + currentVersion: VERSION, + }); let nextRecords = records; for (const [pluginId, record] of Object.entries(records)) { @@ -700,7 +727,7 @@ async function repairMissingPluginInstalls(params: { }, }, pluginIds: missingRecordedPluginIds, - updateChannel: params.cfg.update?.channel, + updateChannel, logger: { warn: (message) => warnings.push(message), error: (message) => warnings.push(message), @@ -754,7 +781,7 @@ async function repairMissingPluginInstalls(params: { if (hasUsableRecord) { continue; } - const installed = await installCandidate({ candidate, records: nextRecords }); + const installed = await installCandidate({ candidate, records: nextRecords, updateChannel }); nextRecords = installed.records; changes.push(...installed.changes); warnings.push(...installed.warnings); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 983ba7721e8..3d5263e51cf 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -4,6 +4,7 @@ import { resolveBundledInstallPlanForCatalogEntry } from "../cli/plugin-install- import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { normalizeUpdateChannel, resolveRegistryUpdateChannel } from "../infra/update-channels.js"; import { findBundledPluginSourceInMap, resolveBundledPluginSources, @@ -11,6 +12,10 @@ import { import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-install-records.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js"; import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js"; +import { + resolveClawHubInstallSpecsForUpdateChannel, + resolveNpmInstallSpecsForUpdateChannel, +} from "../plugins/install-channel-specs.js"; import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import { installPluginFromNpmSpec } from "../plugins/install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "../plugins/installs.js"; @@ -18,6 +23,7 @@ import type { PluginPackageInstall } from "../plugins/manifest.js"; import type { RuntimeEnv } from "../runtime.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { withTimeout } from "../utils/with-timeout.js"; +import { VERSION } from "../version.js"; import type { WizardPrompter } from "../wizard/prompts.js"; type InstallChoice = "clawhub" | "npm" | "local" | "skip"; @@ -325,6 +331,8 @@ async function promptInstallChoice(params: { * to that source. Useful when the caller already knows the user's intent * (e.g. they just picked the channel in a previous menu). */ autoConfirmSingleSource?: boolean; + effectiveNpmSpec?: string | null; + effectiveClawHubSpec?: string | null; }): Promise { const rawClawHubSpec = resolveClawHubSpecForOnboarding(params.entry.install); const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install); @@ -336,8 +344,10 @@ async function promptInstallChoice(params: { // case is misleading; those catalog specs only exist as fallback metadata for // non-bundled builds. Hide them so bundled channels like Tlon look identical // to Twitch / Slack in the menu. - const clawhubSpec = params.bundledLocalPath ? null : rawClawHubSpec; - const npmSpec = params.bundledLocalPath ? null : rawNpmSpec; + const clawhubSpec = params.bundledLocalPath + ? null + : (params.effectiveClawHubSpec ?? rawClawHubSpec); + const npmSpec = params.bundledLocalPath ? null : (params.effectiveNpmSpec ?? rawNpmSpec); const safeLabel = sanitizeTerminalText(params.entry.label); const safeClawHubSpec = clawhubSpec ? sanitizeTerminalText(clawhubSpec) : null; const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null; @@ -729,6 +739,24 @@ export async function ensureOnboardingPluginInstalled(params: { }); const clawhubSpec = resolveClawHubSpecForOnboarding(entry.install); const npmSpec = resolveNpmSpecForOnboarding(entry.install); + const updateChannel = resolveRegistryUpdateChannel({ + configChannel: normalizeUpdateChannel(next.update?.channel), + currentVersion: VERSION, + }); + const clawhubSpecs = clawhubSpec + ? resolveClawHubInstallSpecsForUpdateChannel({ + spec: clawhubSpec, + updateChannel, + }) + : null; + const npmSpecs = npmSpec + ? resolveNpmInstallSpecsForUpdateChannel({ + spec: npmSpec, + updateChannel, + }) + : null; + const clawhubInstallSpec = clawhubSpecs?.installSpec ?? clawhubSpec; + const npmInstallSpec = npmSpecs?.installSpec ?? npmSpec; const defaultChoice = resolveInstallDefaultChoice({ cfg: next, entry, @@ -747,6 +775,8 @@ export async function ensureOnboardingPluginInstalled(params: { defaultChoice, prompter, autoConfirmSingleSource: params.autoConfirmSingleSource, + effectiveClawHubSpec: clawhubInstallSpec, + effectiveNpmSpec: npmInstallSpec, }); if (choice === "skip") { @@ -793,10 +823,10 @@ export async function ensureOnboardingPluginInstalled(params: { } let shouldTryNpm = choice === "npm"; - if (choice === "clawhub" && clawhubSpec) { + if (choice === "clawhub" && clawhubInstallSpec) { const installOutcome = await installPluginFromClawHubSpecWithProgress({ entry, - clawhubSpec, + clawhubSpec: clawhubInstallSpec, prompter, runtime, }); @@ -804,13 +834,13 @@ export async function ensureOnboardingPluginInstalled(params: { if (installOutcome.status === "timed_out") { await prompter.note( [ - `Installing ${sanitizeTerminalText(clawhubSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, + `Installing ${sanitizeTerminalText(clawhubInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, "Returning to selection.", ].join("\n"), "Plugin install", ); runtime.error?.( - `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubSpec)}`, + `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubInstallSpec)}`, ); return { cfg: next, @@ -841,7 +871,7 @@ export async function ensureOnboardingPluginInstalled(params: { next = recordPluginInstall(next, { pluginId: result.pluginId, ...buildClawHubPluginInstallRecordFields(result.clawhub), - spec: clawhubSpec, + spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec, installPath: result.targetDir, }); return { @@ -854,13 +884,13 @@ export async function ensureOnboardingPluginInstalled(params: { await prompter.note( [ - `Failed to install ${sanitizeTerminalText(clawhubSpec)}: ${summarizeInstallError(result.error)}`, + `Failed to install ${sanitizeTerminalText(clawhubInstallSpec)}: ${summarizeInstallError(result.error)}`, "Returning to selection.", ].join("\n"), "Plugin install", ); - if (!npmSpec || !shouldFallbackClawHubToNpm(result)) { + if (!npmInstallSpec || !shouldFallbackClawHubToNpm(result)) { runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`); return { cfg: next, @@ -871,7 +901,7 @@ export async function ensureOnboardingPluginInstalled(params: { } shouldTryNpm = await prompter.confirm({ - message: `Use npm package instead? (${sanitizeTerminalText(npmSpec)})`, + message: `Use npm package instead? (${sanitizeTerminalText(npmInstallSpec)})`, initialValue: true, }); if (!shouldTryNpm) { @@ -885,7 +915,7 @@ export async function ensureOnboardingPluginInstalled(params: { } } - if (!shouldTryNpm || !npmSpec) { + if (!shouldTryNpm || !npmInstallSpec) { await prompter.note( `No remote install source is available for ${sanitizeTerminalText(entry.label)}. Returning to selection.`, "Plugin install", @@ -903,7 +933,7 @@ export async function ensureOnboardingPluginInstalled(params: { const installOutcome = await installPluginFromNpmSpecWithProgress({ entry, - npmSpec, + npmSpec: npmInstallSpec, prompter, runtime, }); @@ -911,13 +941,13 @@ export async function ensureOnboardingPluginInstalled(params: { if (installOutcome.status === "timed_out") { await prompter.note( [ - `Installing ${sanitizeTerminalText(npmSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, + `Installing ${sanitizeTerminalText(npmInstallSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, "Returning to selection.", ].join("\n"), "Plugin install", ); runtime.error?.( - `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmSpec)}`, + `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`, ); return { cfg: next, @@ -949,7 +979,7 @@ export async function ensureOnboardingPluginInstalled(params: { const install = { pluginId: result.pluginId, source: "npm", - spec: npmSpec, + spec: npmSpecs?.recordSpec ?? npmInstallSpec, installPath: result.targetDir, version: result.version, ...buildNpmResolutionInstallFields(result.npmResolution), @@ -965,7 +995,7 @@ export async function ensureOnboardingPluginInstalled(params: { await prompter.note( [ - `Failed to install ${sanitizeTerminalText(npmSpec)}: ${summarizeInstallError(result.error)}`, + `Failed to install ${sanitizeTerminalText(npmInstallSpec)}: ${summarizeInstallError(result.error)}`, "Returning to selection.", ].join("\n"), "Plugin install", diff --git a/src/plugins/install-channel-specs.ts b/src/plugins/install-channel-specs.ts new file mode 100644 index 00000000000..9e441bd45e8 --- /dev/null +++ b/src/plugins/install-channel-specs.ts @@ -0,0 +1,87 @@ +import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import type { UpdateChannel } from "../infra/update-channels.js"; + +export type ChannelInstallSpecs = { + installSpec: string; + recordSpec: string; + fallbackSpec?: string; + fallbackLabel?: string; +}; + +function isDefaultNpmSpecForBetaChannel(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 isDefaultClawHubSpecForBetaChannel(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; +} + +export function resolveNpmInstallSpecsForUpdateChannel(params: { + spec: string; + updateChannel?: UpdateChannel; +}): ChannelInstallSpecs { + if (params.updateChannel !== "beta") { + return { + installSpec: params.spec, + recordSpec: params.spec, + }; + } + const betaTarget = isDefaultNpmSpecForBetaChannel(params.spec); + if (!betaTarget) { + return { + installSpec: params.spec, + recordSpec: params.spec, + }; + } + const betaSpec = `${betaTarget.name}@beta`; + return { + installSpec: betaSpec, + recordSpec: params.spec, + fallbackSpec: params.spec, + fallbackLabel: betaSpec, + }; +} + +export function resolveClawHubInstallSpecsForUpdateChannel(params: { + spec: string; + updateChannel?: UpdateChannel; +}): ChannelInstallSpecs { + if (params.updateChannel !== "beta") { + return { + installSpec: params.spec, + recordSpec: params.spec, + }; + } + const betaTarget = isDefaultClawHubSpecForBetaChannel(params.spec); + if (!betaTarget) { + return { + installSpec: params.spec, + recordSpec: params.spec, + }; + } + const betaSpec = `clawhub:${betaTarget.name}@beta`; + return { + installSpec: betaSpec, + recordSpec: params.spec, + fallbackSpec: params.spec, + fallbackLabel: betaSpec, + }; +} diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 2a1175954a5..de3f0c870d0 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -31,6 +31,10 @@ import { type ExternalizedBundledPluginBridge, } from "./externalized-bundled-plugins.js"; import { installPluginFromGitSpec } from "./git-install.js"; +import { + resolveClawHubInstallSpecsForUpdateChannel, + resolveNpmInstallSpecsForUpdateChannel, +} from "./install-channel-specs.js"; import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE, @@ -459,20 +463,6 @@ function npmUpdateFailureSpec(params: { return params.effectiveSpec ?? params.fallbackSpec ?? "unknown"; } -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 resolveNpmSpecPackageName(spec: string | undefined): string | undefined { return spec ? parseRegistryNpmSpec(spec)?.name : undefined; } @@ -563,36 +553,16 @@ function resolveNpmUpdateSpecs(params: { if (!recordSpec) { return {}; } - if (params.specOverride || params.updateChannel !== "beta") { + if (params.specOverride) { 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; + return resolveNpmInstallSpecsForUpdateChannel({ + spec: recordSpec, + updateChannel: params.updateChannel, + }); } function resolveClawHubUpdateSpecs(params: { @@ -608,25 +578,10 @@ function resolveClawHubUpdateSpecs(params: { 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`, - }; + return resolveClawHubInstallSpecsForUpdateChannel({ + spec: recordSpec, + updateChannel: params.updateChannel, + }); } function isBridgeAlreadyInstalledFromPreferredSource(params: {