/ services / plugins / PluginInstallationManager.ts
PluginInstallationManager.ts
  1  /**
  2   * Background plugin and marketplace installation manager
  3   *
  4   * This module handles automatic installation of plugins and marketplaces
  5   * from trusted sources (repository and user settings) without blocking startup.
  6   */
  7  
  8  import type { AppState } from '../../state/AppState.js'
  9  import { logForDebugging } from '../../utils/debug.js'
 10  import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
 11  import { logError } from '../../utils/log.js'
 12  import {
 13    clearMarketplacesCache,
 14    getDeclaredMarketplaces,
 15    loadKnownMarketplacesConfig,
 16  } from '../../utils/plugins/marketplaceManager.js'
 17  import { clearPluginCache } from '../../utils/plugins/pluginLoader.js'
 18  import {
 19    diffMarketplaces,
 20    reconcileMarketplaces,
 21  } from '../../utils/plugins/reconciler.js'
 22  import { refreshActivePlugins } from '../../utils/plugins/refresh.js'
 23  import { logEvent } from '../analytics/index.js'
 24  
 25  type SetAppState = (f: (prevState: AppState) => AppState) => void
 26  
 27  /**
 28   * Update marketplace installation status in app state
 29   */
 30  function updateMarketplaceStatus(
 31    setAppState: SetAppState,
 32    name: string,
 33    status: 'pending' | 'installing' | 'installed' | 'failed',
 34    error?: string,
 35  ): void {
 36    setAppState(prevState => ({
 37      ...prevState,
 38      plugins: {
 39        ...prevState.plugins,
 40        installationStatus: {
 41          ...prevState.plugins.installationStatus,
 42          marketplaces: prevState.plugins.installationStatus.marketplaces.map(
 43            m => (m.name === name ? { ...m, status, error } : m),
 44          ),
 45        },
 46      },
 47    }))
 48  }
 49  
 50  /**
 51   * Perform background plugin startup checks and installations.
 52   *
 53   * This is a thin wrapper around reconcileMarketplaces() that maps onProgress
 54   * events to AppState updates for the REPL UI. After marketplaces are
 55   * reconciled:
 56   * - New installs → auto-refresh plugins (fixes "plugin-not-found" errors
 57   *   from the initial cache-only load on fresh homespace/cleared cache)
 58   * - Updates only → set needsRefresh, show notification for /reload-plugins
 59   */
 60  export async function performBackgroundPluginInstallations(
 61    setAppState: SetAppState,
 62  ): Promise<void> {
 63    logForDebugging('performBackgroundPluginInstallations called')
 64  
 65    try {
 66      // Compute diff upfront for initial UI status (pending spinners)
 67      const declared = getDeclaredMarketplaces()
 68      const materialized = await loadKnownMarketplacesConfig().catch(() => ({}))
 69      const diff = diffMarketplaces(declared, materialized)
 70  
 71      const pendingNames = [
 72        ...diff.missing,
 73        ...diff.sourceChanged.map(c => c.name),
 74      ]
 75  
 76      // Initialize AppState with pending status. No per-plugin pending status —
 77      // plugin load is fast (cache hit or local copy); marketplace clone is the
 78      // slow part worth showing progress for.
 79      setAppState(prev => ({
 80        ...prev,
 81        plugins: {
 82          ...prev.plugins,
 83          installationStatus: {
 84            marketplaces: pendingNames.map(name => ({
 85              name,
 86              status: 'pending' as const,
 87            })),
 88            plugins: [],
 89          },
 90        },
 91      }))
 92  
 93      if (pendingNames.length === 0) {
 94        return
 95      }
 96  
 97      logForDebugging(
 98        `Installing ${pendingNames.length} marketplace(s) in background`,
 99      )
100  
101      const result = await reconcileMarketplaces({
102        onProgress: event => {
103          switch (event.type) {
104            case 'installing':
105              updateMarketplaceStatus(setAppState, event.name, 'installing')
106              break
107            case 'installed':
108              updateMarketplaceStatus(setAppState, event.name, 'installed')
109              break
110            case 'failed':
111              updateMarketplaceStatus(
112                setAppState,
113                event.name,
114                'failed',
115                event.error,
116              )
117              break
118          }
119        },
120      })
121  
122      const metrics = {
123        installed_count: result.installed.length,
124        updated_count: result.updated.length,
125        failed_count: result.failed.length,
126        up_to_date_count: result.upToDate.length,
127      }
128      logEvent('tengu_marketplace_background_install', metrics)
129      logForDiagnosticsNoPII(
130        'info',
131        'tengu_marketplace_background_install',
132        metrics,
133      )
134  
135      if (result.installed.length > 0) {
136        // New marketplaces were installed — auto-refresh plugins. This fixes
137        // "Plugin not found in marketplace" errors from the initial cache-only
138        // load (e.g., fresh homespace where marketplace cache was empty).
139        // refreshActivePlugins clears all caches, reloads plugins, and bumps
140        // pluginReconnectKey so MCP connections are re-established.
141        clearMarketplacesCache()
142        logForDebugging(
143          `Auto-refreshing plugins after ${result.installed.length} new marketplace(s) installed`,
144        )
145        try {
146          await refreshActivePlugins(setAppState)
147        } catch (refreshError) {
148          // If auto-refresh fails, fall back to needsRefresh notification so
149          // the user can manually run /reload-plugins to recover.
150          logError(refreshError)
151          logForDebugging(
152            `Auto-refresh failed, falling back to needsRefresh: ${refreshError}`,
153            { level: 'warn' },
154          )
155          clearPluginCache(
156            'performBackgroundPluginInstallations: auto-refresh failed',
157          )
158          setAppState(prev => {
159            if (prev.plugins.needsRefresh) return prev
160            return {
161              ...prev,
162              plugins: { ...prev.plugins, needsRefresh: true },
163            }
164          })
165        }
166      } else if (result.updated.length > 0) {
167        // Existing marketplaces updated — notify user to run /reload-plugins.
168        // Updates are less urgent and the user should choose when to apply them.
169        clearMarketplacesCache()
170        clearPluginCache(
171          'performBackgroundPluginInstallations: marketplaces reconciled',
172        )
173        setAppState(prev => {
174          if (prev.plugins.needsRefresh) return prev
175          return {
176            ...prev,
177            plugins: { ...prev.plugins, needsRefresh: true },
178          }
179        })
180      }
181    } catch (error) {
182      logError(error)
183    }
184  }