Files
openclaw/src/plugins/update.ts
Vincent Koc 7c0f5463a5 fix(update): isolate plugin sync failures
Disable and skip plugins that fail package-update plugin sync so broken plugin packages do not fail an otherwise successful OpenClaw update.
2026-05-04 14:06:44 -07:00

1719 lines
53 KiB
TypeScript

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 {
compareOpenClawReleaseVersions,
isPrereleaseResolutionAllowed,
parseRegistryNpmSpec,
} from "../infra/npm-registry-spec.js";
import {
expectedIntegrityForUpdate,
readInstalledPackageVersion,
} from "../infra/package-update-utils.js";
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
import type { UpdateChannel } from "../infra/update-channels.js";
import { resolveUserPath } from "../utils.js";
import { resolveBundledPluginSources } from "./bundled-sources.js";
import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import {
getExternalizedBundledPluginLegacyPathSuffix,
getExternalizedBundledPluginClawHubSpec,
getExternalizedBundledPluginLookupIds,
getExternalizedBundledPluginNpmSpec,
getExternalizedBundledPluginPreferredSource,
getExternalizedBundledPluginTargetId,
type ExternalizedBundledPluginBridge,
} from "./externalized-bundled-plugins.js";
import { installPluginFromGitSpec } from "./git-install.js";
import {
installPluginFromNpmSpec,
PLUGIN_INSTALL_ERROR_CODE,
resolvePluginInstallDir,
} from "./install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
import { installPluginFromMarketplace } from "./marketplace.js";
import {
getOfficialExternalPluginCatalogEntry,
resolveOfficialExternalPluginInstall,
} from "./official-external-plugin-catalog.js";
export type PluginUpdateLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
error?: (message: string) => void;
};
export type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error";
export type PluginUpdateOutcome = {
pluginId: string;
status: PluginUpdateStatus;
message: string;
currentVersion?: string;
nextVersion?: 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 PluginChannelSyncSummary = {
switchedToBundled: string[];
switchedToClawHub: string[];
switchedToNpm: string[];
warnings: string[];
errors: string[];
};
export type PluginChannelSyncResult = {
config: OpenClawConfig;
changed: boolean;
summary: PluginChannelSyncSummary;
};
function formatNpmInstallFailure(params: {
pluginId: string;
spec: string;
phase: "check" | "update";
result: { error: string; code?: string };
}): string {
if (params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) {
return `Failed to ${params.phase} ${params.pluginId}: npm package not found for ${params.spec}.`;
}
return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`;
}
function formatMarketplaceInstallFailure(params: {
pluginId: string;
marketplaceSource: string;
marketplacePlugin: string;
phase: "check" | "update";
error: string;
}): string {
return (
`Failed to ${params.phase} ${params.pluginId}: ` +
`${params.error} (marketplace plugin ${params.marketplacePlugin} from ${params.marketplaceSource}).`
);
}
function formatClawHubInstallFailure(params: {
pluginId: string;
spec: string;
phase: "check" | "update";
error: string;
}): string {
return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`;
}
function formatGitInstallFailure(params: {
pluginId: string;
spec: string;
phase: "check" | "update";
error: string;
}): string {
return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (git ${params.spec}).`;
}
type InstallIntegrityDrift = {
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolution: {
resolvedSpec?: string;
version?: string;
};
};
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
);
}
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,
}),
);
}
function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean {
const releaseCmp = compareOpenClawReleaseVersions(bundledVersion, installedVersion);
if (releaseCmp !== null) {
return releaseCmp > 0;
}
const bundled = parseComparableSemver(bundledVersion);
const installed = parseComparableSemver(installedVersion);
const cmp = compareComparableSemver(bundled, installed);
return cmp !== null && cmp > 0;
}
function pathsEqual(
left: string | undefined,
right: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (!left || !right) {
return false;
}
return resolveUserPath(left, env) === resolveUserPath(right, env);
}
function resolveRecordedExtensionsDir(params: {
pluginId: string;
installPath: string;
}): string | undefined {
const parentDir = path.dirname(params.installPath);
try {
const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir);
return canonicalInstallPath === params.installPath ? parentDir : undefined;
} catch {
return undefined;
}
}
function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = process.env) {
let paths = [...existing];
const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry, env)));
let resolved = resolveSet();
let changed = false;
const addPath = (value: string) => {
const normalized = resolveUserPath(value, env);
if (resolved.has(normalized)) {
return;
}
paths.push(value);
resolved.add(normalized);
changed = true;
};
const removePath = (value: string) => {
const normalized = resolveUserPath(value, env);
if (!resolved.has(normalized)) {
return;
}
paths = paths.filter((entry) => resolveUserPath(entry, env) !== normalized);
resolved = resolveSet();
changed = true;
};
const removeMatching = (predicate: (value: string) => boolean) => {
const next = paths.filter((entry) => !predicate(entry));
if (next.length === paths.length) {
return;
}
paths = next;
resolved = resolveSet();
changed = true;
};
return {
addPath,
removePath,
removeMatching,
get changed() {
return changed;
},
get paths() {
return paths;
},
};
}
function normalizePathSegment(value: string | undefined): string {
return (
value
?.trim()
.replaceAll("\\", "/")
.replace(/^\/+|\/+$/g, "") ?? ""
);
}
function pathEndsWithSegment(params: {
value: string | undefined;
segment: string | undefined;
env: NodeJS.ProcessEnv;
}): boolean {
const value = normalizePathSegment(params.value ? resolveUserPath(params.value, params.env) : "");
const segment = normalizePathSegment(params.segment);
return Boolean(value && segment && (value === segment || value.endsWith(`/${segment}`)));
}
function isBridgeBundledPathRecord(params: {
bridge: ExternalizedBundledPluginBridge;
bundledLocalPath?: string;
record: PluginInstallRecord;
env: NodeJS.ProcessEnv;
}): boolean {
if (params.record.source !== "path") {
return false;
}
if (
params.bundledLocalPath &&
(pathsEqual(params.record.sourcePath, params.bundledLocalPath, params.env) ||
pathsEqual(params.record.installPath, params.bundledLocalPath, params.env))
) {
return true;
}
const bundledPathSuffix = getExternalizedBundledPluginLegacyPathSuffix(params.bridge);
return (
pathEndsWithSegment({
value: params.record.sourcePath,
segment: bundledPathSuffix,
env: params.env,
}) ||
pathEndsWithSegment({
value: params.record.installPath,
segment: bundledPathSuffix,
env: params.env,
})
);
}
function removeBridgeBundledLoadPaths(params: {
bridge: ExternalizedBundledPluginBridge;
loadPaths: ReturnType<typeof buildLoadPathHelpers>;
env: NodeJS.ProcessEnv;
}) {
const bundledPathSuffix = getExternalizedBundledPluginLegacyPathSuffix(params.bridge);
params.loadPaths.removeMatching((entry) =>
pathEndsWithSegment({
value: entry,
segment: bundledPathSuffix,
env: params.env,
}),
);
}
function resolveBridgeInstallRecord(params: {
installs: Record<string, PluginInstallRecord>;
bridge: ExternalizedBundledPluginBridge;
}): { pluginId: string; record: PluginInstallRecord } | undefined {
for (const pluginId of getExternalizedBundledPluginLookupIds(params.bridge)) {
const record = params.installs[pluginId];
if (record) {
return { pluginId, record };
}
}
return undefined;
}
function isBridgeChannelEnabledByConfig(params: {
config: OpenClawConfig;
bridge: ExternalizedBundledPluginBridge;
}): boolean {
const channels = params.config.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return false;
}
for (const channelId of params.bridge.channelIds ?? []) {
const entry = (channels as Record<string, unknown>)[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
if (Object.is((entry as Record<string, unknown>).enabled, true)) {
return true;
}
}
return false;
}
function isExternalizedBundledPluginEnabled(params: {
config: OpenClawConfig;
bridge: ExternalizedBundledPluginBridge;
}): boolean {
const normalized = normalizePluginsConfig(params.config.plugins);
if (!normalized.enabled) {
return false;
}
const pluginIds = getExternalizedBundledPluginLookupIds(params.bridge);
if (
pluginIds.some(
(pluginId) =>
normalized.deny.includes(pluginId) ||
Object.is(normalized.entries[pluginId]?.enabled, false),
)
) {
return false;
}
for (const pluginId of pluginIds) {
if (
resolveEffectiveEnableState({
id: pluginId,
origin: "bundled",
config: normalized,
rootConfig: params.config,
enabledByDefault: params.bridge.enabledByDefault,
}).enabled
) {
return true;
}
}
if (isBridgeChannelEnabledByConfig(params)) {
return true;
}
return false;
}
function shouldFallbackClawHubBridgeToNpm(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
);
}
function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): boolean {
return shouldFallbackClawHubBridgeToNpm(result);
}
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 =
params.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(
params.result.error,
);
const reason = missingBeta ? "has no beta npm release" : "failed beta npm update";
return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; falling back to ${params.fallbackSpec}.`;
}
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";
}
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;
}
function isTrustedSourceLinkedOfficialNpmUpdate(params: {
pluginId: string;
spec: string | undefined;
record: PluginInstallRecord;
}): boolean {
if (params.record.source !== "npm") {
return false;
}
const entry = getOfficialExternalPluginCatalogEntry(params.pluginId);
if (!entry) {
return false;
}
const officialPackageName = resolveNpmSpecPackageName(
resolveOfficialExternalPluginInstall(entry)?.npmSpec,
);
const requestedPackageName = resolveNpmSpecPackageName(params.spec);
if (!officialPackageName || requestedPackageName !== officialPackageName) {
return false;
}
const recordedPackageNames = [
params.record.resolvedName,
resolveNpmSpecPackageName(params.record.spec),
resolveNpmSpecPackageName(params.record.resolvedSpec),
].filter((value): value is string => Boolean(value));
return recordedPackageNames.includes(officialPackageName);
}
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);
}
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;
}): boolean {
const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge);
return preferredSource === "clawhub"
? isBridgeClawHubInstall(params)
: isBridgeNpmInstall(params);
}
function isBridgeInstalledFromFallbackSource(params: {
bridge: ExternalizedBundledPluginBridge;
record: PluginInstallRecord;
}): boolean {
const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge);
return preferredSource === "clawhub"
? isBridgeNpmInstall(params)
: isBridgeClawHubInstall(params);
}
function replacePluginIdInList(
entries: string[] | undefined,
fromId: string,
toId: string,
): string[] | undefined {
if (!entries || entries.length === 0 || fromId === toId) {
return entries;
}
const next: string[] = [];
for (const entry of entries) {
const value = entry === fromId ? toId : entry;
if (!next.includes(value)) {
next.push(value);
}
}
return next;
}
function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string): OpenClawConfig {
if (fromId === toId) {
return cfg;
}
const installs = cfg.plugins?.installs;
const entries = cfg.plugins?.entries;
const slots = cfg.plugins?.slots;
const allow = replacePluginIdInList(cfg.plugins?.allow, fromId, toId);
const deny = replacePluginIdInList(cfg.plugins?.deny, fromId, toId);
const nextInstalls = installs ? { ...installs } : undefined;
if (nextInstalls && fromId in nextInstalls) {
const record = nextInstalls[fromId];
if (record && !(toId in nextInstalls)) {
nextInstalls[toId] = record;
}
delete nextInstalls[fromId];
}
const nextEntries = entries ? { ...entries } : undefined;
if (nextEntries && fromId in nextEntries) {
const entry = nextEntries[fromId];
if (entry) {
nextEntries[toId] = nextEntries[toId]
? {
...entry,
...nextEntries[toId],
}
: entry;
}
delete nextEntries[fromId];
}
const nextSlots = slots
? {
...slots,
...(slots.memory === fromId ? { memory: toId } : {}),
...(slots.contextEngine === fromId ? { contextEngine: toId } : {}),
}
: undefined;
return {
...cfg,
plugins: {
...cfg.plugins,
allow,
deny,
entries: nextEntries,
installs: nextInstalls,
slots: nextSlots,
},
};
}
function createPluginUpdateIntegrityDriftHandler(params: {
pluginId: string;
dryRun: boolean;
logger: PluginUpdateLogger;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}) {
return async (drift: InstallIntegrityDrift) => {
const payload: PluginUpdateIntegrityDriftParams = {
pluginId: params.pluginId,
spec: drift.spec,
expectedIntegrity: drift.expectedIntegrity,
actualIntegrity: drift.actualIntegrity,
resolvedSpec: drift.resolution.resolvedSpec,
resolvedVersion: drift.resolution.version,
dryRun: params.dryRun,
};
if (params.onIntegrityDrift) {
return await params.onIntegrityDrift(payload);
}
params.logger.warn?.(
`Integrity drift for "${params.pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`,
);
return false;
};
}
function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): OpenClawConfig {
const existingEntry = config.plugins?.entries?.[pluginId];
return {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[pluginId]: {
...existingEntry,
enabled: false,
},
},
},
};
}
export async function updateNpmInstalledPlugins(params: {
config: OpenClawConfig;
logger?: PluginUpdateLogger;
pluginIds?: string[];
skipIds?: Set<string>;
skipDisabledPlugins?: boolean;
disableOnFailure?: boolean;
timeoutMs?: number;
dryRun?: boolean;
updateChannel?: UpdateChannel;
dangerouslyForceUnsafeInstall?: boolean;
specOverrides?: Record<string, string>;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> {
const logger = params.logger ?? {};
const installs = params.config.plugins?.installs ?? {};
const targets = params.pluginIds?.length ? params.pluginIds : Object.keys(installs);
const normalizedPluginConfig = params.skipDisabledPlugins
? normalizePluginsConfig(params.config.plugins)
: undefined;
const bundled = resolveBundledPluginSources({});
const outcomes: PluginUpdateOutcome[] = [];
let next = params.config;
let changed = false;
const recordFailure = (pluginId: string, message: string) => {
if (params.disableOnFailure && !params.dryRun) {
const disabledMessage =
`Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` +
message;
logger.warn?.(disabledMessage);
next = disablePluginConfigEntry(next, pluginId);
changed = true;
outcomes.push({
pluginId,
status: "skipped",
message: disabledMessage,
});
return;
}
outcomes.push({
pluginId,
status: "error",
message,
});
};
for (const pluginId of targets) {
if (params.skipIds?.has(pluginId)) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (already updated).`,
});
continue;
}
const record = installs[pluginId];
if (!record) {
outcomes.push({
pluginId,
status: "skipped",
message: `No install record for "${pluginId}".`,
});
continue;
}
if (normalizedPluginConfig) {
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: "global",
config: normalizedPluginConfig,
rootConfig: params.config,
});
if (!enableState.enabled) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (${enableState.reason ?? "disabled by plugin config"}).`,
});
continue;
}
}
if (
record.source !== "npm" &&
record.source !== "marketplace" &&
record.source !== "clawhub" &&
record.source !== "git"
) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (source: ${record.source}).`,
});
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"
? 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;
const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialNpmUpdate({
pluginId,
spec: effectiveSpec,
record,
});
if (record.source === "npm" && !effectiveSpec) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (missing npm spec).`,
});
continue;
}
if (record.source === "git" && !effectiveSpec) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (missing git spec).`,
});
continue;
}
if (record.source === "clawhub" && !record.clawhubPackage) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (missing ClawHub package metadata).`,
});
continue;
}
if (record.source === "clawhub" || record.source === "marketplace") {
const bundledSource = bundled.get(pluginId);
if (
bundledSource?.version &&
record.version &&
isBundledVersionNewer(bundledSource.version, record.version)
) {
logger.warn?.(
`Skipping "${pluginId}" update: bundled version ${bundledSource.version} is newer than the installed ${record.source} version ${record.version}. ` +
`Uninstall the ${record.source} plugin to use the bundled version, or pin a newer version explicitly.`,
);
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}": bundled version ${bundledSource.version} is newer than ${record.source} version ${record.version}.`,
});
continue;
}
}
if (
record.source === "marketplace" &&
(!record.marketplaceSource || !record.marketplacePlugin)
) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (missing marketplace source metadata).`,
});
continue;
}
let installPath: string;
try {
installPath = resolveUserPath(
record.installPath?.trim() || resolvePluginInstallDir(pluginId),
);
} catch (err) {
recordFailure(pluginId, `Invalid install path for "${pluginId}": ${String(err)}`);
continue;
}
const currentVersion = await readInstalledPackageVersion(installPath);
const extensionsDir = resolveRecordedExtensionsDir({
pluginId,
installPath,
});
if (!params.dryRun && record.source === "npm" && currentVersion) {
const metadataResult = await resolveNpmSpecMetadata({
spec: effectiveSpec!,
timeoutMs: params.timeoutMs,
});
if (metadataResult.ok) {
if (
!shouldBypassTrustedOfficialUnchangedNpmCheck({
metadata: metadataResult.metadata,
spec: effectiveSpec!,
trustedSourceLinkedOfficialInstall,
}) &&
shouldSkipUnchangedNpmInstall({
currentVersion,
record,
metadata: metadataResult.metadata,
})
) {
outcomes.push({
pluginId,
status: "unchanged",
currentVersion,
nextVersion: metadataResult.metadata.version,
message: `${pluginId} is up to date (${currentVersion}).`,
});
continue;
}
} else {
logger.warn?.(
`Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`,
);
}
}
if (params.dryRun) {
let probe:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>
| Awaited<ReturnType<typeof installPluginFromGitSpec>>
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
try {
probe =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: effectiveSpec!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
trustedSourceLinkedOfficialInstall,
expectedPluginId: pluginId,
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: true,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
})
: record.source === "clawhub"
? await installPluginFromClawHub({
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
baseUrl: record.clawhubUrl,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
: record.source === "git"
? await installPluginFromGitSpec({
spec: effectiveSpec!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
});
} catch (err) {
recordFailure(pluginId, `Failed to check ${pluginId}: ${String(err)}`);
continue;
}
let usedNpmFallback = false;
if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
logger.warn?.(
describeBetaNpmFallback({
pluginId,
betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
fallbackSpec: npmSpecs.fallbackSpec,
result: probe,
}),
);
usedNpmFallback = true;
probe = await installPluginFromNpmSpec({
spec: npmSpecs.fallbackSpec,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
trustedSourceLinkedOfficialInstall,
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) {
recordFailure(
pluginId,
record.source === "npm"
? formatNpmInstallFailure({
pluginId,
spec: npmUpdateFailureSpec({
effectiveSpec,
fallbackSpec: npmSpecs?.fallbackSpec,
usedFallback: usedNpmFallback,
}),
phase: "check",
result: probe,
})
: record.source === "clawhub"
? formatClawHubInstallFailure({
pluginId,
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
phase: "check",
error: probe.error,
})
: record.source === "git"
? formatGitInstallFailure({
pluginId,
spec: effectiveSpec!,
phase: "check",
error: probe.error,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "check",
error: probe.error,
}),
);
continue;
}
const nextVersion = probe.version ?? "unknown";
const currentLabel = currentVersion ?? "unknown";
const gitProbe =
record.source === "git"
? (probe as Extract<Awaited<ReturnType<typeof installPluginFromGitSpec>>, { ok: true }>)
.git
: undefined;
const unchanged =
record.source === "git" && record.gitCommit && gitProbe?.commit
? record.gitCommit === gitProbe.commit
: Boolean(currentVersion && probe.version && currentVersion === probe.version);
if (unchanged) {
outcomes.push({
pluginId,
status: "unchanged",
currentVersion: currentVersion ?? undefined,
nextVersion: probe.version ?? undefined,
message: `${pluginId} is up to date (${currentLabel}).`,
});
} else {
outcomes.push({
pluginId,
status: "updated",
currentVersion: currentVersion ?? undefined,
nextVersion: probe.version ?? undefined,
message: `Would update ${pluginId}: ${currentLabel} -> ${nextVersion}.`,
});
}
continue;
}
let result:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>
| Awaited<ReturnType<typeof installPluginFromGitSpec>>
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
try {
result =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: effectiveSpec!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
trustedSourceLinkedOfficialInstall,
expectedPluginId: pluginId,
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: false,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
})
: record.source === "clawhub"
? await installPluginFromClawHub({
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
baseUrl: record.clawhubUrl,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
: record.source === "git"
? await installPluginFromGitSpec({
spec: effectiveSpec!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
});
} catch (err) {
recordFailure(pluginId, `Failed to update ${pluginId}: ${String(err)}`);
continue;
}
let usedNpmFallback = false;
if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
logger.warn?.(
describeBetaNpmFallback({
pluginId,
betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
fallbackSpec: npmSpecs.fallbackSpec,
result,
}),
);
usedNpmFallback = true;
result = await installPluginFromNpmSpec({
spec: npmSpecs.fallbackSpec,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
trustedSourceLinkedOfficialInstall,
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) {
recordFailure(
pluginId,
record.source === "npm"
? formatNpmInstallFailure({
pluginId,
spec: npmUpdateFailureSpec({
effectiveSpec,
fallbackSpec: npmSpecs?.fallbackSpec,
usedFallback: usedNpmFallback,
}),
phase: "update",
result: result,
})
: record.source === "clawhub"
? formatClawHubInstallFailure({
pluginId,
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
phase: "update",
error: result.error,
})
: record.source === "git"
? formatGitInstallFailure({
pluginId,
spec: effectiveSpec!,
phase: "update",
error: result.error,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "update",
error: result.error,
}),
);
continue;
}
const resolvedPluginId = result.pluginId;
if (resolvedPluginId !== pluginId) {
next = migratePluginConfigId(next, pluginId, resolvedPluginId);
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
if (record.source === "npm") {
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "npm",
spec: recordSpec,
installPath: result.targetDir,
version: nextVersion,
...buildNpmResolutionInstallFields(result.npmResolution),
});
} else if (record.source === "clawhub") {
const clawhubResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromClawHub>>,
{ ok: true }
>;
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub),
spec: recordSpec ?? record.spec ?? `clawhub:${record.clawhubPackage!}`,
installPath: result.targetDir,
version: nextVersion,
});
} else if (record.source === "git") {
const gitResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromGitSpec>>,
{ ok: true }
>;
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "git",
spec: effectiveSpec ?? record.spec,
installPath: result.targetDir,
version: nextVersion,
resolvedAt: gitResult.git.resolvedAt,
gitUrl: gitResult.git.url,
gitRef: gitResult.git.ref,
gitCommit: gitResult.git.commit,
});
} else {
const marketplaceResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromMarketplace>>,
{ ok: true }
>;
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "marketplace",
installPath: result.targetDir,
version: nextVersion,
marketplaceName: marketplaceResult.marketplaceName ?? record.marketplaceName,
marketplaceSource: record.marketplaceSource,
marketplacePlugin: record.marketplacePlugin,
});
}
changed = true;
const currentLabel = currentVersion ?? "unknown";
const nextLabel = nextVersion ?? "unknown";
if (currentVersion && nextVersion && currentVersion === nextVersion) {
outcomes.push({
pluginId,
status: "unchanged",
currentVersion: currentVersion ?? undefined,
nextVersion: nextVersion ?? undefined,
message: `${pluginId} already at ${currentLabel}.`,
});
} else {
outcomes.push({
pluginId,
status: "updated",
currentVersion: currentVersion ?? undefined,
nextVersion: nextVersion ?? undefined,
message: `Updated ${pluginId}: ${currentLabel} -> ${nextLabel}.`,
});
}
}
return { config: next, changed, outcomes };
}
export async function syncPluginsForUpdateChannel(params: {
config: OpenClawConfig;
channel: UpdateChannel;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: PluginUpdateLogger;
externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[];
}): Promise<PluginChannelSyncResult> {
const env = params.env ?? process.env;
const logger = params.logger ?? {};
const summary: PluginChannelSyncSummary = {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
warnings: [],
errors: [],
};
const bundled = resolveBundledPluginSources({
workspaceDir: params.workspaceDir,
env,
});
let next = params.config;
const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env);
let installs = next.plugins?.installs ?? {};
let changed = false;
if (params.channel === "dev") {
for (const [pluginId, record] of Object.entries(installs)) {
const bundledInfo = bundled.get(pluginId);
if (!bundledInfo) {
continue;
}
loadHelpers.addPath(bundledInfo.localPath);
const alreadyBundled =
record.source === "path" && pathsEqual(record.sourcePath, bundledInfo.localPath, env);
if (alreadyBundled) {
continue;
}
next = recordPluginInstall(next, {
pluginId,
source: "path",
sourcePath: bundledInfo.localPath,
installPath: bundledInfo.localPath,
spec: record.spec ?? bundledInfo.npmSpec,
version: record.version,
});
summary.switchedToBundled.push(pluginId);
changed = true;
}
} else {
const bridges = params.externalizedBundledPluginBridges ?? [];
for (const bridge of bridges) {
const targetPluginId = getExternalizedBundledPluginTargetId(bridge);
const bundledInfo = bundled.get(bridge.bundledPluginId);
if (bundledInfo) {
continue;
}
const existing = resolveBridgeInstallRecord({ installs, bridge });
if (
!existing &&
!isExternalizedBundledPluginEnabled({
config: next,
bridge,
})
) {
continue;
}
if (
existing &&
!isExternalizedBundledPluginEnabled({
config: next,
bridge,
})
) {
continue;
}
if (
existing &&
isBridgeAlreadyInstalledFromPreferredSource({
bridge,
record: existing.record,
})
) {
if (existing.pluginId !== targetPluginId) {
next = migratePluginConfigId(next, existing.pluginId, targetPluginId);
installs = next.plugins?.installs ?? {};
changed = true;
}
removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env });
continue;
}
if (
existing &&
!isBridgeBundledPathRecord({
bridge,
record: existing.record,
env,
}) &&
!isBridgeInstalledFromFallbackSource({
bridge,
record: existing.record,
})
) {
continue;
}
const preferredSource = getExternalizedBundledPluginPreferredSource(bridge);
const npmSpec = getExternalizedBundledPluginNpmSpec(bridge);
const clawhubSpec = getExternalizedBundledPluginClawHubSpec(bridge);
const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialBridgeNpmInstall({
targetPluginId,
npmSpec,
});
let installSource = preferredSource;
let installSpec = preferredSource === "clawhub" ? clawhubSpec : npmSpec;
let result:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>;
if (!installSpec) {
const message = `Failed to update ${targetPluginId}: missing ${preferredSource} install spec for externalized bundled plugin.`;
summary.errors.push(message);
logger.error?.(message);
continue;
}
if (preferredSource === "clawhub") {
result = await installPluginFromClawHub({
spec: clawhubSpec,
...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}),
mode: "update",
expectedPluginId: targetPluginId,
logger,
});
if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm(result)) {
const warning = `ClawHub ${clawhubSpec} unavailable for ${targetPluginId}; falling back to npm ${npmSpec}.`;
summary.warnings.push(warning);
logger.warn?.(warning);
installSource = "npm";
installSpec = npmSpec;
result = await installPluginFromNpmSpec({
spec: npmSpec,
mode: "update",
expectedPluginId: targetPluginId,
trustedSourceLinkedOfficialInstall,
logger,
});
}
} else {
result = await installPluginFromNpmSpec({
spec: npmSpec,
mode: "update",
expectedPluginId: targetPluginId,
trustedSourceLinkedOfficialInstall,
logger,
});
}
if (!result.ok) {
const message =
installSource === "clawhub"
? formatClawHubInstallFailure({
pluginId: targetPluginId,
spec: installSpec,
phase: "update",
error: result.error,
})
: formatNpmInstallFailure({
pluginId: targetPluginId,
spec: installSpec,
phase: "update",
result,
});
summary.errors.push(message);
logger.error?.(message);
continue;
}
const resolvedPluginId = result.pluginId;
if (existing && existing.pluginId !== resolvedPluginId) {
next = migratePluginConfigId(next, existing.pluginId, resolvedPluginId);
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
if (installSource === "clawhub") {
const clawhubResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromClawHub>>,
{ ok: true }
>;
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub),
spec: installSpec,
installPath: result.targetDir,
version: nextVersion,
});
} else {
const npmResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromNpmSpec>>,
{ ok: true }
>;
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "npm",
spec: installSpec,
installPath: result.targetDir,
version: nextVersion,
...buildNpmResolutionInstallFields(npmResult.npmResolution),
});
}
installs = next.plugins?.installs ?? {};
if (existing?.record.sourcePath) {
loadHelpers.removePath(existing.record.sourcePath);
}
if (existing?.record.installPath) {
loadHelpers.removePath(existing.record.installPath);
}
removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env });
if (installSource === "clawhub") {
summary.switchedToClawHub.push(resolvedPluginId);
} else {
summary.switchedToNpm.push(resolvedPluginId);
}
changed = true;
}
for (const [pluginId, record] of Object.entries(installs)) {
const bundledInfo = bundled.get(pluginId);
if (!bundledInfo) {
continue;
}
if (record.source === "npm") {
loadHelpers.removePath(bundledInfo.localPath);
continue;
}
if (record.source !== "path") {
continue;
}
if (!pathsEqual(record.sourcePath, bundledInfo.localPath, env)) {
continue;
}
// Keep explicit bundled installs on release channels. Replacing them with
// npm installs can reintroduce duplicate-id shadowing and packaging drift.
loadHelpers.addPath(bundledInfo.localPath);
const alreadyBundled =
record.source === "path" &&
pathsEqual(record.sourcePath, bundledInfo.localPath, env) &&
pathsEqual(record.installPath, bundledInfo.localPath, env);
if (alreadyBundled) {
continue;
}
next = recordPluginInstall(next, {
pluginId,
source: "path",
sourcePath: bundledInfo.localPath,
installPath: bundledInfo.localPath,
spec: record.spec ?? bundledInfo.npmSpec,
version: record.version,
});
changed = true;
}
}
if (loadHelpers.changed) {
next = {
...next,
plugins: {
...next.plugins,
load: {
...next.plugins?.load,
paths: loadHelpers.paths,
},
},
};
changed = true;
}
return { config: next, changed, summary };
}