/ utils / plugins / pluginStartupCheck.ts
pluginStartupCheck.ts
  1  import { join } from 'path'
  2  import { getCwd } from '../cwd.js'
  3  import { logForDebugging } from '../debug.js'
  4  import { logError } from '../log.js'
  5  import type { SettingSource } from '../settings/constants.js'
  6  import {
  7    getInitialSettings,
  8    getSettingsForSource,
  9    updateSettingsForSource,
 10  } from '../settings/settings.js'
 11  import { getAddDirEnabledPlugins } from './addDirPluginSettings.js'
 12  import {
 13    getInMemoryInstalledPlugins,
 14    migrateFromEnabledPlugins,
 15  } from './installedPluginsManager.js'
 16  import { getPluginById } from './marketplaceManager.js'
 17  import {
 18    type ExtendedPluginScope,
 19    type PersistablePluginScope,
 20    SETTING_SOURCE_TO_SCOPE,
 21    scopeToSettingSource,
 22  } from './pluginIdentifier.js'
 23  import {
 24    cacheAndRegisterPlugin,
 25    registerPluginInstallation,
 26  } from './pluginInstallationHelpers.js'
 27  import { isLocalPluginSource, type PluginScope } from './schemas.js'
 28  
 29  /**
 30   * Checks for enabled plugins across all settings sources, including --add-dir.
 31   *
 32   * Uses getInitialSettings() which merges all sources with policy as
 33   * highest priority, then layers --add-dir plugins underneath. This is the
 34   * authoritative "is this plugin enabled?" check — don't delegate to
 35   * getPluginEditableScopes() which serves a different purpose (scope tracking).
 36   *
 37   * @returns Array of plugin IDs (plugin@marketplace format) that are enabled
 38   */
 39  export async function checkEnabledPlugins(): Promise<string[]> {
 40    const settings = getInitialSettings()
 41    const enabledPlugins: string[] = []
 42  
 43    // Start with --add-dir plugins (lowest priority)
 44    const addDirPlugins = getAddDirEnabledPlugins()
 45    for (const [pluginId, value] of Object.entries(addDirPlugins)) {
 46      if (pluginId.includes('@') && value) {
 47        enabledPlugins.push(pluginId)
 48      }
 49    }
 50  
 51    // Merged settings (policy > local > project > user) override --add-dir
 52    if (settings.enabledPlugins) {
 53      for (const [pluginId, value] of Object.entries(settings.enabledPlugins)) {
 54        if (!pluginId.includes('@')) {
 55          continue
 56        }
 57        const idx = enabledPlugins.indexOf(pluginId)
 58        if (value) {
 59          if (idx === -1) {
 60            enabledPlugins.push(pluginId)
 61          }
 62        } else {
 63          // Explicitly disabled — remove even if --add-dir enabled it
 64          if (idx !== -1) {
 65            enabledPlugins.splice(idx, 1)
 66          }
 67        }
 68      }
 69    }
 70  
 71    return enabledPlugins
 72  }
 73  
 74  /**
 75   * Gets the user-editable scope that "owns" each enabled plugin.
 76   *
 77   * Used for scope tracking: determining where to write back when a user
 78   * enables/disables a plugin. Managed (policy) settings are processed first
 79   * (lowest priority) because the user cannot edit them — the scope should
 80   * resolve to the highest user-controllable source.
 81   *
 82   * NOTE: This is NOT the authoritative "is this plugin enabled?" check.
 83   * Use checkEnabledPlugins() for that — it uses merged settings where
 84   * policy has highest priority and can block user-enabled plugins.
 85   *
 86   * Precedence (lowest to highest):
 87   * 0. addDir (--add-dir directories) - session-only, lowest priority
 88   * 1. managed (policySettings) - not user-editable
 89   * 2. user (userSettings)
 90   * 3. project (projectSettings)
 91   * 4. local (localSettings)
 92   * 5. flag (flagSettings) - session-only, not persisted
 93   *
 94   * @returns Map of plugin ID to the user-editable scope that owns it
 95   */
 96  export function getPluginEditableScopes(): Map<string, ExtendedPluginScope> {
 97    const result = new Map<string, ExtendedPluginScope>()
 98  
 99    // Process --add-dir directories FIRST (lowest priority, overridden by all standard sources)
100    const addDirPlugins = getAddDirEnabledPlugins()
101    for (const [pluginId, value] of Object.entries(addDirPlugins)) {
102      if (!pluginId.includes('@')) {
103        continue
104      }
105      if (value === true) {
106        result.set(pluginId, 'flag') // 'flag' scope = session-only, no write-back
107      } else if (value === false) {
108        result.delete(pluginId)
109      }
110    }
111  
112    // Process standard sources in precedence order (later overrides earlier)
113    const scopeSources: Array<{
114      scope: ExtendedPluginScope
115      source: SettingSource
116    }> = [
117      { scope: 'managed', source: 'policySettings' },
118      { scope: 'user', source: 'userSettings' },
119      { scope: 'project', source: 'projectSettings' },
120      { scope: 'local', source: 'localSettings' },
121      { scope: 'flag', source: 'flagSettings' },
122    ]
123  
124    for (const { scope, source } of scopeSources) {
125      const settings = getSettingsForSource(source)
126      if (!settings?.enabledPlugins) {
127        continue
128      }
129  
130      for (const [pluginId, value] of Object.entries(settings.enabledPlugins)) {
131        // Skip invalid format
132        if (!pluginId.includes('@')) {
133          continue
134        }
135  
136        // Log when a standard source overrides an --add-dir plugin
137        if (pluginId in addDirPlugins && addDirPlugins[pluginId] !== value) {
138          logForDebugging(
139            `Plugin ${pluginId} from --add-dir (${addDirPlugins[pluginId]}) overridden by ${source} (${value})`,
140          )
141        }
142  
143        if (value === true) {
144          // Plugin enabled at this scope
145          result.set(pluginId, scope)
146        } else if (value === false) {
147          // Explicitly disabled - remove from result
148          result.delete(pluginId)
149        }
150        // Note: Other values (like version strings for future P2) are ignored for now
151      }
152    }
153  
154    logForDebugging(
155      `Found ${result.size} enabled plugins with scopes: ${Array.from(
156        result.entries(),
157      )
158        .map(([id, scope]) => `${id}(${scope})`)
159        .join(', ')}`,
160    )
161  
162    return result
163  }
164  
165  /**
166   * Check if a scope is persistable (not session-only).
167   * @param scope The scope to check
168   * @returns true if the scope should be persisted to installed_plugins.json
169   */
170  export function isPersistableScope(
171    scope: ExtendedPluginScope,
172  ): scope is PersistablePluginScope {
173    return scope !== 'flag'
174  }
175  
176  /**
177   * Convert SettingSource to plugin scope.
178   * @param source The settings source
179   * @returns The corresponding plugin scope
180   */
181  export function settingSourceToScope(
182    source: SettingSource,
183  ): ExtendedPluginScope {
184    return SETTING_SOURCE_TO_SCOPE[source]
185  }
186  
187  /**
188   * Gets the list of currently installed plugins
189   * Reads from installed_plugins.json which tracks global installation state.
190   * Automatically runs migration on first call if needed.
191   *
192   * Always uses V2 format and initializes the in-memory session state
193   * (which triggers V1→V2 migration if needed).
194   *
195   * @returns Array of installed plugin IDs
196   */
197  export async function getInstalledPlugins(): Promise<string[]> {
198    // Trigger sync in background (don't await - don't block startup)
199    // This syncs enabledPlugins from settings.json to installed_plugins.json
200    void migrateFromEnabledPlugins().catch(error => {
201      logError(error)
202    })
203  
204    // Always use V2 format - initializes in-memory session state and triggers V1→V2 migration
205    const v2Data = getInMemoryInstalledPlugins()
206    const installed = Object.keys(v2Data.plugins)
207    logForDebugging(`Found ${installed.length} installed plugins`)
208    return installed
209  }
210  
211  /**
212   * Finds plugins that are enabled but not installed
213   * @param enabledPlugins Array of enabled plugin IDs
214   * @returns Array of missing plugin IDs
215   */
216  export async function findMissingPlugins(
217    enabledPlugins: string[],
218  ): Promise<string[]> {
219    try {
220      const installedPlugins = await getInstalledPlugins()
221  
222      // Filter to not-installed synchronously, then look up all in parallel.
223      // Results are collected in original enabledPlugins order.
224      const notInstalled = enabledPlugins.filter(
225        id => !installedPlugins.includes(id),
226      )
227      const lookups = await Promise.all(
228        notInstalled.map(async pluginId => {
229          try {
230            const plugin = await getPluginById(pluginId)
231            return { pluginId, found: plugin !== null && plugin !== undefined }
232          } catch (error) {
233            logForDebugging(
234              `Failed to check plugin ${pluginId} in marketplace: ${error}`,
235            )
236            // Plugin doesn't exist in any marketplace, will be handled as an error
237            return { pluginId, found: false }
238          }
239        }),
240      )
241      const missing = lookups
242        .filter(({ found }) => found)
243        .map(({ pluginId }) => pluginId)
244  
245      return missing
246    } catch (error) {
247      logError(error)
248      return []
249    }
250  }
251  
252  /**
253   * Result of plugin installation attempt
254   */
255  export type PluginInstallResult = {
256    installed: string[]
257    failed: Array<{ name: string; error: string }>
258  }
259  
260  /**
261   * Installation scope type for install functions (excludes 'managed' which is read-only)
262   */
263  type InstallableScope = Exclude<PluginScope, 'managed'>
264  
265  /**
266   * Installs the selected plugins
267   * @param pluginsToInstall Array of plugin IDs to install
268   * @param onProgress Optional callback for installation progress
269   * @param scope Installation scope: user, project, or local (defaults to 'user')
270   * @returns Installation results with succeeded and failed plugins
271   */
272  export async function installSelectedPlugins(
273    pluginsToInstall: string[],
274    onProgress?: (name: string, index: number, total: number) => void,
275    scope: InstallableScope = 'user',
276  ): Promise<PluginInstallResult> {
277    // Get projectPath for non-user scopes
278    const projectPath = scope !== 'user' ? getCwd() : undefined
279  
280    // Get the correct settings source for this scope
281    const settingSource = scopeToSettingSource(scope)
282    const settings = getSettingsForSource(settingSource)
283    const updatedEnabledPlugins = { ...settings?.enabledPlugins }
284    const installed: string[] = []
285    const failed: Array<{ name: string; error: string }> = []
286  
287    for (let i = 0; i < pluginsToInstall.length; i++) {
288      const pluginId = pluginsToInstall[i]
289      if (!pluginId) continue
290  
291      if (onProgress) {
292        onProgress(pluginId, i + 1, pluginsToInstall.length)
293      }
294  
295      try {
296        const pluginInfo = await getPluginById(pluginId)
297        if (!pluginInfo) {
298          failed.push({
299            name: pluginId,
300            error: 'Plugin not found in any marketplace',
301          })
302          continue
303        }
304  
305        // Cache the plugin if it's from an external source
306        const { entry, marketplaceInstallLocation } = pluginInfo
307        if (!isLocalPluginSource(entry.source)) {
308          // External plugin - cache and register it with scope
309          await cacheAndRegisterPlugin(pluginId, entry, scope, projectPath)
310        } else {
311          // Local plugin - just register it with the install path and scope
312          registerPluginInstallation(
313            {
314              pluginId,
315              installPath: join(marketplaceInstallLocation, entry.source),
316              version: entry.version,
317            },
318            scope,
319            projectPath,
320          )
321        }
322  
323        // Mark as enabled in settings
324        updatedEnabledPlugins[pluginId] = true
325        installed.push(pluginId)
326      } catch (error) {
327        const errorMessage =
328          error instanceof Error ? error.message : String(error)
329        failed.push({ name: pluginId, error: errorMessage })
330        logError(error)
331      }
332    }
333  
334    // Update settings with newly enabled plugins using the correct settings source
335    updateSettingsForSource(settingSource, {
336      ...settings,
337      enabledPlugins: updatedEnabledPlugins,
338    })
339  
340    return { installed, failed }
341  }