From 99fc0fbac1eff73a52842f4bf7a8ac26da139b7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 15:56:48 +0000 Subject: [PATCH] feat: sync plugin updates with update channel --- CHANGELOG.md | 2 + docs/cli/update.md | 1 + docs/install/development-channels.md | 7 + src/cli/plugins-cli.ts | 110 ++------ src/cli/update-cli.test.ts | 36 +++ src/cli/update-cli.ts | 84 ++++++ src/plugins/update.ts | 401 +++++++++++++++++++++++++++ 7 files changed, 550 insertions(+), 91 deletions(-) create mode 100644 src/plugins/update.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 186629b38..e6ca93dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Docs: https://docs.clawd.bot ### Changes - Deps: update workspace + memory-lancedb dependencies. - Repo: remove the Peekaboo git submodule now that the SPM release is used. +- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`. +- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. - Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow. diff --git a/docs/cli/update.md b/docs/cli/update.md index 11823977e..d841e73df 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -64,6 +64,7 @@ High-level: 4. Installs deps (pnpm preferred; npm fallback). 5. Builds + builds the Control UI. 6. Runs `clawdbot doctor` as the final “safe update” check. +7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins. ## `--update` shorthand diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index 1992a113b..41c110b4e 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -40,6 +40,13 @@ This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`). Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one. +## Plugins and channels + +When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sources: + +- `dev` prefers bundled plugins from the git checkout. +- `stable` and `beta` restore npm-installed plugin packages. + ## Tagging best practices - Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-`). diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 1b0683a3f..d9874af2e 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import fsp from "node:fs/promises"; import path from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; @@ -7,15 +6,12 @@ import type { Command } from "commander"; import { loadConfig, writeConfigFile } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveArchiveKind } from "../infra/archive.js"; -import { - installPluginFromNpmSpec, - installPluginFromPath, - resolvePluginInstallDir, -} from "../plugins/install.js"; +import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import type { PluginRecord } from "../plugins/registry.js"; import { buildPluginStatusReport } from "../plugins/status.js"; +import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; @@ -70,16 +66,6 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string { return parts.join("\n"); } -async function readInstalledPackageVersion(dir: string): Promise { - try { - const raw = await fsp.readFile(path.join(dir, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { version?: unknown }; - return typeof parsed.version === "string" ? parsed.version : undefined; - } catch { - return undefined; - } -} - function applySlotSelectionForPlugin( config: ClawdbotConfig, pluginId: string, @@ -438,88 +424,30 @@ export function registerPluginsCli(program: Command) { process.exit(1); } - let nextCfg = cfg; - let updatedCount = 0; + const result = await updateNpmInstalledPlugins({ + config: cfg, + pluginIds: targets, + dryRun: opts.dryRun, + logger: { + info: (msg) => defaultRuntime.log(msg), + warn: (msg) => defaultRuntime.log(chalk.yellow(msg)), + }, + }); - for (const pluginId of targets) { - const record = installs[pluginId]; - if (!record) { - defaultRuntime.log(chalk.yellow(`No install record for "${pluginId}".`)); + for (const outcome of result.outcomes) { + if (outcome.status === "error") { + defaultRuntime.log(chalk.red(outcome.message)); continue; } - if (record.source !== "npm") { - defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`)); + if (outcome.status === "skipped") { + defaultRuntime.log(chalk.yellow(outcome.message)); continue; } - if (!record.spec) { - defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (missing npm spec).`)); - continue; - } - - const installPath = record.installPath ?? resolvePluginInstallDir(pluginId); - const currentVersion = await readInstalledPackageVersion(installPath); - - if (opts.dryRun) { - const probe = await installPluginFromNpmSpec({ - spec: record.spec, - mode: "update", - dryRun: true, - expectedPluginId: pluginId, - logger: { - info: (msg) => defaultRuntime.log(msg), - warn: (msg) => defaultRuntime.log(chalk.yellow(msg)), - }, - }); - if (!probe.ok) { - defaultRuntime.log(chalk.red(`Failed to check ${pluginId}: ${probe.error}`)); - continue; - } - - const nextVersion = probe.version ?? "unknown"; - const currentLabel = currentVersion ?? "unknown"; - if (currentVersion && probe.version && currentVersion === probe.version) { - defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`); - } else { - defaultRuntime.log(`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`); - } - continue; - } - - const result = await installPluginFromNpmSpec({ - spec: record.spec, - mode: "update", - expectedPluginId: pluginId, - logger: { - info: (msg) => defaultRuntime.log(msg), - warn: (msg) => defaultRuntime.log(chalk.yellow(msg)), - }, - }); - if (!result.ok) { - defaultRuntime.log(chalk.red(`Failed to update ${pluginId}: ${result.error}`)); - continue; - } - - const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); - nextCfg = recordPluginInstall(nextCfg, { - pluginId, - source: "npm", - spec: record.spec, - installPath: result.targetDir, - version: nextVersion, - }); - updatedCount += 1; - - const currentLabel = currentVersion ?? "unknown"; - const nextLabel = nextVersion ?? "unknown"; - if (currentVersion && nextVersion && currentVersion === nextVersion) { - defaultRuntime.log(`${pluginId} already at ${currentLabel}.`); - } else { - defaultRuntime.log(`Updated ${pluginId}: ${currentLabel} → ${nextLabel}.`); - } + defaultRuntime.log(outcome.message); } - if (updatedCount > 0) { - await writeConfigFile(nextCfg); + if (!opts.dryRun && result.changed) { + await writeConfigFile(result.config); defaultRuntime.log("Restart the gateway to load plugins."); } }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 2cc17b6fb..3f85153d9 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -38,6 +38,11 @@ vi.mock("../commands/doctor.js", () => ({ vi.mock("./daemon-cli.js", () => ({ runDaemonRestart: vi.fn(), })); +// Mock plugin update helpers +vi.mock("../plugins/update.js", () => ({ + syncPluginsForUpdateChannel: vi.fn(), + updateNpmInstalledPlugins: vi.fn(), +})); // Mock the runtime vi.mock("../runtime.js", () => ({ @@ -74,6 +79,8 @@ describe("update-cli", () => { const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); const { readConfigFileSnapshot } = await import("../config/config.js"); const { checkUpdateStatus, fetchNpmTagVersion } = await import("../infra/update-check.js"); + const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = + await import("../plugins/update.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); vi.mocked(fetchNpmTagVersion).mockResolvedValue({ @@ -105,6 +112,16 @@ describe("update-cli", () => { latestVersion: "1.2.3", }, }); + vi.mocked(syncPluginsForUpdateChannel).mockResolvedValue({ + config: baseSnapshot.config, + changed: false, + summary: { switchedToBundled: [], switchedToNpm: [], warnings: [], errors: [] }, + }); + vi.mocked(updateNpmInstalledPlugins).mockResolvedValue({ + config: baseSnapshot.config, + changed: false, + outcomes: [], + }); setTty(false); setStdoutTty(false); }); @@ -146,6 +163,25 @@ describe("update-cli", () => { expect(defaultRuntime.log).toHaveBeenCalled(); }); + it("updateCommand syncs plugins after a successful update", async () => { + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = + await import("../plugins/update.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }); + + await updateCommand({}); + + expect(syncPluginsForUpdateChannel).toHaveBeenCalled(); + expect(updateNpmInstalledPlugins).toHaveBeenCalled(); + }); + it("updateStatusCommand prints table output", async () => { const { defaultRuntime } = await import("../runtime.js"); const { updateStatusCommand } = await import("./update-cli.js"); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 6268cc866..76d526cc4 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -36,6 +36,7 @@ import { formatUpdateOneLiner, resolveUpdateAvailability, } from "../commands/status.update.js"; +import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js"; export type UpdateCommandOptions = { json?: boolean; @@ -389,6 +390,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { })) ?? process.cwd(); const configSnapshot = await readConfigFileSnapshot(); + let activeConfig = configSnapshot.valid ? configSnapshot.config : null; const storedChannel = configSnapshot.valid ? normalizeUpdateChannel(configSnapshot.config.update?.channel) : null; @@ -459,6 +461,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { }, }; await writeConfigFile(next); + activeConfig = next; if (!opts.json) { defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); } @@ -513,6 +516,87 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } + if (activeConfig) { + const pluginLogger = opts.json + ? {} + : { + info: (msg: string) => defaultRuntime.log(msg), + warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), + error: (msg: string) => defaultRuntime.log(theme.error(msg)), + }; + + if (!opts.json) { + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Updating plugins...")); + } + + const syncResult = await syncPluginsForUpdateChannel({ + config: activeConfig, + channel, + workspaceDir: root, + logger: pluginLogger, + }); + let pluginConfig = syncResult.config; + + const npmResult = await updateNpmInstalledPlugins({ + config: pluginConfig, + skipIds: new Set(syncResult.summary.switchedToNpm), + logger: pluginLogger, + }); + pluginConfig = npmResult.config; + + if (syncResult.changed || npmResult.changed) { + await writeConfigFile(pluginConfig); + } + + if (!opts.json) { + const summarizeList = (list: string[]) => { + if (list.length <= 6) return list.join(", "); + return `${list.slice(0, 6).join(", ")} +${list.length - 6} more`; + }; + + if (syncResult.summary.switchedToBundled.length > 0) { + defaultRuntime.log( + theme.muted( + `Switched to bundled plugins: ${summarizeList(syncResult.summary.switchedToBundled)}.`, + ), + ); + } + if (syncResult.summary.switchedToNpm.length > 0) { + defaultRuntime.log( + theme.muted(`Restored npm plugins: ${summarizeList(syncResult.summary.switchedToNpm)}.`), + ); + } + for (const warning of syncResult.summary.warnings) { + defaultRuntime.log(theme.warn(warning)); + } + for (const error of syncResult.summary.errors) { + defaultRuntime.log(theme.error(error)); + } + + const updated = npmResult.outcomes.filter((entry) => entry.status === "updated").length; + const unchanged = npmResult.outcomes.filter((entry) => entry.status === "unchanged").length; + const failed = npmResult.outcomes.filter((entry) => entry.status === "error").length; + const skipped = npmResult.outcomes.filter((entry) => entry.status === "skipped").length; + + if (npmResult.outcomes.length === 0) { + defaultRuntime.log(theme.muted("No plugin updates needed.")); + } else { + const parts = [`${updated} updated`, `${unchanged} unchanged`]; + if (failed > 0) parts.push(`${failed} failed`); + if (skipped > 0) parts.push(`${skipped} skipped`); + defaultRuntime.log(theme.muted(`npm plugins: ${parts.join(", ")}.`)); + } + + for (const outcome of npmResult.outcomes) { + if (outcome.status !== "error") continue; + defaultRuntime.log(theme.error(outcome.message)); + } + } + } else if (!opts.json) { + defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid.")); + } + // Restart daemon if requested if (opts.restart) { if (!opts.json) { diff --git a/src/plugins/update.ts b/src/plugins/update.ts new file mode 100644 index 000000000..d493e665d --- /dev/null +++ b/src/plugins/update.ts @@ -0,0 +1,401 @@ +import fs from "node:fs/promises"; + +import type { ClawdbotConfig } from "../config/config.js"; +import type { UpdateChannel } from "../infra/update-channels.js"; +import { resolveUserPath } from "../utils.js"; +import { discoverClawdbotPlugins } from "./discovery.js"; +import { installPluginFromNpmSpec, resolvePluginInstallDir } from "./install.js"; +import { recordPluginInstall } from "./installs.js"; +import { loadPluginManifest } from "./manifest.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: ClawdbotConfig; + changed: boolean; + outcomes: PluginUpdateOutcome[]; +}; + +export type PluginChannelSyncSummary = { + switchedToBundled: string[]; + switchedToNpm: string[]; + warnings: string[]; + errors: string[]; +}; + +export type PluginChannelSyncResult = { + config: ClawdbotConfig; + changed: boolean; + summary: PluginChannelSyncSummary; +}; + +type BundledPluginSource = { + pluginId: string; + localPath: string; + npmSpec?: string; +}; + +async function readInstalledPackageVersion(dir: string): Promise { + try { + const raw = await fs.readFile(`${dir}/package.json`, "utf-8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" ? parsed.version : undefined; + } catch { + return undefined; + } +} + +function resolveBundledPluginSources(params: { + workspaceDir?: string; +}): Map { + const discovery = discoverClawdbotPlugins({ workspaceDir: params.workspaceDir }); + const bundled = new Map(); + + for (const candidate of discovery.candidates) { + if (candidate.origin !== "bundled") continue; + const manifest = loadPluginManifest(candidate.rootDir); + if (!manifest.ok) continue; + const pluginId = manifest.manifest.id; + if (bundled.has(pluginId)) continue; + + const npmSpec = + candidate.packageClawdbot?.install?.npmSpec?.trim() || + candidate.packageName?.trim() || + undefined; + + bundled.set(pluginId, { + pluginId, + localPath: candidate.rootDir, + npmSpec, + }); + } + + return bundled; +} + +function pathsEqual(left?: string, right?: string): boolean { + if (!left || !right) return false; + return resolveUserPath(left) === resolveUserPath(right); +} + +function buildLoadPathHelpers(existing: string[]) { + let paths = [...existing]; + const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry))); + let resolved = resolveSet(); + let changed = false; + + const addPath = (value: string) => { + const normalized = resolveUserPath(value); + if (resolved.has(normalized)) return; + paths.push(value); + resolved.add(normalized); + changed = true; + }; + + const removePath = (value: string) => { + const normalized = resolveUserPath(value); + if (!resolved.has(normalized)) return; + paths = paths.filter((entry) => resolveUserPath(entry) !== normalized); + resolved = resolveSet(); + changed = true; + }; + + return { + addPath, + removePath, + get changed() { + return changed; + }, + get paths() { + return paths; + }, + }; +} + +export async function updateNpmInstalledPlugins(params: { + config: ClawdbotConfig; + logger?: PluginUpdateLogger; + pluginIds?: string[]; + skipIds?: Set; + dryRun?: boolean; +}): Promise { + const logger = params.logger ?? {}; + const installs = params.config.plugins?.installs ?? {}; + const targets = params.pluginIds?.length ? params.pluginIds : Object.keys(installs); + const outcomes: PluginUpdateOutcome[] = []; + let next = params.config; + let changed = false; + + 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 (record.source !== "npm") { + outcomes.push({ + pluginId, + status: "skipped", + message: `Skipping "${pluginId}" (source: ${record.source}).`, + }); + continue; + } + + if (!record.spec) { + outcomes.push({ + pluginId, + status: "skipped", + message: `Skipping "${pluginId}" (missing npm spec).`, + }); + continue; + } + + const installPath = record.installPath ?? resolvePluginInstallDir(pluginId); + const currentVersion = await readInstalledPackageVersion(installPath); + + if (params.dryRun) { + let probe: Awaited>; + try { + probe = await installPluginFromNpmSpec({ + spec: record.spec, + mode: "update", + dryRun: true, + expectedPluginId: pluginId, + logger, + }); + } catch (err) { + outcomes.push({ + pluginId, + status: "error", + message: `Failed to check ${pluginId}: ${String(err)}`, + }); + continue; + } + if (!probe.ok) { + outcomes.push({ + pluginId, + status: "error", + message: `Failed to check ${pluginId}: ${probe.error}`, + }); + continue; + } + + const nextVersion = probe.version ?? "unknown"; + const currentLabel = currentVersion ?? "unknown"; + if (currentVersion && probe.version && currentVersion === probe.version) { + 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>; + try { + result = await installPluginFromNpmSpec({ + spec: record.spec, + mode: "update", + expectedPluginId: pluginId, + logger, + }); + } catch (err) { + outcomes.push({ + pluginId, + status: "error", + message: `Failed to update ${pluginId}: ${String(err)}`, + }); + continue; + } + if (!result.ok) { + outcomes.push({ + pluginId, + status: "error", + message: `Failed to update ${pluginId}: ${result.error}`, + }); + continue; + } + + const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); + next = recordPluginInstall(next, { + pluginId, + source: "npm", + spec: record.spec, + installPath: result.targetDir, + version: nextVersion, + }); + 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: ClawdbotConfig; + channel: UpdateChannel; + workspaceDir?: string; + logger?: PluginUpdateLogger; +}): Promise { + const summary: PluginChannelSyncSummary = { + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }; + const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir }); + if (bundled.size === 0) { + return { config: params.config, changed: false, summary }; + } + + let next = params.config; + const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? []); + const 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); + 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 { + 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)) continue; + + const spec = record.spec ?? bundledInfo.npmSpec; + if (!spec) { + summary.warnings.push(`Missing npm spec for ${pluginId}; keeping local path.`); + continue; + } + + let result: Awaited>; + try { + result = await installPluginFromNpmSpec({ + spec, + mode: "update", + expectedPluginId: pluginId, + logger: params.logger, + }); + } catch (err) { + summary.errors.push(`Failed to install ${pluginId}: ${String(err)}`); + continue; + } + if (!result.ok) { + summary.errors.push(`Failed to install ${pluginId}: ${result.error}`); + continue; + } + + next = recordPluginInstall(next, { + pluginId, + source: "npm", + spec, + installPath: result.targetDir, + version: result.version, + sourcePath: undefined, + }); + summary.switchedToNpm.push(pluginId); + changed = true; + loadHelpers.removePath(bundledInfo.localPath); + } + } + + if (loadHelpers.changed) { + next = { + ...next, + plugins: { + ...next.plugins, + load: { + ...next.plugins?.load, + paths: loadHelpers.paths, + }, + }, + }; + changed = true; + } + + return { config: next, changed, summary }; +}