/ utils / plugins / pluginInstallationHelpers.ts
pluginInstallationHelpers.ts
  1  /**
  2   * Shared helper functions for plugin installation
  3   *
  4   * This module contains common utilities used across the plugin installation
  5   * system to reduce code duplication and improve maintainability.
  6   */
  7  
  8  import { randomBytes } from 'crypto'
  9  import { rename, rm } from 'fs/promises'
 10  import { dirname, join, resolve, sep } from 'path'
 11  import {
 12    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 13    type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 14    logEvent,
 15  } from '../../services/analytics/index.js'
 16  import { getCwd } from '../cwd.js'
 17  import { toError } from '../errors.js'
 18  import { getFsImplementation } from '../fsOperations.js'
 19  import { logError } from '../log.js'
 20  import {
 21    getSettingsForSource,
 22    updateSettingsForSource,
 23  } from '../settings/settings.js'
 24  import { buildPluginTelemetryFields } from '../telemetry/pluginTelemetry.js'
 25  import { clearAllCaches } from './cacheUtils.js'
 26  import {
 27    formatDependencyCountSuffix,
 28    getEnabledPluginIdsForScope,
 29    type ResolutionResult,
 30    resolveDependencyClosure,
 31  } from './dependencyResolver.js'
 32  import {
 33    addInstalledPlugin,
 34    getGitCommitSha,
 35  } from './installedPluginsManager.js'
 36  import { getManagedPluginNames } from './managedPlugins.js'
 37  import { getMarketplaceCacheOnly, getPluginById } from './marketplaceManager.js'
 38  import {
 39    isOfficialMarketplaceName,
 40    parsePluginIdentifier,
 41    scopeToSettingSource,
 42  } from './pluginIdentifier.js'
 43  import {
 44    cachePlugin,
 45    getVersionedCachePath,
 46    getVersionedZipCachePath,
 47  } from './pluginLoader.js'
 48  import { isPluginBlockedByPolicy } from './pluginPolicy.js'
 49  import { calculatePluginVersion } from './pluginVersioning.js'
 50  import {
 51    isLocalPluginSource,
 52    type PluginMarketplaceEntry,
 53    type PluginScope,
 54    type PluginSource,
 55  } from './schemas.js'
 56  import {
 57    convertDirectoryToZipInPlace,
 58    isPluginZipCacheEnabled,
 59  } from './zipCache.js'
 60  
 61  /**
 62   * Plugin installation metadata for installed_plugins.json
 63   */
 64  export type PluginInstallationInfo = {
 65    pluginId: string
 66    installPath: string
 67    version?: string
 68  }
 69  
 70  /**
 71   * Get current ISO timestamp
 72   */
 73  export function getCurrentTimestamp(): string {
 74    return new Date().toISOString()
 75  }
 76  
 77  /**
 78   * Validate that a resolved path stays within a base directory.
 79   * Prevents path traversal attacks where malicious paths like './../../../etc/passwd'
 80   * could escape the expected directory.
 81   *
 82   * @param basePath - The base directory that the resolved path must stay within
 83   * @param relativePath - The relative path to validate
 84   * @returns The validated absolute path
 85   * @throws Error if the path would escape the base directory
 86   */
 87  export function validatePathWithinBase(
 88    basePath: string,
 89    relativePath: string,
 90  ): string {
 91    const resolvedPath = resolve(basePath, relativePath)
 92    const normalizedBase = resolve(basePath) + sep
 93  
 94    // Check if the resolved path starts with the base path
 95    // Adding sep ensures we don't match partial directory names
 96    // e.g., /foo/bar should not match /foo/barbaz
 97    if (
 98      !resolvedPath.startsWith(normalizedBase) &&
 99      resolvedPath !== resolve(basePath)
100    ) {
101      throw new Error(
102        `Path traversal detected: "${relativePath}" would escape the base directory`,
103      )
104    }
105  
106    return resolvedPath
107  }
108  
109  /**
110   * Cache a plugin (local or external) and add it to installed_plugins.json
111   *
112   * This function combines the common pattern of:
113   * 1. Caching a plugin to ~/.claude/plugins/cache/
114   * 2. Adding it to the installed plugins registry
115   *
116   * Both local plugins (with string source like "./path") and external plugins
117   * (with object source like {source: "github", ...}) are cached to the same
118   * location to ensure consistent behavior.
119   *
120   * @param pluginId - Plugin ID in "plugin@marketplace" format
121   * @param entry - Plugin marketplace entry
122   * @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'.
123   *                'managed' scope is used for plugins installed automatically from managed settings.
124   * @param projectPath - Project path (required for project/local scopes)
125   * @param localSourcePath - For local plugins, the resolved absolute path to the source directory
126   * @returns The installation path
127   */
128  export async function cacheAndRegisterPlugin(
129    pluginId: string,
130    entry: PluginMarketplaceEntry,
131    scope: PluginScope = 'user',
132    projectPath?: string,
133    localSourcePath?: string,
134  ): Promise<string> {
135    // For local plugins, we need the resolved absolute path
136    // Cast to PluginSource since cachePlugin handles any string path at runtime
137    const source: PluginSource =
138      typeof entry.source === 'string' && localSourcePath
139        ? (localSourcePath as PluginSource)
140        : entry.source
141  
142    const cacheResult = await cachePlugin(source, {
143      manifest: entry as PluginMarketplaceEntry,
144    })
145  
146    // For local plugins, use the original source path for Git SHA calculation
147    // because the cached temp directory doesn't have .git (it's copied from a
148    // subdirectory of the marketplace git repo). For external plugins, use the
149    // cached path. For git-subdir sources, cachePlugin already captured the SHA
150    // before discarding the ephemeral clone (the extracted subdir has no .git).
151    const pathForGitSha = localSourcePath || cacheResult.path
152    const gitCommitSha =
153      cacheResult.gitCommitSha ?? (await getGitCommitSha(pathForGitSha))
154  
155    const now = getCurrentTimestamp()
156    const version = await calculatePluginVersion(
157      pluginId,
158      entry.source,
159      cacheResult.manifest,
160      pathForGitSha,
161      entry.version,
162      cacheResult.gitCommitSha,
163    )
164  
165    // Move the cached plugin to the versioned path: cache/marketplace/plugin/version/
166    const versionedPath = getVersionedCachePath(pluginId, version)
167    let finalPath = cacheResult.path
168  
169    // Only move if the paths are different and plugin was cached to a different location
170    if (cacheResult.path !== versionedPath) {
171      // Create the versioned directory structure
172      await getFsImplementation().mkdir(dirname(versionedPath))
173  
174      // Remove existing versioned path if present (force: no-op if missing)
175      await rm(versionedPath, { recursive: true, force: true })
176  
177      // Check if versionedPath is a subdirectory of cacheResult.path
178      // This happens when marketplace name equals plugin name (e.g., "exa-mcp-server@exa-mcp-server")
179      // In this case, we can't directly rename because we'd be moving a directory into itself
180      const normalizedCachePath = cacheResult.path.endsWith(sep)
181        ? cacheResult.path
182        : cacheResult.path + sep
183      const isSubdirectory = versionedPath.startsWith(normalizedCachePath)
184  
185      if (isSubdirectory) {
186        // Move to a temp location first, then to final destination
187        // We can't directly rename/copy a directory into its own subdirectory
188        // Use the parent of cacheResult.path (same filesystem) to avoid EXDEV
189        // errors when /tmp is on a different filesystem (e.g., tmpfs)
190        const tempPath = join(
191          dirname(cacheResult.path),
192          `.claude-plugin-temp-${Date.now()}-${randomBytes(4).toString('hex')}`,
193        )
194        await rename(cacheResult.path, tempPath)
195        await getFsImplementation().mkdir(dirname(versionedPath))
196        await rename(tempPath, versionedPath)
197      } else {
198        // Move the cached plugin to the versioned location
199        await rename(cacheResult.path, versionedPath)
200      }
201      finalPath = versionedPath
202    }
203  
204    // Zip cache mode: convert directory to ZIP and remove the directory
205    if (isPluginZipCacheEnabled()) {
206      const zipPath = getVersionedZipCachePath(pluginId, version)
207      await convertDirectoryToZipInPlace(finalPath, zipPath)
208      finalPath = zipPath
209    }
210  
211    // Add to both V1 and V2 installed_plugins files with correct scope
212    addInstalledPlugin(
213      pluginId,
214      {
215        version,
216        installedAt: now,
217        lastUpdated: now,
218        installPath: finalPath,
219        gitCommitSha,
220      },
221      scope,
222      projectPath,
223    )
224  
225    return finalPath
226  }
227  
228  /**
229   * Register a plugin installation without caching
230   *
231   * Used for local plugins that are already on disk and don't need remote caching.
232   * External plugins should use cacheAndRegisterPlugin() instead.
233   *
234   * @param info - Plugin installation information
235   * @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'.
236   *                'managed' scope is used for plugins registered from managed settings.
237   * @param projectPath - Project path (required for project/local scopes)
238   */
239  export function registerPluginInstallation(
240    info: PluginInstallationInfo,
241    scope: PluginScope = 'user',
242    projectPath?: string,
243  ): void {
244    const now = getCurrentTimestamp()
245    addInstalledPlugin(
246      info.pluginId,
247      {
248        version: info.version || 'unknown',
249        installedAt: now,
250        lastUpdated: now,
251        installPath: info.installPath,
252      },
253      scope,
254      projectPath,
255    )
256  }
257  
258  /**
259   * Parse plugin ID into components
260   *
261   * @param pluginId - Plugin ID in "plugin@marketplace" format
262   * @returns Parsed components or null if invalid
263   */
264  export function parsePluginId(
265    pluginId: string,
266  ): { name: string; marketplace: string } | null {
267    const parts = pluginId.split('@')
268    if (parts.length !== 2 || !parts[0] || !parts[1]) {
269      return null
270    }
271  
272    return {
273      name: parts[0],
274      marketplace: parts[1],
275    }
276  }
277  
278  /**
279   * Structured result from the install core. Wrappers format messages and
280   * handle analytics/error-catching around this.
281   */
282  export type InstallCoreResult =
283    | { ok: true; closure: string[]; depNote: string }
284    | { ok: false; reason: 'local-source-no-location'; pluginName: string }
285    | { ok: false; reason: 'settings-write-failed'; message: string }
286    | {
287        ok: false
288        reason: 'resolution-failed'
289        resolution: ResolutionResult & { ok: false }
290      }
291    | { ok: false; reason: 'blocked-by-policy'; pluginName: string }
292    | {
293        ok: false
294        reason: 'dependency-blocked-by-policy'
295        pluginName: string
296        blockedDependency: string
297      }
298  
299  /**
300   * Format a failed ResolutionResult into a user-facing message. Unified on
301   * the richer CLI messages (the "Is the X marketplace added?" hint is useful
302   * for UI users too).
303   */
304  export function formatResolutionError(
305    r: ResolutionResult & { ok: false },
306  ): string {
307    switch (r.reason) {
308      case 'cycle':
309        return `Dependency cycle: ${r.chain.join(' → ')}`
310      case 'cross-marketplace': {
311        const depMkt = parsePluginIdentifier(r.dependency).marketplace
312        const where = depMkt
313          ? `marketplace "${depMkt}"`
314          : 'a different marketplace'
315        const hint = depMkt
316          ? ` Add "${depMkt}" to allowCrossMarketplaceDependenciesOn in the ROOT marketplace's marketplace.json (the marketplace of the plugin you're installing — only its allowlist applies; no transitive trust).`
317          : ''
318        return `Dependency "${r.dependency}" (required by ${r.requiredBy}) is in ${where}, which is not in the allowlist — cross-marketplace dependencies are blocked by default. Install it manually first.${hint}`
319      }
320      case 'not-found': {
321        const { marketplace: depMkt } = parsePluginIdentifier(r.missing)
322        return depMkt
323          ? `Dependency "${r.missing}" (required by ${r.requiredBy}) not found. Is the "${depMkt}" marketplace added?`
324          : `Dependency "${r.missing}" (required by ${r.requiredBy}) not found in any configured marketplace`
325      }
326    }
327  }
328  
329  /**
330   * Core plugin install logic, shared by the CLI path (`installPluginOp`) and
331   * the interactive UI path (`installPluginFromMarketplace`). Given a
332   * pre-resolved marketplace entry, this:
333   *
334   *   1. Guards against local-source plugins without a marketplace install
335   *      location (would silently no-op otherwise).
336   *   2. Resolves the transitive dependency closure (when PLUGIN_DEPENDENCIES
337   *      is on; trivial single-plugin closure otherwise).
338   *   3. Writes the entire closure to enabledPlugins in one settings update.
339   *   4. Caches each closure member (downloads/copies sources as needed).
340   *   5. Clears memoization caches.
341   *
342   * Returns a structured result. Message formatting, analytics, and top-level
343   * error wrapping stay in the caller-specific wrappers.
344   *
345   * @param marketplaceInstallLocation Pass this if the caller already has it
346   *   (from a prior marketplace search) to avoid a redundant lookup.
347   */
348  export async function installResolvedPlugin({
349    pluginId,
350    entry,
351    scope,
352    marketplaceInstallLocation,
353  }: {
354    pluginId: string
355    entry: PluginMarketplaceEntry
356    scope: 'user' | 'project' | 'local'
357    marketplaceInstallLocation?: string
358  }): Promise<InstallCoreResult> {
359    const settingSource = scopeToSettingSource(scope)
360  
361    // ── Policy guard ──
362    // Org-blocked plugins (managed-settings.json enabledPlugins: false) cannot
363    // be installed. Checked here so all install paths (CLI, UI, hint-triggered)
364    // are covered in one place.
365    if (isPluginBlockedByPolicy(pluginId)) {
366      return { ok: false, reason: 'blocked-by-policy', pluginName: entry.name }
367    }
368  
369    // ── Resolve dependency closure ──
370    // depInfo caches marketplace lookups so the materialize loop doesn't
371    // re-fetch. Seed the root if the caller gave us its install location.
372    const depInfo = new Map<
373      string,
374      { entry: PluginMarketplaceEntry; marketplaceInstallLocation: string }
375    >()
376    // Without this guard, a local-source root with undefined
377    // marketplaceInstallLocation falls through: depInfo isn't seeded, the
378    // materialize loop's `if (!info) continue` skips the root, and the user
379    // sees "Successfully installed" while nothing is cached.
380    if (isLocalPluginSource(entry.source) && !marketplaceInstallLocation) {
381      return {
382        ok: false,
383        reason: 'local-source-no-location',
384        pluginName: entry.name,
385      }
386    }
387    if (marketplaceInstallLocation) {
388      depInfo.set(pluginId, { entry, marketplaceInstallLocation })
389    }
390  
391    const rootMarketplace = parsePluginIdentifier(pluginId).marketplace
392    const allowedCrossMarketplaces = new Set(
393      (rootMarketplace
394        ? (await getMarketplaceCacheOnly(rootMarketplace))
395            ?.allowCrossMarketplaceDependenciesOn
396        : undefined) ?? [],
397    )
398    const resolution = await resolveDependencyClosure(
399      pluginId,
400      async id => {
401        if (depInfo.has(id)) return depInfo.get(id)!.entry
402        if (id === pluginId) return entry
403        const info = await getPluginById(id)
404        if (info) depInfo.set(id, info)
405        return info?.entry ?? null
406      },
407      getEnabledPluginIdsForScope(settingSource),
408      allowedCrossMarketplaces,
409    )
410    if (!resolution.ok) {
411      return { ok: false, reason: 'resolution-failed', resolution }
412    }
413  
414    // ── Policy guard for transitive dependencies ──
415    // The root plugin was already checked above, but any dependency in the
416    // closure could also be policy-blocked. Check before writing to settings
417    // so a non-blocked plugin can't pull in a blocked dependency.
418    for (const id of resolution.closure) {
419      if (id !== pluginId && isPluginBlockedByPolicy(id)) {
420        return {
421          ok: false,
422          reason: 'dependency-blocked-by-policy',
423          pluginName: entry.name,
424          blockedDependency: id,
425        }
426      }
427    }
428  
429    // ── ACTION: write entire closure to settings in one call ──
430    const closureEnabled: Record<string, true> = {}
431    for (const id of resolution.closure) closureEnabled[id] = true
432    const { error } = updateSettingsForSource(settingSource, {
433      enabledPlugins: {
434        ...getSettingsForSource(settingSource)?.enabledPlugins,
435        ...closureEnabled,
436      },
437    })
438    if (error) {
439      return {
440        ok: false,
441        reason: 'settings-write-failed',
442        message: error.message,
443      }
444    }
445  
446    // ── Materialize: cache each closure member ──
447    const projectPath = scope !== 'user' ? getCwd() : undefined
448    for (const id of resolution.closure) {
449      let info = depInfo.get(id)
450      // Root wasn't pre-seeded (caller didn't pass marketplaceInstallLocation
451      // for a non-local source). Fetch now; it's needed for the cache write.
452      if (!info && id === pluginId) {
453        const mktLocation = (await getPluginById(id))?.marketplaceInstallLocation
454        if (mktLocation) info = { entry, marketplaceInstallLocation: mktLocation }
455      }
456      if (!info) continue
457  
458      let localSourcePath: string | undefined
459      const { source } = info.entry
460      if (isLocalPluginSource(source)) {
461        localSourcePath = validatePathWithinBase(
462          info.marketplaceInstallLocation,
463          source,
464        )
465      }
466      await cacheAndRegisterPlugin(
467        id,
468        info.entry,
469        scope,
470        projectPath,
471        localSourcePath,
472      )
473    }
474  
475    clearAllCaches()
476  
477    const depNote = formatDependencyCountSuffix(
478      resolution.closure.filter(id => id !== pluginId),
479    )
480    return { ok: true, closure: resolution.closure, depNote }
481  }
482  
483  /**
484   * Result of a plugin installation operation
485   */
486  export type InstallPluginResult =
487    | { success: true; message: string }
488    | { success: false; error: string }
489  
490  /**
491   * Parameters for installing a plugin from marketplace
492   */
493  export type InstallPluginParams = {
494    pluginId: string
495    entry: PluginMarketplaceEntry
496    marketplaceName: string
497    scope?: 'user' | 'project' | 'local'
498    trigger?: 'hint' | 'user'
499  }
500  
501  /**
502   * Install a single plugin from a marketplace with the specified scope.
503   * Interactive-UI wrapper around `installResolvedPlugin` — adds try/catch,
504   * analytics, and UI-style message formatting.
505   */
506  export async function installPluginFromMarketplace({
507    pluginId,
508    entry,
509    marketplaceName,
510    scope = 'user',
511    trigger = 'user',
512  }: InstallPluginParams): Promise<InstallPluginResult> {
513    try {
514      // Look up the marketplace install location for local-source plugins.
515      // Without this, plugins with relative-path sources fail from the
516      // interactive UI path (/plugin install) even though the CLI path works.
517      const pluginInfo = await getPluginById(pluginId)
518      const marketplaceInstallLocation = pluginInfo?.marketplaceInstallLocation
519  
520      const result = await installResolvedPlugin({
521        pluginId,
522        entry,
523        scope,
524        marketplaceInstallLocation,
525      })
526  
527      if (!result.ok) {
528        switch (result.reason) {
529          case 'local-source-no-location':
530            return {
531              success: false,
532              error: `Cannot install local plugin "${result.pluginName}" without marketplace install location`,
533            }
534          case 'settings-write-failed':
535            return {
536              success: false,
537              error: `Failed to update settings: ${result.message}`,
538            }
539          case 'resolution-failed':
540            return {
541              success: false,
542              error: formatResolutionError(result.resolution),
543            }
544          case 'blocked-by-policy':
545            return {
546              success: false,
547              error: `Plugin "${result.pluginName}" is blocked by your organization's policy and cannot be installed`,
548            }
549          case 'dependency-blocked-by-policy':
550            return {
551              success: false,
552              error: `Cannot install "${result.pluginName}": dependency "${result.blockedDependency}" is blocked by your organization's policy`,
553            }
554        }
555      }
556  
557      // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
558      // plugin_id kept in additional_metadata (redacted to 'third-party' for
559      // non-official) because dbt external_claude_code_plugin_installs.sql
560      // extracts $.plugin_id for official-marketplace install tracking. Other
561      // plugin lifecycle events drop the blob key — no downstream consumers.
562      logEvent('tengu_plugin_installed', {
563        _PROTO_plugin_name:
564          entry.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
565        _PROTO_marketplace_name:
566          marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
567        plugin_id: (isOfficialMarketplaceName(marketplaceName)
568          ? pluginId
569          : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
570        trigger:
571          trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
572        install_source: (trigger === 'hint'
573          ? 'ui-suggestion'
574          : 'ui-discover') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
575        ...buildPluginTelemetryFields(
576          entry.name,
577          marketplaceName,
578          getManagedPluginNames(),
579        ),
580        ...(entry.version && {
581          version:
582            entry.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
583        }),
584      })
585  
586      return {
587        success: true,
588        message: `✓ Installed ${entry.name}${result.depNote}. Run /reload-plugins to activate.`,
589      }
590    } catch (err) {
591      const errorMessage = err instanceof Error ? err.message : String(err)
592      logError(toError(err))
593      return { success: false, error: `Failed to install: ${errorMessage}` }
594    }
595  }