fix(plugins): honor beta channel for auto installs

This commit is contained in:
Vincent Koc
2026-05-04 21:33:46 -07:00
parent e03fe1e289
commit b0f841ef37
7 changed files with 325 additions and 90 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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([
{

View File

@@ -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<string, PluginInstallRecord>;
updateChannel?: UpdateChannel;
}): Promise<{
records: Record<string, PluginInstallRecord>;
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<string>();
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);

View File

@@ -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<InstallChoice> {
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",

View File

@@ -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,
};
}

View File

@@ -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: {