| import isEqual from 'lodash-es/isEqual.js' |
| import { toError } from '../errors.js' |
| import { logError } from '../log.js' |
| import { getSettingsForSource } from '../settings/settings.js' |
| import { plural } from '../stringUtils.js' |
| import { checkGitAvailable } from './gitAvailability.js' |
| import { getMarketplace } from './marketplaceManager.js' |
| import type { KnownMarketplace, MarketplaceSource } from './schemas.js' |
|
|
| |
| |
| |
| |
| |
| |
| export function formatFailureDetails( |
| failures: Array<{ name: string; reason?: string; error?: string }>, |
| includeReasons: boolean, |
| ): string { |
| const maxShow = 2 |
| const details = failures |
| .slice(0, maxShow) |
| .map(f => { |
| const reason = f.reason || f.error || 'unknown error' |
| return includeReasons ? `${f.name} (${reason})` : f.name |
| }) |
| .join(includeReasons ? '; ' : ', ') |
|
|
| const remaining = failures.length - maxShow |
| const moreText = remaining > 0 ? ` and ${remaining} more` : '' |
|
|
| return `${details}${moreText}` |
| } |
|
|
| |
| |
| |
| export function getMarketplaceSourceDisplay(source: MarketplaceSource): string { |
| switch (source.source) { |
| case 'github': |
| return source.repo |
| case 'url': |
| return source.url |
| case 'git': |
| return source.url |
| case 'directory': |
| return source.path |
| case 'file': |
| return source.path |
| case 'settings': |
| return `settings:${source.name}` |
| default: |
| return 'Unknown source' |
| } |
| } |
|
|
| |
| |
| |
| export function createPluginId( |
| pluginName: string, |
| marketplaceName: string, |
| ): string { |
| return `${pluginName}@${marketplaceName}` |
| } |
|
|
| |
| |
| |
| |
| export async function loadMarketplacesWithGracefulDegradation( |
| config: Record<string, KnownMarketplace>, |
| ): Promise<{ |
| marketplaces: Array<{ |
| name: string |
| config: KnownMarketplace |
| data: Awaited<ReturnType<typeof getMarketplace>> | null |
| }> |
| failures: Array<{ name: string; error: string }> |
| }> { |
| const marketplaces: Array<{ |
| name: string |
| config: KnownMarketplace |
| data: Awaited<ReturnType<typeof getMarketplace>> | null |
| }> = [] |
| const failures: Array<{ name: string; error: string }> = [] |
|
|
| for (const [name, marketplaceConfig] of Object.entries(config)) { |
| |
| if (!isSourceAllowedByPolicy(marketplaceConfig.source)) { |
| continue |
| } |
|
|
| let data = null |
| try { |
| data = await getMarketplace(name) |
| } catch (err) { |
| |
| const errorMessage = err instanceof Error ? err.message : String(err) |
| failures.push({ name, error: errorMessage }) |
|
|
| |
| logError(toError(err)) |
| } |
|
|
| marketplaces.push({ |
| name, |
| config: marketplaceConfig, |
| data, |
| }) |
| } |
|
|
| return { marketplaces, failures } |
| } |
|
|
| |
| |
| |
| export function formatMarketplaceLoadingErrors( |
| failures: Array<{ name: string; error: string }>, |
| successCount: number, |
| ): { type: 'warning' | 'error'; message: string } | null { |
| if (failures.length === 0) { |
| return null |
| } |
|
|
| |
| if (successCount > 0) { |
| const message = |
| failures.length === 1 |
| ? `Warning: Failed to load marketplace '${failures[0]!.name}': ${failures[0]!.error}` |
| : `Warning: Failed to load ${failures.length} marketplaces: ${formatFailureNames(failures)}` |
| return { type: 'warning', message } |
| } |
|
|
| |
| return { |
| type: 'error', |
| message: `Failed to load all marketplaces. Errors: ${formatFailureErrors(failures)}`, |
| } |
| } |
|
|
| function formatFailureNames( |
| failures: Array<{ name: string; error: string }>, |
| ): string { |
| return failures.map(f => f.name).join(', ') |
| } |
|
|
| function formatFailureErrors( |
| failures: Array<{ name: string; error: string }>, |
| ): string { |
| return failures.map(f => `${f.name}: ${f.error}`).join('; ') |
| } |
|
|
| |
| |
| |
| |
| export function getStrictKnownMarketplaces(): MarketplaceSource[] | null { |
| const policySettings = getSettingsForSource('policySettings') |
| if (!policySettings?.strictKnownMarketplaces) { |
| return null |
| } |
| return policySettings.strictKnownMarketplaces |
| } |
|
|
| |
| |
| |
| |
| export function getBlockedMarketplaces(): MarketplaceSource[] | null { |
| const policySettings = getSettingsForSource('policySettings') |
| if (!policySettings?.blockedMarketplaces) { |
| return null |
| } |
| return policySettings.blockedMarketplaces |
| } |
|
|
| |
| |
| |
| |
| export function getPluginTrustMessage(): string | undefined { |
| return getSettingsForSource('policySettings')?.pluginTrustMessage |
| } |
|
|
| |
| |
| |
| |
| function areSourcesEqual(a: MarketplaceSource, b: MarketplaceSource): boolean { |
| if (a.source !== b.source) return false |
|
|
| switch (a.source) { |
| case 'url': |
| return a.url === (b as typeof a).url |
| case 'github': |
| return ( |
| a.repo === (b as typeof a).repo && |
| (a.ref || undefined) === ((b as typeof a).ref || undefined) && |
| (a.path || undefined) === ((b as typeof a).path || undefined) |
| ) |
| case 'git': |
| return ( |
| a.url === (b as typeof a).url && |
| (a.ref || undefined) === ((b as typeof a).ref || undefined) && |
| (a.path || undefined) === ((b as typeof a).path || undefined) |
| ) |
| case 'npm': |
| return a.package === (b as typeof a).package |
| case 'file': |
| return a.path === (b as typeof a).path |
| case 'directory': |
| return a.path === (b as typeof a).path |
| case 'settings': |
| return ( |
| a.name === (b as typeof a).name && |
| isEqual(a.plugins, (b as typeof a).plugins) |
| ) |
| default: |
| return false |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function extractHostFromSource( |
| source: MarketplaceSource, |
| ): string | null { |
| switch (source.source) { |
| case 'github': |
| |
| return 'github.com' |
|
|
| case 'git': { |
| |
| const sshMatch = source.url.match(/^[^@]+@([^:]+):/) |
| if (sshMatch?.[1]) { |
| return sshMatch[1] |
| } |
| |
| try { |
| return new URL(source.url).hostname |
| } catch { |
| return null |
| } |
| } |
|
|
| case 'url': |
| try { |
| return new URL(source.url).hostname |
| } catch { |
| return null |
| } |
|
|
| |
| default: |
| return null |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function doesSourceMatchHostPattern( |
| source: MarketplaceSource, |
| pattern: MarketplaceSource & { source: 'hostPattern' }, |
| ): boolean { |
| const host = extractHostFromSource(source) |
| if (!host) { |
| return false |
| } |
|
|
| try { |
| const regex = new RegExp(pattern.hostPattern) |
| return regex.test(host) |
| } catch { |
| |
| logError(new Error(`Invalid hostPattern regex: ${pattern.hostPattern}`)) |
| return false |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function doesSourceMatchPathPattern( |
| source: MarketplaceSource, |
| pattern: MarketplaceSource & { source: 'pathPattern' }, |
| ): boolean { |
| |
| if (source.source !== 'file' && source.source !== 'directory') { |
| return false |
| } |
|
|
| try { |
| const regex = new RegExp(pattern.pathPattern) |
| return regex.test(source.path) |
| } catch { |
| logError(new Error(`Invalid pathPattern regex: ${pattern.pathPattern}`)) |
| return false |
| } |
| } |
|
|
| |
| |
| |
| |
| export function getHostPatternsFromAllowlist(): string[] { |
| const allowlist = getStrictKnownMarketplaces() |
| if (!allowlist) return [] |
|
|
| return allowlist |
| .filter( |
| (entry): entry is MarketplaceSource & { source: 'hostPattern' } => |
| entry.source === 'hostPattern', |
| ) |
| .map(entry => entry.hostPattern) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function extractGitHubRepoFromGitUrl(url: string): string | null { |
| |
| const sshMatch = url.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/) |
| if (sshMatch && sshMatch[1]) { |
| return sshMatch[1] |
| } |
|
|
| |
| const httpsMatch = url.match( |
| /^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/, |
| ) |
| if (httpsMatch && httpsMatch[1]) { |
| return httpsMatch[1] |
| } |
|
|
| return null |
| } |
|
|
| |
| |
| |
| |
| |
| function blockedConstraintMatches( |
| blockedValue: string | undefined, |
| sourceValue: string | undefined, |
| ): boolean { |
| |
| if (!blockedValue) { |
| return true |
| } |
| |
| return (blockedValue || undefined) === (sourceValue || undefined) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function areSourcesEquivalentForBlocklist( |
| source: MarketplaceSource, |
| blocked: MarketplaceSource, |
| ): boolean { |
| |
| if (source.source === blocked.source) { |
| switch (source.source) { |
| case 'github': { |
| const b = blocked as typeof source |
| if (source.repo !== b.repo) return false |
| return ( |
| blockedConstraintMatches(b.ref, source.ref) && |
| blockedConstraintMatches(b.path, source.path) |
| ) |
| } |
| case 'git': { |
| const b = blocked as typeof source |
| if (source.url !== b.url) return false |
| return ( |
| blockedConstraintMatches(b.ref, source.ref) && |
| blockedConstraintMatches(b.path, source.path) |
| ) |
| } |
| case 'url': |
| return source.url === (blocked as typeof source).url |
| case 'npm': |
| return source.package === (blocked as typeof source).package |
| case 'file': |
| return source.path === (blocked as typeof source).path |
| case 'directory': |
| return source.path === (blocked as typeof source).path |
| case 'settings': |
| return source.name === (blocked as typeof source).name |
| default: |
| return false |
| } |
| } |
|
|
| |
| if (source.source === 'git' && blocked.source === 'github') { |
| const extractedRepo = extractGitHubRepoFromGitUrl(source.url) |
| if (extractedRepo === blocked.repo) { |
| return ( |
| blockedConstraintMatches(blocked.ref, source.ref) && |
| blockedConstraintMatches(blocked.path, source.path) |
| ) |
| } |
| } |
|
|
| |
| if (source.source === 'github' && blocked.source === 'git') { |
| const extractedRepo = extractGitHubRepoFromGitUrl(blocked.url) |
| if (extractedRepo === source.repo) { |
| return ( |
| blockedConstraintMatches(blocked.ref, source.ref) && |
| blockedConstraintMatches(blocked.path, source.path) |
| ) |
| } |
| } |
|
|
| return false |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function isSourceInBlocklist(source: MarketplaceSource): boolean { |
| const blocklist = getBlockedMarketplaces() |
| if (blocklist === null) { |
| return false |
| } |
| return blocklist.some(blocked => |
| areSourcesEquivalentForBlocklist(source, blocked), |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function isSourceAllowedByPolicy(source: MarketplaceSource): boolean { |
| |
| if (isSourceInBlocklist(source)) { |
| return false |
| } |
|
|
| |
| const allowlist = getStrictKnownMarketplaces() |
| if (allowlist === null) { |
| return true |
| } |
|
|
| |
| return allowlist.some(allowed => { |
| |
| if (allowed.source === 'hostPattern') { |
| return doesSourceMatchHostPattern(source, allowed) |
| } |
| |
| if (allowed.source === 'pathPattern') { |
| return doesSourceMatchPathPattern(source, allowed) |
| } |
| |
| return areSourcesEqual(source, allowed) |
| }) |
| } |
|
|
| |
| |
| |
| export function formatSourceForDisplay(source: MarketplaceSource): string { |
| switch (source.source) { |
| case 'github': |
| return `github:${source.repo}${source.ref ? `@${source.ref}` : ''}` |
| case 'url': |
| return source.url |
| case 'git': |
| return `git:${source.url}${source.ref ? `@${source.ref}` : ''}` |
| case 'npm': |
| return `npm:${source.package}` |
| case 'file': |
| return `file:${source.path}` |
| case 'directory': |
| return `dir:${source.path}` |
| case 'hostPattern': |
| return `hostPattern:${source.hostPattern}` |
| case 'pathPattern': |
| return `pathPattern:${source.pathPattern}` |
| case 'settings': |
| return `settings:${source.name} (${source.plugins.length} ${plural(source.plugins.length, 'plugin')})` |
| default: |
| return 'unknown source' |
| } |
| } |
|
|
| |
| |
| |
| export type EmptyMarketplaceReason = |
| | 'git-not-installed' |
| | 'all-blocked-by-policy' |
| | 'policy-restricts-sources' |
| | 'all-marketplaces-failed' |
| | 'no-marketplaces-configured' |
| | 'all-plugins-installed' |
|
|
| |
| |
| |
| |
| export async function detectEmptyMarketplaceReason({ |
| configuredMarketplaceCount, |
| failedMarketplaceCount, |
| }: { |
| configuredMarketplaceCount: number |
| failedMarketplaceCount: number |
| }): Promise<EmptyMarketplaceReason> { |
| |
| const gitAvailable = await checkGitAvailable() |
| if (!gitAvailable) { |
| return 'git-not-installed' |
| } |
|
|
| |
| const allowlist = getStrictKnownMarketplaces() |
| if (allowlist !== null) { |
| if (allowlist.length === 0) { |
| |
| return 'all-blocked-by-policy' |
| } |
| |
| if (configuredMarketplaceCount === 0) { |
| return 'policy-restricts-sources' |
| } |
| } |
|
|
| |
| if (configuredMarketplaceCount === 0) { |
| return 'no-marketplaces-configured' |
| } |
|
|
| |
| if ( |
| failedMarketplaceCount > 0 && |
| failedMarketplaceCount === configuredMarketplaceCount |
| ) { |
| return 'all-marketplaces-failed' |
| } |
|
|
| |
| |
| return 'all-plugins-installed' |
| } |
|
|