/** * Background plugin autoupdate functionality * * At startup, this module: * 1. First updates marketplaces that have autoUpdate enabled * 2. Then checks all installed plugins from those marketplaces and updates them * * Updates are non-inplace (disk-only), requiring a restart to take effect. * Official Anthropic marketplaces have autoUpdate enabled by default, * but users can disable it per-marketplace. */ import { updatePluginOp } from '../../services/plugins/pluginOperations.js' import { shouldSkipPluginAutoupdate } from '../config.js' import { logForDebugging } from '../debug.js' import { errorMessage } from '../errors.js' import { logError } from '../log.js' import { getPendingUpdatesDetails, hasPendingUpdates, isInstallationRelevantToCurrentProject, loadInstalledPluginsFromDisk, } from './installedPluginsManager.js' import { getDeclaredMarketplaces, loadKnownMarketplacesConfig, refreshMarketplace, } from './marketplaceManager.js' import { parsePluginIdentifier } from './pluginIdentifier.js' import { isMarketplaceAutoUpdate, type PluginScope } from './schemas.js' /** * Callback type for notifying when plugins have been updated */ export type PluginAutoUpdateCallback = (updatedPlugins: string[]) => void // Store callback for plugin update notifications let pluginUpdateCallback: PluginAutoUpdateCallback | null = null // Store pending updates that occurred before callback was registered // This handles the race condition where updates complete before REPL mounts let pendingNotification: string[] | null = null /** * Register a callback to be notified when plugins are auto-updated. * This is used by the REPL to show restart notifications. * * If plugins were already updated before the callback was registered, * the callback will be invoked immediately with the pending updates. */ export function onPluginsAutoUpdated( callback: PluginAutoUpdateCallback, ): () => void { pluginUpdateCallback = callback // If there are pending updates that happened before registration, deliver them now if (pendingNotification !== null && pendingNotification.length > 0) { callback(pendingNotification) pendingNotification = null } return () => { pluginUpdateCallback = null } } /** * Check if pending updates came from autoupdate (for notification purposes). * Returns the list of plugin names that have pending updates. */ export function getAutoUpdatedPluginNames(): string[] { if (!hasPendingUpdates()) { return [] } return getPendingUpdatesDetails().map( d => parsePluginIdentifier(d.pluginId).name, ) } /** * Get the set of marketplaces that have autoUpdate enabled. * Returns the marketplace names that should be auto-updated. */ async function getAutoUpdateEnabledMarketplaces(): Promise> { const config = await loadKnownMarketplacesConfig() const declared = getDeclaredMarketplaces() const enabled = new Set() for (const [name, entry] of Object.entries(config)) { // Settings-declared autoUpdate takes precedence over JSON state const declaredAutoUpdate = declared[name]?.autoUpdate const autoUpdate = declaredAutoUpdate !== undefined ? declaredAutoUpdate : isMarketplaceAutoUpdate(name, entry) if (autoUpdate) { enabled.add(name.toLowerCase()) } } return enabled } /** * Update a single plugin's installations. * Returns the plugin ID if any installation was updated, null otherwise. */ async function updatePlugin( pluginId: string, installations: Array<{ scope: PluginScope; projectPath?: string }>, ): Promise { let wasUpdated = false for (const { scope } of installations) { try { const result = await updatePluginOp(pluginId, scope) if (result.success && !result.alreadyUpToDate) { wasUpdated = true logForDebugging( `Plugin autoupdate: updated ${pluginId} from ${result.oldVersion} to ${result.newVersion}`, ) } else if (!result.alreadyUpToDate) { logForDebugging( `Plugin autoupdate: failed to update ${pluginId}: ${result.message}`, { level: 'warn' }, ) } } catch (error) { logForDebugging( `Plugin autoupdate: error updating ${pluginId}: ${errorMessage(error)}`, { level: 'warn' }, ) } } return wasUpdated ? pluginId : null } /** * Update all project-relevant installed plugins from the given marketplaces. * * Iterates installed_plugins.json, filters to plugins whose marketplace is in * the set, further filters each plugin's installations to those relevant to * the current project (user/managed scope, or project/local scope matching * cwd — see isInstallationRelevantToCurrentProject), then calls updatePluginOp * per installation. Already-up-to-date plugins are silently skipped. * * Called by: * - updatePlugins() below — background autoupdate path (autoUpdate-enabled * marketplaces only; third-party marketplaces default autoUpdate: false) * - ManageMarketplaces.tsx applyChanges() — user-initiated /plugin marketplace * update. Before #29512 this path only called refreshMarketplace() (git * pull on the marketplace clone), so the loader would create the new * version cache dir but installed_plugins.json stayed on the old version, * and the orphan GC stamped the NEW dir with .orphaned_at on next startup. * * @param marketplaceNames - lowercase marketplace names to update plugins from * @returns plugin IDs that were actually updated (not already up-to-date) */ export async function updatePluginsForMarketplaces( marketplaceNames: Set, ): Promise { const installedPlugins = loadInstalledPluginsFromDisk() const pluginIds = Object.keys(installedPlugins.plugins) if (pluginIds.length === 0) { return [] } const results = await Promise.allSettled( pluginIds.map(async pluginId => { const { marketplace } = parsePluginIdentifier(pluginId) if (!marketplace || !marketplaceNames.has(marketplace.toLowerCase())) { return null } const allInstallations = installedPlugins.plugins[pluginId] if (!allInstallations || allInstallations.length === 0) { return null } const relevantInstallations = allInstallations.filter( isInstallationRelevantToCurrentProject, ) if (relevantInstallations.length === 0) { return null } return updatePlugin(pluginId, relevantInstallations) }), ) return results .filter( (r): r is PromiseFulfilledResult => r.status === 'fulfilled' && r.value !== null, ) .map(r => r.value) } /** * Update plugins from marketplaces that have autoUpdate enabled. * Returns the list of plugin IDs that were updated. */ async function updatePlugins( autoUpdateEnabledMarketplaces: Set, ): Promise { return updatePluginsForMarketplaces(autoUpdateEnabledMarketplaces) } /** * Auto-update marketplaces and plugins in the background. * * This function: * 1. Checks which marketplaces have autoUpdate enabled * 2. Refreshes only those marketplaces (git pull/re-download) * 3. Updates installed plugins from those marketplaces * 4. If any plugins were updated, notifies via the registered callback * * Official Anthropic marketplaces have autoUpdate enabled by default, * but users can disable it per-marketplace in the UI. * * This function runs silently without blocking user interaction. * Called from main.tsx during startup as a background job. */ export function autoUpdateMarketplacesAndPluginsInBackground(): void { void (async () => { if (shouldSkipPluginAutoupdate()) { logForDebugging('Plugin autoupdate: skipped (auto-updater disabled)') return } try { // Get marketplaces with autoUpdate enabled const autoUpdateEnabledMarketplaces = await getAutoUpdateEnabledMarketplaces() if (autoUpdateEnabledMarketplaces.size === 0) { return } // Refresh only marketplaces with autoUpdate enabled const refreshResults = await Promise.allSettled( Array.from(autoUpdateEnabledMarketplaces).map(async name => { try { await refreshMarketplace(name, undefined, { disableCredentialHelper: true, }) } catch (error) { logForDebugging( `Plugin autoupdate: failed to refresh marketplace ${name}: ${errorMessage(error)}`, { level: 'warn' }, ) } }), ) // Log any refresh failures const failures = refreshResults.filter(r => r.status === 'rejected') if (failures.length > 0) { logForDebugging( `Plugin autoupdate: ${failures.length} marketplace refresh(es) failed`, { level: 'warn' }, ) } logForDebugging('Plugin autoupdate: checking installed plugins') const updatedPlugins = await updatePlugins(autoUpdateEnabledMarketplaces) if (updatedPlugins.length > 0) { if (pluginUpdateCallback) { // Callback is already registered, invoke it immediately pluginUpdateCallback(updatedPlugins) } else { // Callback not yet registered (REPL not mounted), store for later delivery pendingNotification = updatedPlugins } } } catch (error) { logError(error) } })() }