/ utils / plugins / pluginAutoupdate.ts
pluginAutoupdate.ts
  1  /**
  2   * Background plugin autoupdate functionality
  3   *
  4   * At startup, this module:
  5   * 1. First updates marketplaces that have autoUpdate enabled
  6   * 2. Then checks all installed plugins from those marketplaces and updates them
  7   *
  8   * Updates are non-inplace (disk-only), requiring a restart to take effect.
  9   * Official Anthropic marketplaces have autoUpdate enabled by default,
 10   * but users can disable it per-marketplace.
 11   */
 12  
 13  import { updatePluginOp } from '../../services/plugins/pluginOperations.js'
 14  import { shouldSkipPluginAutoupdate } from '../config.js'
 15  import { logForDebugging } from '../debug.js'
 16  import { errorMessage } from '../errors.js'
 17  import { logError } from '../log.js'
 18  import {
 19    getPendingUpdatesDetails,
 20    hasPendingUpdates,
 21    isInstallationRelevantToCurrentProject,
 22    loadInstalledPluginsFromDisk,
 23  } from './installedPluginsManager.js'
 24  import {
 25    getDeclaredMarketplaces,
 26    loadKnownMarketplacesConfig,
 27    refreshMarketplace,
 28  } from './marketplaceManager.js'
 29  import { parsePluginIdentifier } from './pluginIdentifier.js'
 30  import { isMarketplaceAutoUpdate, type PluginScope } from './schemas.js'
 31  
 32  /**
 33   * Callback type for notifying when plugins have been updated
 34   */
 35  export type PluginAutoUpdateCallback = (updatedPlugins: string[]) => void
 36  
 37  // Store callback for plugin update notifications
 38  let pluginUpdateCallback: PluginAutoUpdateCallback | null = null
 39  
 40  // Store pending updates that occurred before callback was registered
 41  // This handles the race condition where updates complete before REPL mounts
 42  let pendingNotification: string[] | null = null
 43  
 44  /**
 45   * Register a callback to be notified when plugins are auto-updated.
 46   * This is used by the REPL to show restart notifications.
 47   *
 48   * If plugins were already updated before the callback was registered,
 49   * the callback will be invoked immediately with the pending updates.
 50   */
 51  export function onPluginsAutoUpdated(
 52    callback: PluginAutoUpdateCallback,
 53  ): () => void {
 54    pluginUpdateCallback = callback
 55  
 56    // If there are pending updates that happened before registration, deliver them now
 57    if (pendingNotification !== null && pendingNotification.length > 0) {
 58      callback(pendingNotification)
 59      pendingNotification = null
 60    }
 61  
 62    return () => {
 63      pluginUpdateCallback = null
 64    }
 65  }
 66  
 67  /**
 68   * Check if pending updates came from autoupdate (for notification purposes).
 69   * Returns the list of plugin names that have pending updates.
 70   */
 71  export function getAutoUpdatedPluginNames(): string[] {
 72    if (!hasPendingUpdates()) {
 73      return []
 74    }
 75    return getPendingUpdatesDetails().map(
 76      d => parsePluginIdentifier(d.pluginId).name,
 77    )
 78  }
 79  
 80  /**
 81   * Get the set of marketplaces that have autoUpdate enabled.
 82   * Returns the marketplace names that should be auto-updated.
 83   */
 84  async function getAutoUpdateEnabledMarketplaces(): Promise<Set<string>> {
 85    const config = await loadKnownMarketplacesConfig()
 86    const declared = getDeclaredMarketplaces()
 87    const enabled = new Set<string>()
 88  
 89    for (const [name, entry] of Object.entries(config)) {
 90      // Settings-declared autoUpdate takes precedence over JSON state
 91      const declaredAutoUpdate = declared[name]?.autoUpdate
 92      const autoUpdate =
 93        declaredAutoUpdate !== undefined
 94          ? declaredAutoUpdate
 95          : isMarketplaceAutoUpdate(name, entry)
 96      if (autoUpdate) {
 97        enabled.add(name.toLowerCase())
 98      }
 99    }
100  
101    return enabled
102  }
103  
104  /**
105   * Update a single plugin's installations.
106   * Returns the plugin ID if any installation was updated, null otherwise.
107   */
108  async function updatePlugin(
109    pluginId: string,
110    installations: Array<{ scope: PluginScope; projectPath?: string }>,
111  ): Promise<string | null> {
112    let wasUpdated = false
113  
114    for (const { scope } of installations) {
115      try {
116        const result = await updatePluginOp(pluginId, scope)
117  
118        if (result.success && !result.alreadyUpToDate) {
119          wasUpdated = true
120          logForDebugging(
121            `Plugin autoupdate: updated ${pluginId} from ${result.oldVersion} to ${result.newVersion}`,
122          )
123        } else if (!result.alreadyUpToDate) {
124          logForDebugging(
125            `Plugin autoupdate: failed to update ${pluginId}: ${result.message}`,
126            { level: 'warn' },
127          )
128        }
129      } catch (error) {
130        logForDebugging(
131          `Plugin autoupdate: error updating ${pluginId}: ${errorMessage(error)}`,
132          { level: 'warn' },
133        )
134      }
135    }
136  
137    return wasUpdated ? pluginId : null
138  }
139  
140  /**
141   * Update all project-relevant installed plugins from the given marketplaces.
142   *
143   * Iterates installed_plugins.json, filters to plugins whose marketplace is in
144   * the set, further filters each plugin's installations to those relevant to
145   * the current project (user/managed scope, or project/local scope matching
146   * cwd — see isInstallationRelevantToCurrentProject), then calls updatePluginOp
147   * per installation. Already-up-to-date plugins are silently skipped.
148   *
149   * Called by:
150   * - updatePlugins() below — background autoupdate path (autoUpdate-enabled
151   *   marketplaces only; third-party marketplaces default autoUpdate: false)
152   * - ManageMarketplaces.tsx applyChanges() — user-initiated /plugin marketplace
153   *   update. Before #29512 this path only called refreshMarketplace() (git
154   *   pull on the marketplace clone), so the loader would create the new
155   *   version cache dir but installed_plugins.json stayed on the old version,
156   *   and the orphan GC stamped the NEW dir with .orphaned_at on next startup.
157   *
158   * @param marketplaceNames - lowercase marketplace names to update plugins from
159   * @returns plugin IDs that were actually updated (not already up-to-date)
160   */
161  export async function updatePluginsForMarketplaces(
162    marketplaceNames: Set<string>,
163  ): Promise<string[]> {
164    const installedPlugins = loadInstalledPluginsFromDisk()
165    const pluginIds = Object.keys(installedPlugins.plugins)
166  
167    if (pluginIds.length === 0) {
168      return []
169    }
170  
171    const results = await Promise.allSettled(
172      pluginIds.map(async pluginId => {
173        const { marketplace } = parsePluginIdentifier(pluginId)
174        if (!marketplace || !marketplaceNames.has(marketplace.toLowerCase())) {
175          return null
176        }
177  
178        const allInstallations = installedPlugins.plugins[pluginId]
179        if (!allInstallations || allInstallations.length === 0) {
180          return null
181        }
182  
183        const relevantInstallations = allInstallations.filter(
184          isInstallationRelevantToCurrentProject,
185        )
186        if (relevantInstallations.length === 0) {
187          return null
188        }
189  
190        return updatePlugin(pluginId, relevantInstallations)
191      }),
192    )
193  
194    return results
195      .filter(
196        (r): r is PromiseFulfilledResult<string> =>
197          r.status === 'fulfilled' && r.value !== null,
198      )
199      .map(r => r.value)
200  }
201  
202  /**
203   * Update plugins from marketplaces that have autoUpdate enabled.
204   * Returns the list of plugin IDs that were updated.
205   */
206  async function updatePlugins(
207    autoUpdateEnabledMarketplaces: Set<string>,
208  ): Promise<string[]> {
209    return updatePluginsForMarketplaces(autoUpdateEnabledMarketplaces)
210  }
211  
212  /**
213   * Auto-update marketplaces and plugins in the background.
214   *
215   * This function:
216   * 1. Checks which marketplaces have autoUpdate enabled
217   * 2. Refreshes only those marketplaces (git pull/re-download)
218   * 3. Updates installed plugins from those marketplaces
219   * 4. If any plugins were updated, notifies via the registered callback
220   *
221   * Official Anthropic marketplaces have autoUpdate enabled by default,
222   * but users can disable it per-marketplace in the UI.
223   *
224   * This function runs silently without blocking user interaction.
225   * Called from main.tsx during startup as a background job.
226   */
227  export function autoUpdateMarketplacesAndPluginsInBackground(): void {
228    void (async () => {
229      if (shouldSkipPluginAutoupdate()) {
230        logForDebugging('Plugin autoupdate: skipped (auto-updater disabled)')
231        return
232      }
233  
234      try {
235        // Get marketplaces with autoUpdate enabled
236        const autoUpdateEnabledMarketplaces =
237          await getAutoUpdateEnabledMarketplaces()
238  
239        if (autoUpdateEnabledMarketplaces.size === 0) {
240          return
241        }
242  
243        // Refresh only marketplaces with autoUpdate enabled
244        const refreshResults = await Promise.allSettled(
245          Array.from(autoUpdateEnabledMarketplaces).map(async name => {
246            try {
247              await refreshMarketplace(name, undefined, {
248                disableCredentialHelper: true,
249              })
250            } catch (error) {
251              logForDebugging(
252                `Plugin autoupdate: failed to refresh marketplace ${name}: ${errorMessage(error)}`,
253                { level: 'warn' },
254              )
255            }
256          }),
257        )
258  
259        // Log any refresh failures
260        const failures = refreshResults.filter(r => r.status === 'rejected')
261        if (failures.length > 0) {
262          logForDebugging(
263            `Plugin autoupdate: ${failures.length} marketplace refresh(es) failed`,
264            { level: 'warn' },
265          )
266        }
267  
268        logForDebugging('Plugin autoupdate: checking installed plugins')
269        const updatedPlugins = await updatePlugins(autoUpdateEnabledMarketplaces)
270  
271        if (updatedPlugins.length > 0) {
272          if (pluginUpdateCallback) {
273            // Callback is already registered, invoke it immediately
274            pluginUpdateCallback(updatedPlugins)
275          } else {
276            // Callback not yet registered (REPL not mounted), store for later delivery
277            pendingNotification = updatedPlugins
278          }
279        }
280      } catch (error) {
281        logError(error)
282      }
283    })()
284  }