π File detail
utils/plugins/marketplaceHelpers.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes formatFailureDetails, getMarketplaceSourceDisplay, createPluginId, loadMarketplacesWithGracefulDegradation, and formatMarketplaceLoadingErrors (and more) β mainly functions, hooks, or classes. Dependencies touch lodash-es. It composes internal code from errors, log, settings, stringUtils, and gitAvailability (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
formatFailureDetailsgetMarketplaceSourceDisplaycreatePluginIdloadMarketplacesWithGracefulDegradationformatMarketplaceLoadingErrorsgetStrictKnownMarketplacesgetBlockedMarketplacesgetPluginTrustMessageextractHostFromSourcegetHostPatternsFromAllowlistisSourceInBlocklistisSourceAllowedByPolicyformatSourceForDisplayEmptyMarketplaceReasondetectEmptyMarketplaceReason
π External import roots
Package roots from from "β¦" (relative paths omitted).
lodash-es
π₯οΈ Source preview
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'
/**
* Format plugin failure details for user display
* @param failures - Array of failures with names and reasons
* @param includeReasons - Whether to include failure reasons (true for full errors, false for summaries)
* @returns Formatted string like "plugin-a (reason); plugin-b (reason)" or "plugin-a, plugin-b"
*/
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}`
}
/**
* Extract source display string from marketplace configuration
*/
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'
}
}
/**
* Create a plugin ID from plugin name and marketplace name
*/
export function createPluginId(
pluginName: string,
marketplaceName: string,
): string {
return `${pluginName}@${marketplaceName}`
}
/**
* Load marketplaces with graceful degradation for individual failures.
* Blocked marketplaces (per enterprise policy) are excluded from the results.
*/
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)) {
// Skip marketplaces blocked by enterprise policy
if (!isSourceAllowedByPolicy(marketplaceConfig.source)) {
continue
}
let data = null
try {
data = await getMarketplace(name)
} catch (err) {
// Track individual marketplace failures but continue loading others
const errorMessage = err instanceof Error ? err.message : String(err)
failures.push({ name, error: errorMessage })
// Log for monitoring
logError(toError(err))
}
marketplaces.push({
name,
config: marketplaceConfig,
data,
})
}
return { marketplaces, failures }
}
/**
* Format marketplace loading failures into appropriate user messages
*/
export function formatMarketplaceLoadingErrors(
failures: Array<{ name: string; error: string }>,
successCount: number,
): { type: 'warning' | 'error'; message: string } | null {
if (failures.length === 0) {
return null
}
// If some marketplaces succeeded, show warning
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 }
}
// All marketplaces failed - this is a critical error
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('; ')
}
/**
* Get the strict marketplace source allowlist from policy settings.
* Returns null if no restriction is in place, or an array of allowed sources.
*/
export function getStrictKnownMarketplaces(): MarketplaceSource[] | null {
const policySettings = getSettingsForSource('policySettings')
if (!policySettings?.strictKnownMarketplaces) {
return null // No restrictions
}
return policySettings.strictKnownMarketplaces
}
/**
* Get the marketplace source blocklist from policy settings.
* Returns null if no blocklist is in place, or an array of blocked sources.
*/
export function getBlockedMarketplaces(): MarketplaceSource[] | null {
const policySettings = getSettingsForSource('policySettings')
if (!policySettings?.blockedMarketplaces) {
return null // No blocklist
}
return policySettings.blockedMarketplaces
}
/**
* Get the custom plugin trust message from policy settings.
* Returns undefined if not configured.
*/
export function getPluginTrustMessage(): string | undefined {
return getSettingsForSource('policySettings')?.pluginTrustMessage
}
/**
* Compare two MarketplaceSource objects for equality.
* Sources are equal if they have the same type and all relevant fields match.
*/
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
}
}
/**
* Extract the host/domain from a marketplace source.
* Used for hostPattern matching in strictKnownMarketplaces.
*
* Currently only supports github, git, and url sources.
* npm, file, and directory sources are not supported for hostPattern matching.
*
* @param source - The marketplace source to extract host from
* @returns The hostname string, or null if extraction fails or source type not supported
*/
export function extractHostFromSource(
source: MarketplaceSource,
): string | null {
switch (source.source) {
case 'github':
// GitHub shorthand always means github.com
return 'github.com'
case 'git': {
// SSH format: user@HOST:path (e.g., git@github.com:owner/repo.git)
const sshMatch = source.url.match(/^[^@]+@([^:]+):/)
if (sshMatch?.[1]) {
return sshMatch[1]
}
// HTTPS format: extract hostname from URL
try {
return new URL(source.url).hostname
} catch {
return null
}
}
case 'url':
try {
return new URL(source.url).hostname
} catch {
return null
}
// npm, file, directory, hostPattern, pathPattern sources are not supported for hostPattern matching
default:
return null
}
}
/**
* Check if a source matches a hostPattern entry.
* Extracts the host from the source and tests it against the regex pattern.
*
* @param source - The marketplace source to check
* @param pattern - The hostPattern entry from strictKnownMarketplaces
* @returns true if the source's host matches the pattern
*/
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 {
// Invalid regex - log and return false
logError(new Error(`Invalid hostPattern regex: ${pattern.hostPattern}`))
return false
}
}
/**
* Check if a source matches a pathPattern entry.
* Tests the source's .path (file and directory sources only) against the regex pattern.
*
* @param source - The marketplace source to check
* @param pattern - The pathPattern entry from strictKnownMarketplaces
* @returns true if the source's path matches the pattern
*/
function doesSourceMatchPathPattern(
source: MarketplaceSource,
pattern: MarketplaceSource & { source: 'pathPattern' },
): boolean {
// Only file and directory sources have a .path to match against
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
}
}
/**
* Get hosts from hostPattern entries in the allowlist.
* Used to provide helpful error messages.
*/
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)
}
/**
* Extract GitHub owner/repo from a git URL if it's a GitHub URL.
* Returns null if not a GitHub URL.
*
* Handles:
* - git@github.com:owner/repo.git
* - https://github.com/owner/repo.git
* - https://github.com/owner/repo
*/
function extractGitHubRepoFromGitUrl(url: string): string | null {
// SSH format: git@github.com:owner/repo.git
const sshMatch = url.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/)
if (sshMatch && sshMatch[1]) {
return sshMatch[1]
}
// HTTPS format: https://github.com/owner/repo.git or https://github.com/owner/repo
const httpsMatch = url.match(
/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/,
)
if (httpsMatch && httpsMatch[1]) {
return httpsMatch[1]
}
return null
}
/**
* Check if a blocked ref/path constraint matches a source.
* If the blocklist entry has no ref/path, it matches ALL refs/paths (wildcard).
* If the blocklist entry has a specific ref/path, it only matches that exact value.
*/
function blockedConstraintMatches(
blockedValue: string | undefined,
sourceValue: string | undefined,
): boolean {
// If blocklist doesn't specify a constraint, it's a wildcard - matches anything
if (!blockedValue) {
return true
}
// If blocklist specifies a constraint, source must match exactly
return (blockedValue || undefined) === (sourceValue || undefined)
}
/**
* Check if two sources refer to the same GitHub repository, even if using
* different source types (github vs git with GitHub URL).
*
* Blocklist matching is asymmetric:
* - If blocklist entry has no ref/path, it blocks ALL refs/paths (wildcard)
* - If blocklist entry has a specific ref/path, only that exact value is blocked
*/
function areSourcesEquivalentForBlocklist(
source: MarketplaceSource,
blocked: MarketplaceSource,
): boolean {
// Check exact same source type
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
}
}
// Check if a git source matches a github blocklist entry
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)
)
}
}
// Check if a github source matches a git blocklist entry (GitHub URL)
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
}
/**
* Check if a marketplace source is explicitly in the blocklist.
* Used for error message differentiation.
*
* This also catches attempts to bypass a github blocklist entry by using
* git URLs (e.g., git@github.com:owner/repo.git or https://github.com/owner/repo.git).
*/
export function isSourceInBlocklist(source: MarketplaceSource): boolean {
const blocklist = getBlockedMarketplaces()
if (blocklist === null) {
return false
}
return blocklist.some(blocked =>
areSourcesEquivalentForBlocklist(source, blocked),
)
}
/**
* Check if a marketplace source is allowed by enterprise policy.
* Returns true if allowed (or no policy), false if blocked.
* This check happens BEFORE downloading, so blocked sources never touch the filesystem.
*
* Policy precedence:
* 1. blockedMarketplaces (blocklist) - if source matches, it's blocked
* 2. strictKnownMarketplaces (allowlist) - if set, source must be in the list
*/
export function isSourceAllowedByPolicy(source: MarketplaceSource): boolean {
// Check blocklist first (takes precedence)
if (isSourceInBlocklist(source)) {
return false
}
// Then check allowlist
const allowlist = getStrictKnownMarketplaces()
if (allowlist === null) {
return true // No restrictions
}
// Check each entry in the allowlist
return allowlist.some(allowed => {
// Handle hostPattern entries - match by extracted host
if (allowed.source === 'hostPattern') {
return doesSourceMatchHostPattern(source, allowed)
}
// Handle pathPattern entries - match file/directory .path by regex
if (allowed.source === 'pathPattern') {
return doesSourceMatchPathPattern(source, allowed)
}
// Handle regular source entries - exact match
return areSourcesEqual(source, allowed)
})
}
/**
* Format a MarketplaceSource for display in error messages
*/
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'
}
}
/**
* Reasons why no marketplaces are available in the Discover screen
*/
export type EmptyMarketplaceReason =
| 'git-not-installed'
| 'all-blocked-by-policy'
| 'policy-restricts-sources'
| 'all-marketplaces-failed'
| 'no-marketplaces-configured'
| 'all-plugins-installed'
/**
* Detect why no marketplaces are available.
* Checks in order of priority: git availability β policy restrictions β config state β failures
*/
export async function detectEmptyMarketplaceReason({
configuredMarketplaceCount,
failedMarketplaceCount,
}: {
configuredMarketplaceCount: number
failedMarketplaceCount: number
}): Promise<EmptyMarketplaceReason> {
// Check if git is installed (required for most marketplace sources)
const gitAvailable = await checkGitAvailable()
if (!gitAvailable) {
return 'git-not-installed'
}
// Check policy restrictions
const allowlist = getStrictKnownMarketplaces()
if (allowlist !== null) {
if (allowlist.length === 0) {
// Policy explicitly blocks all marketplaces
return 'all-blocked-by-policy'
}
// Policy restricts which sources can be used
if (configuredMarketplaceCount === 0) {
return 'policy-restricts-sources'
}
}
// Check if any marketplaces are configured
if (configuredMarketplaceCount === 0) {
return 'no-marketplaces-configured'
}
// Check if all configured marketplaces failed to load
if (
failedMarketplaceCount > 0 &&
failedMarketplaceCount === configuredMarketplaceCount
) {
return 'all-marketplaces-failed'
}
// Marketplaces are configured and loaded, but no plugins available
// This typically means all plugins are already installed
return 'all-plugins-installed'
}