/ utils / plugins / marketplaceHelpers.ts
marketplaceHelpers.ts
  1  import isEqual from 'lodash-es/isEqual.js'
  2  import { toError } from '../errors.js'
  3  import { logError } from '../log.js'
  4  import { getSettingsForSource } from '../settings/settings.js'
  5  import { plural } from '../stringUtils.js'
  6  import { checkGitAvailable } from './gitAvailability.js'
  7  import { getMarketplace } from './marketplaceManager.js'
  8  import type { KnownMarketplace, MarketplaceSource } from './schemas.js'
  9  
 10  /**
 11   * Format plugin failure details for user display
 12   * @param failures - Array of failures with names and reasons
 13   * @param includeReasons - Whether to include failure reasons (true for full errors, false for summaries)
 14   * @returns Formatted string like "plugin-a (reason); plugin-b (reason)" or "plugin-a, plugin-b"
 15   */
 16  export function formatFailureDetails(
 17    failures: Array<{ name: string; reason?: string; error?: string }>,
 18    includeReasons: boolean,
 19  ): string {
 20    const maxShow = 2
 21    const details = failures
 22      .slice(0, maxShow)
 23      .map(f => {
 24        const reason = f.reason || f.error || 'unknown error'
 25        return includeReasons ? `${f.name} (${reason})` : f.name
 26      })
 27      .join(includeReasons ? '; ' : ', ')
 28  
 29    const remaining = failures.length - maxShow
 30    const moreText = remaining > 0 ? ` and ${remaining} more` : ''
 31  
 32    return `${details}${moreText}`
 33  }
 34  
 35  /**
 36   * Extract source display string from marketplace configuration
 37   */
 38  export function getMarketplaceSourceDisplay(source: MarketplaceSource): string {
 39    switch (source.source) {
 40      case 'github':
 41        return source.repo
 42      case 'url':
 43        return source.url
 44      case 'git':
 45        return source.url
 46      case 'directory':
 47        return source.path
 48      case 'file':
 49        return source.path
 50      case 'settings':
 51        return `settings:${source.name}`
 52      default:
 53        return 'Unknown source'
 54    }
 55  }
 56  
 57  /**
 58   * Create a plugin ID from plugin name and marketplace name
 59   */
 60  export function createPluginId(
 61    pluginName: string,
 62    marketplaceName: string,
 63  ): string {
 64    return `${pluginName}@${marketplaceName}`
 65  }
 66  
 67  /**
 68   * Load marketplaces with graceful degradation for individual failures.
 69   * Blocked marketplaces (per enterprise policy) are excluded from the results.
 70   */
 71  export async function loadMarketplacesWithGracefulDegradation(
 72    config: Record<string, KnownMarketplace>,
 73  ): Promise<{
 74    marketplaces: Array<{
 75      name: string
 76      config: KnownMarketplace
 77      data: Awaited<ReturnType<typeof getMarketplace>> | null
 78    }>
 79    failures: Array<{ name: string; error: string }>
 80  }> {
 81    const marketplaces: Array<{
 82      name: string
 83      config: KnownMarketplace
 84      data: Awaited<ReturnType<typeof getMarketplace>> | null
 85    }> = []
 86    const failures: Array<{ name: string; error: string }> = []
 87  
 88    for (const [name, marketplaceConfig] of Object.entries(config)) {
 89      // Skip marketplaces blocked by enterprise policy
 90      if (!isSourceAllowedByPolicy(marketplaceConfig.source)) {
 91        continue
 92      }
 93  
 94      let data = null
 95      try {
 96        data = await getMarketplace(name)
 97      } catch (err) {
 98        // Track individual marketplace failures but continue loading others
 99        const errorMessage = err instanceof Error ? err.message : String(err)
100        failures.push({ name, error: errorMessage })
101  
102        // Log for monitoring
103        logError(toError(err))
104      }
105  
106      marketplaces.push({
107        name,
108        config: marketplaceConfig,
109        data,
110      })
111    }
112  
113    return { marketplaces, failures }
114  }
115  
116  /**
117   * Format marketplace loading failures into appropriate user messages
118   */
119  export function formatMarketplaceLoadingErrors(
120    failures: Array<{ name: string; error: string }>,
121    successCount: number,
122  ): { type: 'warning' | 'error'; message: string } | null {
123    if (failures.length === 0) {
124      return null
125    }
126  
127    // If some marketplaces succeeded, show warning
128    if (successCount > 0) {
129      const message =
130        failures.length === 1
131          ? `Warning: Failed to load marketplace '${failures[0]!.name}': ${failures[0]!.error}`
132          : `Warning: Failed to load ${failures.length} marketplaces: ${formatFailureNames(failures)}`
133      return { type: 'warning', message }
134    }
135  
136    // All marketplaces failed - this is a critical error
137    return {
138      type: 'error',
139      message: `Failed to load all marketplaces. Errors: ${formatFailureErrors(failures)}`,
140    }
141  }
142  
143  function formatFailureNames(
144    failures: Array<{ name: string; error: string }>,
145  ): string {
146    return failures.map(f => f.name).join(', ')
147  }
148  
149  function formatFailureErrors(
150    failures: Array<{ name: string; error: string }>,
151  ): string {
152    return failures.map(f => `${f.name}: ${f.error}`).join('; ')
153  }
154  
155  /**
156   * Get the strict marketplace source allowlist from policy settings.
157   * Returns null if no restriction is in place, or an array of allowed sources.
158   */
159  export function getStrictKnownMarketplaces(): MarketplaceSource[] | null {
160    const policySettings = getSettingsForSource('policySettings')
161    if (!policySettings?.strictKnownMarketplaces) {
162      return null // No restrictions
163    }
164    return policySettings.strictKnownMarketplaces
165  }
166  
167  /**
168   * Get the marketplace source blocklist from policy settings.
169   * Returns null if no blocklist is in place, or an array of blocked sources.
170   */
171  export function getBlockedMarketplaces(): MarketplaceSource[] | null {
172    const policySettings = getSettingsForSource('policySettings')
173    if (!policySettings?.blockedMarketplaces) {
174      return null // No blocklist
175    }
176    return policySettings.blockedMarketplaces
177  }
178  
179  /**
180   * Get the custom plugin trust message from policy settings.
181   * Returns undefined if not configured.
182   */
183  export function getPluginTrustMessage(): string | undefined {
184    return getSettingsForSource('policySettings')?.pluginTrustMessage
185  }
186  
187  /**
188   * Compare two MarketplaceSource objects for equality.
189   * Sources are equal if they have the same type and all relevant fields match.
190   */
191  function areSourcesEqual(a: MarketplaceSource, b: MarketplaceSource): boolean {
192    if (a.source !== b.source) return false
193  
194    switch (a.source) {
195      case 'url':
196        return a.url === (b as typeof a).url
197      case 'github':
198        return (
199          a.repo === (b as typeof a).repo &&
200          (a.ref || undefined) === ((b as typeof a).ref || undefined) &&
201          (a.path || undefined) === ((b as typeof a).path || undefined)
202        )
203      case 'git':
204        return (
205          a.url === (b as typeof a).url &&
206          (a.ref || undefined) === ((b as typeof a).ref || undefined) &&
207          (a.path || undefined) === ((b as typeof a).path || undefined)
208        )
209      case 'npm':
210        return a.package === (b as typeof a).package
211      case 'file':
212        return a.path === (b as typeof a).path
213      case 'directory':
214        return a.path === (b as typeof a).path
215      case 'settings':
216        return (
217          a.name === (b as typeof a).name &&
218          isEqual(a.plugins, (b as typeof a).plugins)
219        )
220      default:
221        return false
222    }
223  }
224  
225  /**
226   * Extract the host/domain from a marketplace source.
227   * Used for hostPattern matching in strictKnownMarketplaces.
228   *
229   * Currently only supports github, git, and url sources.
230   * npm, file, and directory sources are not supported for hostPattern matching.
231   *
232   * @param source - The marketplace source to extract host from
233   * @returns The hostname string, or null if extraction fails or source type not supported
234   */
235  export function extractHostFromSource(
236    source: MarketplaceSource,
237  ): string | null {
238    switch (source.source) {
239      case 'github':
240        // GitHub shorthand always means github.com
241        return 'github.com'
242  
243      case 'git': {
244        // SSH format: user@HOST:path (e.g., git@github.com:owner/repo.git)
245        const sshMatch = source.url.match(/^[^@]+@([^:]+):/)
246        if (sshMatch?.[1]) {
247          return sshMatch[1]
248        }
249        // HTTPS format: extract hostname from URL
250        try {
251          return new URL(source.url).hostname
252        } catch {
253          return null
254        }
255      }
256  
257      case 'url':
258        try {
259          return new URL(source.url).hostname
260        } catch {
261          return null
262        }
263  
264      // npm, file, directory, hostPattern, pathPattern sources are not supported for hostPattern matching
265      default:
266        return null
267    }
268  }
269  
270  /**
271   * Check if a source matches a hostPattern entry.
272   * Extracts the host from the source and tests it against the regex pattern.
273   *
274   * @param source - The marketplace source to check
275   * @param pattern - The hostPattern entry from strictKnownMarketplaces
276   * @returns true if the source's host matches the pattern
277   */
278  function doesSourceMatchHostPattern(
279    source: MarketplaceSource,
280    pattern: MarketplaceSource & { source: 'hostPattern' },
281  ): boolean {
282    const host = extractHostFromSource(source)
283    if (!host) {
284      return false
285    }
286  
287    try {
288      const regex = new RegExp(pattern.hostPattern)
289      return regex.test(host)
290    } catch {
291      // Invalid regex - log and return false
292      logError(new Error(`Invalid hostPattern regex: ${pattern.hostPattern}`))
293      return false
294    }
295  }
296  
297  /**
298   * Check if a source matches a pathPattern entry.
299   * Tests the source's .path (file and directory sources only) against the regex pattern.
300   *
301   * @param source - The marketplace source to check
302   * @param pattern - The pathPattern entry from strictKnownMarketplaces
303   * @returns true if the source's path matches the pattern
304   */
305  function doesSourceMatchPathPattern(
306    source: MarketplaceSource,
307    pattern: MarketplaceSource & { source: 'pathPattern' },
308  ): boolean {
309    // Only file and directory sources have a .path to match against
310    if (source.source !== 'file' && source.source !== 'directory') {
311      return false
312    }
313  
314    try {
315      const regex = new RegExp(pattern.pathPattern)
316      return regex.test(source.path)
317    } catch {
318      logError(new Error(`Invalid pathPattern regex: ${pattern.pathPattern}`))
319      return false
320    }
321  }
322  
323  /**
324   * Get hosts from hostPattern entries in the allowlist.
325   * Used to provide helpful error messages.
326   */
327  export function getHostPatternsFromAllowlist(): string[] {
328    const allowlist = getStrictKnownMarketplaces()
329    if (!allowlist) return []
330  
331    return allowlist
332      .filter(
333        (entry): entry is MarketplaceSource & { source: 'hostPattern' } =>
334          entry.source === 'hostPattern',
335      )
336      .map(entry => entry.hostPattern)
337  }
338  
339  /**
340   * Extract GitHub owner/repo from a git URL if it's a GitHub URL.
341   * Returns null if not a GitHub URL.
342   *
343   * Handles:
344   * - git@github.com:owner/repo.git
345   * - https://github.com/owner/repo.git
346   * - https://github.com/owner/repo
347   */
348  function extractGitHubRepoFromGitUrl(url: string): string | null {
349    // SSH format: git@github.com:owner/repo.git
350    const sshMatch = url.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/)
351    if (sshMatch && sshMatch[1]) {
352      return sshMatch[1]
353    }
354  
355    // HTTPS format: https://github.com/owner/repo.git or https://github.com/owner/repo
356    const httpsMatch = url.match(
357      /^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/,
358    )
359    if (httpsMatch && httpsMatch[1]) {
360      return httpsMatch[1]
361    }
362  
363    return null
364  }
365  
366  /**
367   * Check if a blocked ref/path constraint matches a source.
368   * If the blocklist entry has no ref/path, it matches ALL refs/paths (wildcard).
369   * If the blocklist entry has a specific ref/path, it only matches that exact value.
370   */
371  function blockedConstraintMatches(
372    blockedValue: string | undefined,
373    sourceValue: string | undefined,
374  ): boolean {
375    // If blocklist doesn't specify a constraint, it's a wildcard - matches anything
376    if (!blockedValue) {
377      return true
378    }
379    // If blocklist specifies a constraint, source must match exactly
380    return (blockedValue || undefined) === (sourceValue || undefined)
381  }
382  
383  /**
384   * Check if two sources refer to the same GitHub repository, even if using
385   * different source types (github vs git with GitHub URL).
386   *
387   * Blocklist matching is asymmetric:
388   * - If blocklist entry has no ref/path, it blocks ALL refs/paths (wildcard)
389   * - If blocklist entry has a specific ref/path, only that exact value is blocked
390   */
391  function areSourcesEquivalentForBlocklist(
392    source: MarketplaceSource,
393    blocked: MarketplaceSource,
394  ): boolean {
395    // Check exact same source type
396    if (source.source === blocked.source) {
397      switch (source.source) {
398        case 'github': {
399          const b = blocked as typeof source
400          if (source.repo !== b.repo) return false
401          return (
402            blockedConstraintMatches(b.ref, source.ref) &&
403            blockedConstraintMatches(b.path, source.path)
404          )
405        }
406        case 'git': {
407          const b = blocked as typeof source
408          if (source.url !== b.url) return false
409          return (
410            blockedConstraintMatches(b.ref, source.ref) &&
411            blockedConstraintMatches(b.path, source.path)
412          )
413        }
414        case 'url':
415          return source.url === (blocked as typeof source).url
416        case 'npm':
417          return source.package === (blocked as typeof source).package
418        case 'file':
419          return source.path === (blocked as typeof source).path
420        case 'directory':
421          return source.path === (blocked as typeof source).path
422        case 'settings':
423          return source.name === (blocked as typeof source).name
424        default:
425          return false
426      }
427    }
428  
429    // Check if a git source matches a github blocklist entry
430    if (source.source === 'git' && blocked.source === 'github') {
431      const extractedRepo = extractGitHubRepoFromGitUrl(source.url)
432      if (extractedRepo === blocked.repo) {
433        return (
434          blockedConstraintMatches(blocked.ref, source.ref) &&
435          blockedConstraintMatches(blocked.path, source.path)
436        )
437      }
438    }
439  
440    // Check if a github source matches a git blocklist entry (GitHub URL)
441    if (source.source === 'github' && blocked.source === 'git') {
442      const extractedRepo = extractGitHubRepoFromGitUrl(blocked.url)
443      if (extractedRepo === source.repo) {
444        return (
445          blockedConstraintMatches(blocked.ref, source.ref) &&
446          blockedConstraintMatches(blocked.path, source.path)
447        )
448      }
449    }
450  
451    return false
452  }
453  
454  /**
455   * Check if a marketplace source is explicitly in the blocklist.
456   * Used for error message differentiation.
457   *
458   * This also catches attempts to bypass a github blocklist entry by using
459   * git URLs (e.g., git@github.com:owner/repo.git or https://github.com/owner/repo.git).
460   */
461  export function isSourceInBlocklist(source: MarketplaceSource): boolean {
462    const blocklist = getBlockedMarketplaces()
463    if (blocklist === null) {
464      return false
465    }
466    return blocklist.some(blocked =>
467      areSourcesEquivalentForBlocklist(source, blocked),
468    )
469  }
470  
471  /**
472   * Check if a marketplace source is allowed by enterprise policy.
473   * Returns true if allowed (or no policy), false if blocked.
474   * This check happens BEFORE downloading, so blocked sources never touch the filesystem.
475   *
476   * Policy precedence:
477   * 1. blockedMarketplaces (blocklist) - if source matches, it's blocked
478   * 2. strictKnownMarketplaces (allowlist) - if set, source must be in the list
479   */
480  export function isSourceAllowedByPolicy(source: MarketplaceSource): boolean {
481    // Check blocklist first (takes precedence)
482    if (isSourceInBlocklist(source)) {
483      return false
484    }
485  
486    // Then check allowlist
487    const allowlist = getStrictKnownMarketplaces()
488    if (allowlist === null) {
489      return true // No restrictions
490    }
491  
492    // Check each entry in the allowlist
493    return allowlist.some(allowed => {
494      // Handle hostPattern entries - match by extracted host
495      if (allowed.source === 'hostPattern') {
496        return doesSourceMatchHostPattern(source, allowed)
497      }
498      // Handle pathPattern entries - match file/directory .path by regex
499      if (allowed.source === 'pathPattern') {
500        return doesSourceMatchPathPattern(source, allowed)
501      }
502      // Handle regular source entries - exact match
503      return areSourcesEqual(source, allowed)
504    })
505  }
506  
507  /**
508   * Format a MarketplaceSource for display in error messages
509   */
510  export function formatSourceForDisplay(source: MarketplaceSource): string {
511    switch (source.source) {
512      case 'github':
513        return `github:${source.repo}${source.ref ? `@${source.ref}` : ''}`
514      case 'url':
515        return source.url
516      case 'git':
517        return `git:${source.url}${source.ref ? `@${source.ref}` : ''}`
518      case 'npm':
519        return `npm:${source.package}`
520      case 'file':
521        return `file:${source.path}`
522      case 'directory':
523        return `dir:${source.path}`
524      case 'hostPattern':
525        return `hostPattern:${source.hostPattern}`
526      case 'pathPattern':
527        return `pathPattern:${source.pathPattern}`
528      case 'settings':
529        return `settings:${source.name} (${source.plugins.length} ${plural(source.plugins.length, 'plugin')})`
530      default:
531        return 'unknown source'
532    }
533  }
534  
535  /**
536   * Reasons why no marketplaces are available in the Discover screen
537   */
538  export type EmptyMarketplaceReason =
539    | 'git-not-installed'
540    | 'all-blocked-by-policy'
541    | 'policy-restricts-sources'
542    | 'all-marketplaces-failed'
543    | 'no-marketplaces-configured'
544    | 'all-plugins-installed'
545  
546  /**
547   * Detect why no marketplaces are available.
548   * Checks in order of priority: git availability → policy restrictions → config state → failures
549   */
550  export async function detectEmptyMarketplaceReason({
551    configuredMarketplaceCount,
552    failedMarketplaceCount,
553  }: {
554    configuredMarketplaceCount: number
555    failedMarketplaceCount: number
556  }): Promise<EmptyMarketplaceReason> {
557    // Check if git is installed (required for most marketplace sources)
558    const gitAvailable = await checkGitAvailable()
559    if (!gitAvailable) {
560      return 'git-not-installed'
561    }
562  
563    // Check policy restrictions
564    const allowlist = getStrictKnownMarketplaces()
565    if (allowlist !== null) {
566      if (allowlist.length === 0) {
567        // Policy explicitly blocks all marketplaces
568        return 'all-blocked-by-policy'
569      }
570      // Policy restricts which sources can be used
571      if (configuredMarketplaceCount === 0) {
572        return 'policy-restricts-sources'
573      }
574    }
575  
576    // Check if any marketplaces are configured
577    if (configuredMarketplaceCount === 0) {
578      return 'no-marketplaces-configured'
579    }
580  
581    // Check if all configured marketplaces failed to load
582    if (
583      failedMarketplaceCount > 0 &&
584      failedMarketplaceCount === configuredMarketplaceCount
585    ) {
586      return 'all-marketplaces-failed'
587    }
588  
589    // Marketplaces are configured and loaded, but no plugins available
590    // This typically means all plugins are already installed
591    return 'all-plugins-installed'
592  }