Files
openclaw/src/plugins/update-source.ts
Peter Steinberger a2eb27c03a refactor(plugins): split update lifecycle (#108877)
* refactor(plugins): split update lifecycle

* fix(plugins): break update type cycle

* fix(plugins): keep update modules below line budget

* fix(plugins): preserve update export surface
2026-07-16 07:54:21 -07:00

744 lines
24 KiB
TypeScript

import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { ClawHubTrustErrorCode } from "../infra/clawhub-install-trust.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { satisfiesPluginApiRange } from "../infra/clawhub.js";
import { unscopedPackageName } from "../infra/install-safe-path.js";
import type { NpmSpecResolution } from "../infra/install-source-utils.js";
import { createNpmMetadataEnv, resolveNpmSpecMetadata } from "../infra/install-source-utils.js";
import {
compareOpenClawReleaseVersions,
isExactSemverVersion,
isPrereleaseResolutionAllowed,
isPrereleaseSemverVersion,
parseRegistryNpmSpec,
} from "../infra/npm-registry-spec.js";
import { expectedIntegrityForUpdate } from "../infra/package-update-utils.js";
import { compareValidSemver } from "../infra/semver.js";
import type { UpdateChannel } from "../infra/update-channels.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
import {
getExternalizedBundledPluginClawHubSpec,
getExternalizedBundledPluginNpmSpec,
getExternalizedBundledPluginPreferredSource,
type ExternalizedBundledPluginBridge,
} from "./externalized-bundled-plugins.js";
import {
resolveClawHubInstallSpecsForUpdateChannel,
resolveNpmInstallSpecsForUpdateChannel,
} from "./install-channel-specs.js";
import { PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
import { checkMinHostVersion } from "./min-host-version.js";
import { resolveTrustedSourceLinkedOfficialNpmSpec } from "./official-external-install-records.js";
import {
getOfficialExternalPluginCatalogEntry,
resolveOfficialExternalPluginInstall,
} from "./official-external-plugin-catalog.js";
import { resolvePackagePluginApiRange } from "./package-compat.js";
/** Logger surface used by plugin update flows. */
export type PluginUpdateLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
error?: (message: string) => void;
terminalLinks?: boolean;
};
type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error";
export type PluginUpdateChannelFallback = {
requestedSpec: string;
usedSpec: string;
requestedLabel: string;
usedLabel: string;
reason: "unavailable" | "failed";
message: string;
};
type BasePluginUpdateOutcome = {
pluginId: string;
message: string;
currentVersion?: string;
nextVersion?: string;
channelFallback?: PluginUpdateChannelFallback;
warning?: string;
};
export type PluginUpdateOutcome =
| (BasePluginUpdateOutcome & {
status: "skipped";
code?: ClawHubTrustErrorCode;
})
| (BasePluginUpdateOutcome & {
status: Exclude<PluginUpdateStatus, "skipped">;
code?: string;
});
export type PluginUpdateSummary = {
config: OpenClawConfig;
changed: boolean;
outcomes: PluginUpdateOutcome[];
};
export type PluginUpdateIntegrityDriftParams = {
pluginId: string;
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolvedSpec?: string;
resolvedVersion?: string;
dryRun: boolean;
};
export type UpdatablePluginInstallRecord = PluginInstallRecord & {
source: "npm" | "marketplace" | "clawhub" | "git";
};
export function isPluginInstallRecordUpdateSource(
record: PluginInstallRecord | undefined,
): record is UpdatablePluginInstallRecord {
return (
record?.source === "npm" ||
record?.source === "marketplace" ||
record?.source === "clawhub" ||
record?.source === "git"
);
}
/** Return whether update identity compatibility can migrate an unscoped install key. */
export function pluginInstallRecordMayMigrateConfigId(params: {
pluginId: string;
record: PluginInstallRecord | undefined;
specOverride?: string;
}): boolean {
if (!isPluginInstallRecordUpdateSource(params.record)) {
return false;
}
if (params.record?.source !== "npm") {
// Generic package/archive installers can resolve an unscoped tracked key
// to a scoped package id; the exact package identity is unavailable preflight.
return !params.pluginId.includes("/");
}
const packageName =
resolveNpmSpecPackageName(params.specOverride ?? params.record.spec) ??
params.record.resolvedName ??
resolveNpmSpecPackageName(params.record.resolvedSpec);
return Boolean(
packageName &&
packageName !== params.pluginId &&
unscopedPackageName(packageName) === params.pluginId,
);
}
export function shouldSkipUnchangedNpmInstall(params: {
currentVersion?: string;
record: {
integrity?: string;
shasum?: string;
resolvedName?: string;
resolvedSpec?: string;
resolvedVersion?: string;
};
metadata: NpmSpecResolution;
}): boolean {
if (!params.currentVersion || !params.metadata.version) {
return false;
}
if (params.currentVersion !== params.metadata.version) {
return false;
}
if (
!params.record.resolvedName ||
!params.record.resolvedSpec ||
!params.record.resolvedVersion
) {
return false;
}
if (!params.metadata.name || !params.metadata.resolvedSpec) {
return false;
}
if (params.metadata.integrity && !params.record.integrity) {
return false;
}
if (params.metadata.shasum && !params.record.shasum) {
return false;
}
return (
(!params.metadata.integrity || params.record.integrity === params.metadata.integrity) &&
(!params.metadata.shasum || params.record.shasum === params.metadata.shasum) &&
params.record.resolvedName === params.metadata.name &&
params.record.resolvedSpec === params.metadata.resolvedSpec &&
params.record.resolvedVersion === params.metadata.version
);
}
export function shouldBypassTrustedOfficialUnchangedNpmCheck(params: {
metadata: NpmSpecResolution;
spec: string;
trustedSourceLinkedOfficialInstall: boolean;
}): boolean {
if (!params.trustedSourceLinkedOfficialInstall || !params.metadata.version) {
return false;
}
const parsedSpec = parseRegistryNpmSpec(params.spec);
return Boolean(
parsedSpec &&
!isPrereleaseResolutionAllowed({
spec: parsedSpec,
resolvedVersion: params.metadata.version,
}),
);
}
export function expectedIntegrityForNpmUpdate(params: {
effectiveSpec: string | undefined;
metadata?: NpmSpecResolution;
record: PluginInstallRecord;
trustedSourceLinkedOfficialInstall: boolean;
}): string | undefined {
if (params.record.source !== "npm") {
return undefined;
}
if (params.effectiveSpec === params.record.spec) {
return expectedIntegrityForUpdate(params.record.spec, params.record.integrity);
}
if (!params.trustedSourceLinkedOfficialInstall || !params.metadata) {
return undefined;
}
const metadataName = params.metadata.name ?? resolveNpmSpecPackageName(params.effectiveSpec);
const recordName =
params.record.resolvedName ??
resolveNpmSpecPackageName(params.record.resolvedSpec) ??
resolveNpmSpecPackageName(params.record.spec);
if (!metadataName || metadataName !== recordName) {
return undefined;
}
if (!params.metadata.version || params.metadata.version !== params.record.resolvedVersion) {
return undefined;
}
return expectedIntegrityForUpdate(
params.record.resolvedSpec ?? params.record.spec,
params.record.integrity,
);
}
function compareNpmSemverForUpdate(left: string, right: string): number {
const releaseCmp = compareOpenClawReleaseVersions(left, right);
if (releaseCmp !== null) {
return releaseCmp;
}
return compareValidSemver(left, right) ?? 0;
}
export async function resolveNewerExactPinnedNpmDefaultLine(params: {
currentVersion: string | undefined;
effectiveSpec: string | undefined;
probeNpmVersion: string | undefined;
timeoutMs?: number;
}): Promise<{ packageName: string; version: string } | undefined> {
if (!params.currentVersion || !params.probeNpmVersion || !params.effectiveSpec) {
return undefined;
}
const packageName = resolveNpmSpecPackageName(params.effectiveSpec);
const exactVersion = resolveExactNpmSpecVersion(params.effectiveSpec);
const probeNpmVersion = normalizeExactNpmVersion(params.probeNpmVersion);
if (!packageName || !exactVersion || probeNpmVersion !== exactVersion) {
return undefined;
}
const metadataResult = await resolveNpmSpecMetadata({
spec: packageName,
timeoutMs: params.timeoutMs,
}).catch(() => undefined);
if (
!metadataResult?.ok ||
metadataResult.metadata.name !== packageName ||
!metadataResult.metadata.version
) {
return undefined;
}
return compareNpmSemverForUpdate(metadataResult.metadata.version, params.currentVersion) > 0
? { packageName, version: metadataResult.metadata.version }
: undefined;
}
async function loadNpmPackageVersionsForUpdate(params: {
packageName: string;
timeoutMs?: number;
}): Promise<string[] | null> {
const versions = await runCommandWithTimeout(
["npm", "view", params.packageName, "versions", "--json"],
{
timeoutMs: Math.max(params.timeoutMs ?? 0, 60_000),
env: createNpmMetadataEnv(),
},
);
if (!versions || versions.code !== 0) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(versions.stdout.trim());
} catch {
return null;
}
return (Array.isArray(parsed) ? parsed : [parsed]).filter(
(value): value is string => typeof value === "string" && isExactSemverVersion(value),
);
}
export async function resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate(params: {
metadata: NpmSpecResolution;
spec: string;
timeoutMs?: number;
}): Promise<
| {
kind: "stable" | "prerelease-only";
metadata: NpmSpecResolution;
}
| undefined
> {
const parsedSpec = parseRegistryNpmSpec(params.spec);
if (
!parsedSpec ||
!parsedSpec.name.startsWith("@openclaw/") ||
!params.metadata.version ||
isPrereleaseResolutionAllowed({
spec: parsedSpec,
resolvedVersion: params.metadata.version,
})
) {
return undefined;
}
const versions = await loadNpmPackageVersionsForUpdate({
packageName: parsedSpec.name,
timeoutMs: params.timeoutMs,
});
const stableVersion = versions
?.filter((value) => !isPrereleaseSemverVersion(value))
.toSorted(compareNpmSemverForUpdate)
.at(-1);
if (stableVersion) {
const stableMetadata = await resolveNpmSpecMetadata({
spec: `${parsedSpec.name}@${stableVersion}`,
timeoutMs: params.timeoutMs,
});
return stableMetadata.ok ? { kind: "stable", metadata: stableMetadata.metadata } : undefined;
}
const prereleaseVersion = versions
?.filter(isPrereleaseSemverVersion)
.toSorted(compareNpmSemverForUpdate)
.at(-1);
if (!prereleaseVersion || !versions?.every(isPrereleaseSemverVersion)) {
return undefined;
}
if (prereleaseVersion === params.metadata.version) {
return { kind: "prerelease-only", metadata: params.metadata };
}
const prereleaseMetadata = await resolveNpmSpecMetadata({
spec: `${parsedSpec.name}@${prereleaseVersion}`,
timeoutMs: params.timeoutMs,
});
return prereleaseMetadata.ok
? { kind: "prerelease-only", metadata: prereleaseMetadata.metadata }
: undefined;
}
export async function expectedIntegrityForNpmFallback(params: {
fallbackSpec: string | undefined;
record: PluginInstallRecord;
timeoutMs?: number;
trustedSourceLinkedOfficialInstall: boolean;
}): Promise<string | undefined> {
if (params.record.source !== "npm" || !params.fallbackSpec) {
return undefined;
}
if (params.fallbackSpec === params.record.spec) {
return expectedIntegrityForUpdate(params.record.spec, params.record.integrity);
}
if (!params.trustedSourceLinkedOfficialInstall) {
return undefined;
}
const fallbackMetadata = await resolveNpmSpecMetadata({
spec: params.fallbackSpec,
timeoutMs: params.timeoutMs,
});
if (!fallbackMetadata.ok) {
return undefined;
}
const trustedPrereleaseFallback = await resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate(
{
metadata: fallbackMetadata.metadata,
spec: params.fallbackSpec,
timeoutMs: params.timeoutMs,
},
);
const expectedIntegrityMetadata =
trustedPrereleaseFallback?.metadata ?? fallbackMetadata.metadata;
if (!isNpmMetadataCompatibleWithCurrentHost(expectedIntegrityMetadata)) {
return undefined;
}
return expectedIntegrityForNpmUpdate({
effectiveSpec: params.fallbackSpec,
metadata: expectedIntegrityMetadata,
record: params.record,
trustedSourceLinkedOfficialInstall: true,
});
}
export function isNpmMetadataCompatibleWithCurrentHost(metadata: NpmSpecResolution): boolean {
const hostVersion = resolveCompatibilityHostVersion();
const installMetadata = metadata.packageOpenClaw?.install;
const minHostVersionCheck = checkMinHostVersion({
currentVersion: hostVersion,
minHostVersion: isRecord(installMetadata) ? installMetadata.minHostVersion : undefined,
});
if (!minHostVersionCheck.ok) {
return false;
}
const pluginApiRangeCheck = resolvePackagePluginApiRange(metadata.packageOpenClaw);
if (!pluginApiRangeCheck.ok) {
return false;
}
const pluginApiRange = pluginApiRangeCheck.range;
if (!pluginApiRange) {
return true;
}
return satisfiesPluginApiRange(hostVersion, pluginApiRange);
}
export function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean {
const releaseCmp = compareOpenClawReleaseVersions(bundledVersion, installedVersion);
if (releaseCmp !== null) {
return releaseCmp > 0;
}
return (compareValidSemver(bundledVersion, installedVersion) ?? 0) > 0;
}
function shouldFallbackClawHubToDefault(result: { ok: false; code?: string }): boolean {
return (
result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
);
}
export function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): boolean {
return shouldFallbackClawHubToDefault(result);
}
function isUnavailableNpmTarget(result: { ok: false; code?: string; error: string }): boolean {
return (
result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND ||
/\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(result.error)
);
}
export function describeBetaNpmFallback(params: {
pluginId: string;
betaSpec: string | undefined;
fallbackSpec: string;
result: { ok: false; code?: string; error: string };
}): string {
const betaSpec = params.betaSpec ?? "the beta npm release";
const missingBeta = isUnavailableNpmTarget(params.result);
const reason = missingBeta ? "has no beta npm release" : "failed beta npm update";
return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; using ${params.fallbackSpec} instead. Core update can still complete.`;
}
function formatNpmSpecSelectorLabel(spec: string | undefined): string {
const parsed = spec ? parseRegistryNpmSpec(spec) : undefined;
if (!parsed) {
return spec ?? "unknown";
}
if (parsed.selectorKind === "none") {
return "@latest";
}
return `@${parsed.selector}`;
}
export function describeNpmChannelFallback(params: {
pluginId: string;
requestedSpec: string | undefined;
usedSpec: string;
result: { ok: false; code?: string; error: string };
verb: "used" | "would use";
}): PluginUpdateChannelFallback {
const requestedSpec = params.requestedSpec ?? "unknown";
const requestedLabel = formatNpmSpecSelectorLabel(params.requestedSpec);
const usedLabel = formatNpmSpecSelectorLabel(params.usedSpec);
const reason = isUnavailableNpmTarget(params.result) ? "unavailable" : "failed";
const message =
reason === "unavailable"
? `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} because ${requestedLabel} was unavailable`
: `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} after ${requestedLabel} failed`;
return {
requestedSpec,
usedSpec: params.usedSpec,
requestedLabel,
usedLabel,
reason,
message,
};
}
export function formatBetaChannelFallbackOutcomeSuffix(params: {
fallbackLabel: string | undefined;
fallbackSpec: string | undefined;
verb: "used" | "would use";
}): string {
if (!params.fallbackSpec) {
return "";
}
const betaTarget = params.fallbackLabel ?? "beta target";
return ` (warning: beta channel fallback ${params.verb} ${params.fallbackSpec} because ${betaTarget} could not be used).`;
}
export function npmUpdateFailureSpec(params: {
effectiveSpec: string | undefined;
fallbackSpec: string | undefined;
usedFallback: boolean;
}): string {
if (params.usedFallback && params.fallbackSpec) {
return params.fallbackSpec;
}
return params.effectiveSpec ?? params.fallbackSpec ?? "unknown";
}
export function resolveNpmSpecPackageName(spec: string | undefined): string | undefined {
return spec ? parseRegistryNpmSpec(spec)?.name : undefined;
}
export function resolveExactNpmSpecVersion(spec: string | undefined): string | undefined {
const parsed = spec ? parseRegistryNpmSpec(spec) : null;
return parsed?.selectorKind === "exact-version"
? normalizeExactNpmVersion(parsed.selector)
: undefined;
}
function normalizeExactNpmVersion(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!isExactSemverVersion(trimmed)) {
return undefined;
}
return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
}
export function resolveNpmResultVersion(result: {
npmResolution?: NpmSpecResolution;
}): string | undefined {
return result.npmResolution?.version;
}
function resolveClawHubSpecPackageName(spec: string | undefined): string | undefined {
return spec ? parseClawHubPluginSpec(spec)?.name : undefined;
}
function isOfficialClawHubInstallRecord(record: PluginInstallRecord): boolean {
if (record.source !== "clawhub" || record.clawhubChannel !== "official") {
return false;
}
return (record.clawhubUrl ?? "").replace(/\/+$/, "") === "https://clawhub.ai";
}
export function resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate(params: {
pluginId: string;
record: PluginInstallRecord;
effectiveClawHubSpec?: string;
recordClawHubSpec?: string;
updateChannel?: UpdateChannel;
coreVersion?: string;
}): {
installSpec: string;
recordSpec: string;
fallbackSpec?: string;
fallbackLabel?: string;
} | null {
if (!isOfficialClawHubInstallRecord(params.record)) {
return null;
}
const entry = getOfficialExternalPluginCatalogEntry(params.pluginId);
if (!entry) {
return null;
}
const officialSpec = resolveOfficialExternalPluginInstall(entry)?.npmSpec;
const officialPackageName = resolveNpmSpecPackageName(officialSpec);
if (!officialSpec || !officialPackageName) {
return null;
}
const recordedPackageNames = [
params.record.clawhubPackage,
resolveClawHubSpecPackageName(params.record.spec),
resolveClawHubSpecPackageName(params.effectiveClawHubSpec),
].filter((value): value is string => Boolean(value));
if (!recordedPackageNames.includes(officialPackageName)) {
return null;
}
const effectiveClawHubVersion = params.effectiveClawHubSpec
? parseClawHubPluginSpec(params.effectiveClawHubSpec)?.version
: undefined;
const recordClawHubVersion = params.recordClawHubSpec
? parseClawHubPluginSpec(params.recordClawHubSpec)?.version
: undefined;
if (effectiveClawHubVersion && effectiveClawHubVersion.toLowerCase() !== "latest") {
return {
installSpec: `${officialPackageName}@${effectiveClawHubVersion}`,
recordSpec:
recordClawHubVersion && recordClawHubVersion.toLowerCase() !== "latest"
? `${officialPackageName}@${recordClawHubVersion}`
: officialSpec,
...(params.updateChannel === "beta" && effectiveClawHubVersion.toLowerCase() === "beta"
? { fallbackSpec: officialSpec, fallbackLabel: `${officialPackageName}@beta` }
: {}),
};
}
return resolveNpmInstallSpecsForUpdateChannel({
spec: officialSpec,
updateChannel: params.updateChannel,
officialPackageName,
coreVersion: params.coreVersion,
});
}
export function isTrustedSourceLinkedOfficialNpmUpdate(params: {
pluginId: string;
spec: string | undefined;
record: PluginInstallRecord;
}): boolean {
const officialSpec = resolveTrustedSourceLinkedOfficialNpmSpec(params);
const officialPackageName = resolveNpmSpecPackageName(officialSpec);
const requestedPackageName = resolveNpmSpecPackageName(params.spec);
return Boolean(officialPackageName && requestedPackageName === officialPackageName);
}
export function isTrustedSourceLinkedOfficialBridgeNpmInstall(params: {
targetPluginId: string;
npmSpec: string | undefined;
}): boolean {
const entry = getOfficialExternalPluginCatalogEntry(params.targetPluginId);
if (!entry) {
return false;
}
const officialPackageName = resolveNpmSpecPackageName(
resolveOfficialExternalPluginInstall(entry)?.npmSpec,
);
const requestedPackageName = resolveNpmSpecPackageName(params.npmSpec);
return Boolean(officialPackageName && requestedPackageName === officialPackageName);
}
function isBridgeNpmInstall(params: {
bridge: ExternalizedBundledPluginBridge;
record: PluginInstallRecord;
}): boolean {
const npmSpec = getExternalizedBundledPluginNpmSpec(params.bridge);
if (!npmSpec || params.record.source !== "npm") {
return false;
}
const bridgePackageName = resolveNpmSpecPackageName(npmSpec);
const recordPackageName =
params.record.resolvedName ??
resolveNpmSpecPackageName(params.record.spec) ??
resolveNpmSpecPackageName(params.record.resolvedSpec);
return Boolean(bridgePackageName && recordPackageName === bridgePackageName);
}
function isBridgeClawHubInstall(params: {
bridge: ExternalizedBundledPluginBridge;
record: PluginInstallRecord;
}): boolean {
if (params.record.source !== "clawhub") {
return false;
}
const clawhubSpec = getExternalizedBundledPluginClawHubSpec(params.bridge);
const bridgeClawHubPackage = clawhubSpec ? parseClawHubPluginSpec(clawhubSpec)?.name : undefined;
const recordClawHubPackage =
params.record.clawhubPackage ?? parseClawHubPluginSpec(params.record.spec ?? "")?.name;
return Boolean(bridgeClawHubPackage && recordClawHubPackage === bridgeClawHubPackage);
}
export function resolveNpmUpdateSpecs(params: {
record: PluginInstallRecord;
specOverride?: string;
officialSpecOverride?: string;
updateChannel?: UpdateChannel;
officialPackageName?: string;
coreVersion?: string;
}): {
installSpec?: string;
recordSpec?: string;
fallbackSpec?: string;
fallbackLabel?: string;
} {
const recordSpec =
params.specOverride ??
(params.updateChannel === "extended-stable" && params.record.spec
? params.record.spec
: (params.officialSpecOverride ?? params.record.spec));
if (!recordSpec) {
return {};
}
if (params.specOverride) {
return resolveNpmInstallSpecsForUpdateChannel({
spec: recordSpec,
updateChannel: params.updateChannel,
officialPackageName: params.officialPackageName,
coreVersion: params.coreVersion,
});
}
return resolveNpmInstallSpecsForUpdateChannel({
spec: recordSpec,
updateChannel: params.updateChannel,
officialPackageName: params.officialPackageName,
coreVersion: params.coreVersion,
});
}
export function resolveClawHubUpdateSpecs(params: {
record: PluginInstallRecord;
officialSpecOverride?: string;
updateChannel?: UpdateChannel;
}): {
installSpec?: string;
recordSpec?: string;
fallbackSpec?: string;
fallbackLabel?: string;
} {
if (!params.officialSpecOverride && !params.record.clawhubPackage) {
return {};
}
const recordSpec =
params.officialSpecOverride ?? params.record.spec ?? `clawhub:${params.record.clawhubPackage}`;
return resolveClawHubInstallSpecsForUpdateChannel({
spec: recordSpec,
updateChannel: params.updateChannel,
});
}
export function isBridgeAlreadyInstalledFromPreferredSource(params: {
bridge: ExternalizedBundledPluginBridge;
record: PluginInstallRecord;
}): boolean {
const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge);
return preferredSource === "clawhub"
? isBridgeClawHubInstall(params)
: isBridgeNpmInstall(params);
}
export function isBridgeInstalledFromFallbackSource(params: {
bridge: ExternalizedBundledPluginBridge;
record: PluginInstallRecord;
}): boolean {
const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge);
return preferredSource === "clawhub"
? isBridgeNpmInstall(params)
: isBridgeClawHubInstall(params);
}