| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import axios from 'axios' |
| import { writeFile } from 'fs/promises' |
| import isEqual from 'lodash-es/isEqual.js' |
| import memoize from 'lodash-es/memoize.js' |
| import { basename, dirname, isAbsolute, join, resolve, sep } from 'path' |
| import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' |
| import { logForDebugging } from '../debug.js' |
| import { isEnvTruthy } from '../envUtils.js' |
| import { |
| ConfigParseError, |
| errorMessage, |
| getErrnoCode, |
| isENOENT, |
| toError, |
| } from '../errors.js' |
| import { execFileNoThrow, execFileNoThrowWithCwd } from '../execFileNoThrow.js' |
| import { getFsImplementation } from '../fsOperations.js' |
| import { gitExe } from '../git.js' |
| import { logError } from '../log.js' |
| import { |
| getInitialSettings, |
| getSettingsForSource, |
| updateSettingsForSource, |
| } from '../settings/settings.js' |
| import type { SettingsJson } from '../settings/types.js' |
| import { |
| jsonParse, |
| jsonStringify, |
| writeFileSync_DEPRECATED, |
| } from '../slowOperations.js' |
| import { |
| getAddDirEnabledPlugins, |
| getAddDirExtraMarketplaces, |
| } from './addDirPluginSettings.js' |
| import { markPluginVersionOrphaned } from './cacheUtils.js' |
| import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js' |
| import { removeAllPluginsForMarketplace } from './installedPluginsManager.js' |
| import { |
| extractHostFromSource, |
| formatSourceForDisplay, |
| getHostPatternsFromAllowlist, |
| getStrictKnownMarketplaces, |
| isSourceAllowedByPolicy, |
| isSourceInBlocklist, |
| } from './marketplaceHelpers.js' |
| import { |
| OFFICIAL_MARKETPLACE_NAME, |
| OFFICIAL_MARKETPLACE_SOURCE, |
| } from './officialMarketplace.js' |
| import { fetchOfficialMarketplaceFromGcs } from './officialMarketplaceGcs.js' |
| import { |
| deletePluginDataDir, |
| getPluginSeedDirs, |
| getPluginsDirectory, |
| } from './pluginDirectories.js' |
| import { parsePluginIdentifier } from './pluginIdentifier.js' |
| import { deletePluginOptions } from './pluginOptionsStorage.js' |
| import { |
| isLocalMarketplaceSource, |
| type KnownMarketplace, |
| type KnownMarketplacesFile, |
| KnownMarketplacesFileSchema, |
| type MarketplaceSource, |
| type PluginMarketplace, |
| type PluginMarketplaceEntry, |
| PluginMarketplaceSchema, |
| validateOfficialNameSource, |
| } from './schemas.js' |
|
|
| |
| |
| |
| type LoadedPluginMarketplace = { |
| marketplace: PluginMarketplace |
| cachePath: string |
| } |
|
|
| |
| |
| |
| |
| function getKnownMarketplacesFile(): string { |
| return join(getPluginsDirectory(), 'known_marketplaces.json') |
| } |
|
|
| |
| |
| |
| |
| export function getMarketplacesCacheDir(): string { |
| return join(getPluginsDirectory(), 'marketplaces') |
| } |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| export function clearMarketplacesCache(): void { |
| getMarketplace.cache?.clear?.() |
| } |
|
|
| |
| |
| |
| export type KnownMarketplacesConfig = KnownMarketplacesFile |
|
|
| |
| |
| |
| |
| |
| |
| |
| export type DeclaredMarketplace = { |
| source: MarketplaceSource |
| installLocation?: string |
| autoUpdate?: boolean |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| sourceIsFallback?: boolean |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function getDeclaredMarketplaces(): Record<string, DeclaredMarketplace> { |
| const implicit: Record<string, DeclaredMarketplace> = {} |
|
|
| |
| |
| |
| const enabledPlugins = { |
| ...getAddDirEnabledPlugins(), |
| ...(getInitialSettings().enabledPlugins ?? {}), |
| } |
| for (const [pluginId, value] of Object.entries(enabledPlugins)) { |
| if ( |
| value && |
| parsePluginIdentifier(pluginId).marketplace === OFFICIAL_MARKETPLACE_NAME |
| ) { |
| implicit[OFFICIAL_MARKETPLACE_NAME] = { |
| source: OFFICIAL_MARKETPLACE_SOURCE, |
| sourceIsFallback: true, |
| } |
| break |
| } |
| } |
|
|
| |
| |
| |
| return { |
| ...implicit, |
| ...getAddDirExtraMarketplaces(), |
| ...(getInitialSettings().extraKnownMarketplaces ?? {}), |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function getMarketplaceDeclaringSource( |
| name: string, |
| ): 'userSettings' | 'projectSettings' | 'localSettings' | null { |
| |
| |
| const editableSources: Array< |
| 'localSettings' | 'projectSettings' | 'userSettings' |
| > = ['localSettings', 'projectSettings', 'userSettings'] |
|
|
| for (const source of editableSources) { |
| const settings = getSettingsForSource(source) |
| if (settings?.extraKnownMarketplaces?.[name]) { |
| return source |
| } |
| } |
| return null |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function saveMarketplaceToSettings( |
| name: string, |
| entry: DeclaredMarketplace, |
| settingSource: |
| | 'userSettings' |
| | 'projectSettings' |
| | 'localSettings' = 'userSettings', |
| ): void { |
| const existing = getSettingsForSource(settingSource) ?? {} |
| const current = { ...existing.extraKnownMarketplaces } |
| current[name] = entry |
| updateSettingsForSource(settingSource, { extraKnownMarketplaces: current }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function loadKnownMarketplacesConfig(): Promise<KnownMarketplacesConfig> { |
| const fs = getFsImplementation() |
| const configFile = getKnownMarketplacesFile() |
|
|
| try { |
| const content = await fs.readFile(configFile, { |
| encoding: 'utf-8', |
| }) |
| const data = jsonParse(content) |
| |
| const parsed = KnownMarketplacesFileSchema().safeParse(data) |
| if (!parsed.success) { |
| const errorMsg = `Marketplace configuration file is corrupted: ${parsed.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}` |
| logForDebugging(errorMsg, { |
| level: 'error', |
| }) |
| throw new ConfigParseError(errorMsg, configFile, data) |
| } |
| return parsed.data |
| } catch (error) { |
| if (isENOENT(error)) { |
| return {} |
| } |
| |
| if (error instanceof ConfigParseError) { |
| throw error |
| } |
| |
| const errorMsg = `Failed to load marketplace configuration: ${errorMessage(error)}` |
| logForDebugging(errorMsg, { |
| level: 'error', |
| }) |
| throw new Error(errorMsg) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function loadKnownMarketplacesConfigSafe(): Promise<KnownMarketplacesConfig> { |
| try { |
| return await loadKnownMarketplacesConfig() |
| } catch { |
| |
| |
| return {} |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function saveKnownMarketplacesConfig( |
| config: KnownMarketplacesConfig, |
| ): Promise<void> { |
| |
| const parsed = KnownMarketplacesFileSchema().safeParse(config) |
| const configFile = getKnownMarketplacesFile() |
|
|
| if (!parsed.success) { |
| throw new ConfigParseError( |
| `Invalid marketplace config: ${parsed.error.message}`, |
| configFile, |
| config, |
| ) |
| } |
|
|
| const fs = getFsImplementation() |
| |
| const dir = join(configFile, '..') |
| await fs.mkdir(dir) |
| writeFileSync_DEPRECATED(configFile, jsonStringify(parsed.data, null, 2), { |
| encoding: 'utf-8', |
| flush: true, |
| }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function registerSeedMarketplaces(): Promise<boolean> { |
| const seedDirs = getPluginSeedDirs() |
| if (seedDirs.length === 0) return false |
|
|
| const primary = await loadKnownMarketplacesConfig() |
| |
| |
| const claimed = new Set<string>() |
| let changed = 0 |
|
|
| for (const seedDir of seedDirs) { |
| const seedConfig = await readSeedKnownMarketplaces(seedDir) |
| if (!seedConfig) continue |
|
|
| for (const [name, seedEntry] of Object.entries(seedConfig)) { |
| if (claimed.has(name)) continue |
|
|
| |
| |
| |
| const resolvedLocation = await findSeedMarketplaceLocation(seedDir, name) |
| if (!resolvedLocation) { |
| |
| |
| logForDebugging( |
| `Seed marketplace '${name}' not found under ${seedDir}/marketplaces/, skipping`, |
| { level: 'warn' }, |
| ) |
| continue |
| } |
| claimed.add(name) |
|
|
| const desired: KnownMarketplace = { |
| source: seedEntry.source, |
| installLocation: resolvedLocation, |
| lastUpdated: seedEntry.lastUpdated, |
| autoUpdate: false, |
| } |
|
|
| |
| if (isEqual(primary[name], desired)) continue |
|
|
| |
| primary[name] = desired |
| changed++ |
| } |
| } |
|
|
| if (changed > 0) { |
| await saveKnownMarketplacesConfig(primary) |
| logForDebugging(`Synced ${changed} marketplace(s) from seed dir(s)`) |
| return true |
| } |
| return false |
| } |
|
|
| async function readSeedKnownMarketplaces( |
| seedDir: string, |
| ): Promise<KnownMarketplacesConfig | null> { |
| const seedJsonPath = join(seedDir, 'known_marketplaces.json') |
| try { |
| const content = await getFsImplementation().readFile(seedJsonPath, { |
| encoding: 'utf-8', |
| }) |
| const parsed = KnownMarketplacesFileSchema().safeParse(jsonParse(content)) |
| if (!parsed.success) { |
| logForDebugging( |
| `Seed known_marketplaces.json invalid at ${seedDir}: ${parsed.error.message}`, |
| { level: 'warn' }, |
| ) |
| return null |
| } |
| return parsed.data |
| } catch (e) { |
| if (!isENOENT(e)) { |
| logForDebugging( |
| `Failed to read seed known_marketplaces.json at ${seedDir}: ${e}`, |
| { level: 'warn' }, |
| ) |
| } |
| return null |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function findSeedMarketplaceLocation( |
| seedDir: string, |
| name: string, |
| ): Promise<string | null> { |
| const dirCandidate = join(seedDir, 'marketplaces', name) |
| const jsonCandidate = join(seedDir, 'marketplaces', `${name}.json`) |
| for (const candidate of [dirCandidate, jsonCandidate]) { |
| try { |
| await readCachedMarketplace(candidate) |
| return candidate |
| } catch { |
| |
| } |
| } |
| return null |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function seedDirFor(installLocation: string): string | undefined { |
| return getPluginSeedDirs().find( |
| d => installLocation === d || installLocation.startsWith(d + sep), |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const GIT_NO_PROMPT_ENV = { |
| GIT_TERMINAL_PROMPT: '0', |
| GIT_ASKPASS: '', |
| } |
|
|
| const DEFAULT_PLUGIN_GIT_TIMEOUT_MS = 120 * 1000 |
|
|
| function getPluginGitTimeoutMs(): number { |
| const envValue = process.env.CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS |
| if (envValue) { |
| const parsed = parseInt(envValue, 10) |
| if (!isNaN(parsed) && parsed > 0) { |
| return parsed |
| } |
| } |
| return DEFAULT_PLUGIN_GIT_TIMEOUT_MS |
| } |
|
|
| export async function gitPull( |
| cwd: string, |
| ref?: string, |
| options?: { disableCredentialHelper?: boolean; sparsePaths?: string[] }, |
| ): Promise<{ code: number; stderr: string }> { |
| logForDebugging(`git pull: cwd=${cwd} ref=${ref ?? 'default'}`) |
| const env = { ...process.env, ...GIT_NO_PROMPT_ENV } |
| const credentialArgs = options?.disableCredentialHelper |
| ? ['-c', 'credential.helper='] |
| : [] |
|
|
| if (ref) { |
| const fetchResult = await execFileNoThrowWithCwd( |
| gitExe(), |
| [...credentialArgs, 'fetch', 'origin', ref], |
| { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, |
| ) |
|
|
| if (fetchResult.code !== 0) { |
| return enhanceGitPullErrorMessages(fetchResult) |
| } |
|
|
| const checkoutResult = await execFileNoThrowWithCwd( |
| gitExe(), |
| [...credentialArgs, 'checkout', ref], |
| { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, |
| ) |
|
|
| if (checkoutResult.code !== 0) { |
| return enhanceGitPullErrorMessages(checkoutResult) |
| } |
|
|
| const pullResult = await execFileNoThrowWithCwd( |
| gitExe(), |
| [...credentialArgs, 'pull', 'origin', ref], |
| { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, |
| ) |
| if (pullResult.code !== 0) { |
| return enhanceGitPullErrorMessages(pullResult) |
| } |
| await gitSubmoduleUpdate(cwd, credentialArgs, env, options?.sparsePaths) |
| return pullResult |
| } |
|
|
| const result = await execFileNoThrowWithCwd( |
| gitExe(), |
| [...credentialArgs, 'pull', 'origin', 'HEAD'], |
| { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, |
| ) |
| if (result.code !== 0) { |
| return enhanceGitPullErrorMessages(result) |
| } |
| await gitSubmoduleUpdate(cwd, credentialArgs, env, options?.sparsePaths) |
| return result |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function gitSubmoduleUpdate( |
| cwd: string, |
| credentialArgs: string[], |
| env: NodeJS.ProcessEnv, |
| sparsePaths: string[] | undefined, |
| ): Promise<void> { |
| if (sparsePaths && sparsePaths.length > 0) return |
| const hasGitmodules = await getFsImplementation() |
| .stat(join(cwd, '.gitmodules')) |
| .then( |
| () => true, |
| () => false, |
| ) |
| if (!hasGitmodules) return |
| const result = await execFileNoThrowWithCwd( |
| gitExe(), |
| [ |
| '-c', |
| 'core.sshCommand=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes', |
| ...credentialArgs, |
| 'submodule', |
| 'update', |
| '--init', |
| '--recursive', |
| '--depth', |
| '1', |
| ], |
| { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, |
| ) |
| if (result.code !== 0) { |
| logForDebugging( |
| `git submodule update failed (non-fatal): ${result.stderr}`, |
| { level: 'warn' }, |
| ) |
| } |
| } |
|
|
| |
| |
| |
| function enhanceGitPullErrorMessages(result: { |
| code: number |
| stderr: string |
| error?: string |
| }): { code: number; stderr: string } { |
| if (result.code === 0) { |
| return result |
| } |
|
|
| |
| |
| if (result.error?.includes('timed out')) { |
| const timeoutSec = Math.round(getPluginGitTimeoutMs() / 1000) |
| return { |
| ...result, |
| stderr: `Git pull timed out after ${timeoutSec}s. Try increasing the timeout via CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS environment variable.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| if (result.stderr.includes('REMOTE HOST IDENTIFICATION HAS CHANGED')) { |
| return { |
| ...result, |
| stderr: `SSH host key for this marketplace's git host has changed (server key rotation or possible MITM). Remove the stale entry with: ssh-keygen -R <host>\nThen connect once manually to accept the new key.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
| if (result.stderr.includes('Host key verification failed')) { |
| return { |
| ...result, |
| stderr: `SSH host key verification failed while updating marketplace. The host key is not in your known_hosts file. Connect once manually to add it (e.g., ssh -T git@<host>), or remove and re-add the marketplace with an HTTPS URL.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| |
| if ( |
| result.stderr.includes('Permission denied (publickey)') || |
| result.stderr.includes('Could not read from remote repository') |
| ) { |
| return { |
| ...result, |
| stderr: `SSH authentication failed while updating marketplace. Please ensure your SSH keys are configured.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| |
| if ( |
| result.stderr.includes('timed out') || |
| result.stderr.includes('Could not resolve host') |
| ) { |
| return { |
| ...result, |
| stderr: `Network error while updating marketplace. Please check your internet connection.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| return result |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function isGitHubSshLikelyConfigured(): Promise<boolean> { |
| try { |
| |
| |
| const result = await execFileNoThrow( |
| 'ssh', |
| [ |
| '-T', |
| '-o', |
| 'BatchMode=yes', |
| '-o', |
| 'ConnectTimeout=2', |
| '-o', |
| 'StrictHostKeyChecking=yes', |
| 'git@github.com', |
| ], |
| { |
| timeout: 3000, |
| }, |
| ) |
|
|
| |
| |
| const configured = |
| result.code === 1 && |
| (result.stderr?.includes('successfully authenticated') || |
| result.stdout?.includes('successfully authenticated')) |
| logForDebugging( |
| `SSH config check: code=${result.code} configured=${configured}`, |
| ) |
| return configured |
| } catch (error) { |
| |
| logForDebugging(`SSH configuration check failed: ${errorMessage(error)}`, { |
| level: 'warn', |
| }) |
| return false |
| } |
| } |
|
|
| |
| |
| |
| |
| function isAuthenticationError(stderr: string): boolean { |
| return ( |
| stderr.includes('Authentication failed') || |
| stderr.includes('could not read Username') || |
| stderr.includes('terminal prompts disabled') || |
| stderr.includes('403') || |
| stderr.includes('401') |
| ) |
| } |
|
|
| |
| |
| |
| |
| function extractSshHost(gitUrl: string): string | null { |
| const match = gitUrl.match(/^[^@]+@([^:]+):/) |
| return match?.[1] ?? null |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function gitClone( |
| gitUrl: string, |
| targetPath: string, |
| ref?: string, |
| sparsePaths?: string[], |
| ): Promise<{ code: number; stderr: string }> { |
| const useSparse = sparsePaths && sparsePaths.length > 0 |
| const args = [ |
| '-c', |
| 'core.sshCommand=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes', |
| 'clone', |
| '--depth', |
| '1', |
| ] |
|
|
| if (useSparse) { |
| |
| |
| |
| |
| args.push('--filter=blob:none', '--no-checkout') |
| } else { |
| args.push('--recurse-submodules', '--shallow-submodules') |
| } |
|
|
| if (ref) { |
| args.push('--branch', ref) |
| } |
|
|
| args.push(gitUrl, targetPath) |
|
|
| const timeoutMs = getPluginGitTimeoutMs() |
| logForDebugging( |
| `git clone: url=${redactUrlCredentials(gitUrl)} ref=${ref ?? 'default'} timeout=${timeoutMs}ms`, |
| ) |
|
|
| const result = await execFileNoThrowWithCwd(gitExe(), args, { |
| timeout: timeoutMs, |
| stdin: 'ignore', |
| env: { ...process.env, ...GIT_NO_PROMPT_ENV }, |
| }) |
|
|
| |
| |
| |
| |
| const redacted = redactUrlCredentials(gitUrl) |
| if (gitUrl !== redacted) { |
| if (result.error) result.error = result.error.replaceAll(gitUrl, redacted) |
| if (result.stderr) |
| result.stderr = result.stderr.replaceAll(gitUrl, redacted) |
| } |
|
|
| if (result.code === 0) { |
| if (useSparse) { |
| |
| |
| |
| const sparseResult = await execFileNoThrowWithCwd( |
| gitExe(), |
| ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths], |
| { |
| cwd: targetPath, |
| timeout: timeoutMs, |
| stdin: 'ignore', |
| env: { ...process.env, ...GIT_NO_PROMPT_ENV }, |
| }, |
| ) |
| if (sparseResult.code !== 0) { |
| return { |
| code: sparseResult.code, |
| stderr: `git sparse-checkout set failed: ${sparseResult.stderr}`, |
| } |
| } |
|
|
| const checkoutResult = await execFileNoThrowWithCwd( |
| gitExe(), |
| |
| |
| ['checkout', 'HEAD'], |
| { |
| cwd: targetPath, |
| timeout: timeoutMs, |
| stdin: 'ignore', |
| env: { ...process.env, ...GIT_NO_PROMPT_ENV }, |
| }, |
| ) |
| if (checkoutResult.code !== 0) { |
| return { |
| code: checkoutResult.code, |
| stderr: `git checkout after sparse-checkout failed: ${checkoutResult.stderr}`, |
| } |
| } |
| } |
| logForDebugging(`git clone succeeded: ${redactUrlCredentials(gitUrl)}`) |
| return result |
| } |
|
|
| logForDebugging( |
| `git clone failed: url=${redactUrlCredentials(gitUrl)} code=${result.code} error=${result.error ?? 'none'} stderr=${result.stderr}`, |
| { level: 'warn' }, |
| ) |
|
|
| |
| |
| |
| |
| if (result.error?.includes('timed out')) { |
| return { |
| ...result, |
| stderr: `Git clone timed out after ${Math.round(timeoutMs / 1000)}s. The repository may be too large for the current timeout. Set CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS to increase it (e.g., 300000 for 5 minutes).\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| |
| if (result.stderr) { |
| |
| |
| |
| |
| |
| if (result.stderr.includes('REMOTE HOST IDENTIFICATION HAS CHANGED')) { |
| const host = extractSshHost(gitUrl) |
| const removeHint = host ? `ssh-keygen -R ${host}` : 'ssh-keygen -R <host>' |
| return { |
| ...result, |
| stderr: `SSH host key has changed (server key rotation or possible MITM). Remove the stale known_hosts entry:\n ${removeHint}\nThen connect once manually to verify and accept the new key.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
| if (result.stderr.includes('Host key verification failed')) { |
| const host = extractSshHost(gitUrl) |
| const connectHint = host ? `ssh -T git@${host}` : 'ssh -T git@<host>' |
| return { |
| ...result, |
| stderr: `SSH host key is not in your known_hosts file. To add it, connect once manually (this will show the fingerprint for you to verify):\n ${connectHint}\n\nOr use an HTTPS URL instead (recommended for public repos).\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| if ( |
| result.stderr.includes('Permission denied (publickey)') || |
| result.stderr.includes('Could not read from remote repository') |
| ) { |
| return { |
| ...result, |
| stderr: `SSH authentication failed. Please ensure your SSH keys are configured for GitHub, or use an HTTPS URL instead.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| if (isAuthenticationError(result.stderr)) { |
| return { |
| ...result, |
| stderr: `HTTPS authentication failed. Please ensure your credential helper is configured (e.g., gh auth login).\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
|
|
| if ( |
| result.stderr.includes('timed out') || |
| result.stderr.includes('timeout') || |
| result.stderr.includes('Could not resolve host') |
| ) { |
| return { |
| ...result, |
| stderr: `Network error or timeout while cloning repository. Please check your internet connection and try again.\n\nOriginal error: ${result.stderr}`, |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| if (!result.stderr) { |
| return { |
| code: result.code, |
| stderr: |
| result.error || |
| `git clone exited with code ${result.code} (no stderr output). Run with --debug to see the full command.`, |
| } |
| } |
|
|
| return result |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export type MarketplaceProgressCallback = (message: string) => void |
|
|
| |
| |
| |
| |
| |
| |
| |
| function safeCallProgress( |
| onProgress: MarketplaceProgressCallback | undefined, |
| message: string, |
| ): void { |
| if (!onProgress) return |
| try { |
| onProgress(message) |
| } catch (callbackError) { |
| logForDebugging(`Progress callback error: ${errorMessage(callbackError)}`, { |
| level: 'warn', |
| }) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function reconcileSparseCheckout( |
| cwd: string, |
| sparsePaths: string[] | undefined, |
| ): Promise<{ code: number; stderr: string }> { |
| const env = { ...process.env, ...GIT_NO_PROMPT_ENV } |
|
|
| if (sparsePaths && sparsePaths.length > 0) { |
| return execFileNoThrowWithCwd( |
| gitExe(), |
| ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths], |
| { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, |
| ) |
| } |
|
|
| const check = await execFileNoThrowWithCwd( |
| gitExe(), |
| ['config', '--get', 'core.sparseCheckout'], |
| { cwd, stdin: 'ignore', env }, |
| ) |
| if (check.code === 0 && check.stdout.trim() === 'true') { |
| return { |
| code: 1, |
| stderr: |
| 'sparsePaths removed from config but repository is sparse; re-cloning for full checkout', |
| } |
| } |
| return { code: 0, stderr: '' } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function cacheMarketplaceFromGit( |
| gitUrl: string, |
| cachePath: string, |
| ref?: string, |
| sparsePaths?: string[], |
| onProgress?: MarketplaceProgressCallback, |
| options?: { disableCredentialHelper?: boolean }, |
| ): Promise<void> { |
| const fs = getFsImplementation() |
|
|
| |
| |
| |
| const timeoutSec = Math.round(getPluginGitTimeoutMs() / 1000) |
| safeCallProgress( |
| onProgress, |
| `Refreshing marketplace cache (timeout: ${timeoutSec}s)…`, |
| ) |
|
|
| |
| |
| |
| const reconcileResult = await reconcileSparseCheckout(cachePath, sparsePaths) |
| if (reconcileResult.code === 0) { |
| const pullStarted = performance.now() |
| const pullResult = await gitPull(cachePath, ref, { |
| disableCredentialHelper: options?.disableCredentialHelper, |
| sparsePaths, |
| }) |
| logPluginFetch( |
| 'marketplace_pull', |
| gitUrl, |
| pullResult.code === 0 ? 'success' : 'failure', |
| performance.now() - pullStarted, |
| pullResult.code === 0 ? undefined : classifyFetchError(pullResult.stderr), |
| ) |
| if (pullResult.code === 0) return |
| logForDebugging(`git pull failed, will re-clone: ${pullResult.stderr}`, { |
| level: 'warn', |
| }) |
| } else { |
| logForDebugging( |
| `sparse-checkout reconcile requires re-clone: ${reconcileResult.stderr}`, |
| ) |
| } |
|
|
| try { |
| await fs.rm(cachePath, { recursive: true }) |
| |
| logForDebugging( |
| `Found stale marketplace directory at ${cachePath}, cleaning up to allow re-clone`, |
| { level: 'warn' }, |
| ) |
| safeCallProgress( |
| onProgress, |
| 'Found stale directory, cleaning up and re-cloning…', |
| ) |
| } catch (rmError) { |
| if (!isENOENT(rmError)) { |
| const rmErrorMsg = errorMessage(rmError) |
| throw new Error( |
| `Failed to clean up existing marketplace directory. Please manually delete the directory at ${cachePath} and try again.\n\nTechnical details: ${rmErrorMsg}`, |
| ) |
| } |
| |
| } |
|
|
| |
| const refMessage = ref ? ` (ref: ${ref})` : '' |
| safeCallProgress( |
| onProgress, |
| `Cloning repository (timeout: ${timeoutSec}s): ${redactUrlCredentials(gitUrl)}${refMessage}`, |
| ) |
| const cloneStarted = performance.now() |
| const result = await gitClone(gitUrl, cachePath, ref, sparsePaths) |
| logPluginFetch( |
| 'marketplace_clone', |
| gitUrl, |
| result.code === 0 ? 'success' : 'failure', |
| performance.now() - cloneStarted, |
| result.code === 0 ? undefined : classifyFetchError(result.stderr), |
| ) |
| if (result.code !== 0) { |
| |
| |
| |
| try { |
| await fs.rm(cachePath, { recursive: true, force: true }) |
| } catch { |
| |
| } |
| throw new Error(`Failed to clone marketplace repository: ${result.stderr}`) |
| } |
| safeCallProgress(onProgress, 'Clone complete, validating marketplace…') |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function redactHeaders( |
| headers: Record<string, string>, |
| ): Record<string, string> { |
| return Object.fromEntries( |
| Object.entries(headers).map(([key]) => [key, '***REDACTED***']), |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function redactUrlCredentials(urlString: string): string { |
| try { |
| const parsed = new URL(urlString) |
| const isHttp = parsed.protocol === 'http:' || parsed.protocol === 'https:' |
| if (isHttp && (parsed.username || parsed.password)) { |
| if (parsed.username) parsed.username = '***' |
| if (parsed.password) parsed.password = '***' |
| return parsed.toString() |
| } |
| } catch { |
| |
| } |
| return urlString |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function cacheMarketplaceFromUrl( |
| url: string, |
| cachePath: string, |
| customHeaders?: Record<string, string>, |
| onProgress?: MarketplaceProgressCallback, |
| ): Promise<void> { |
| const fs = getFsImplementation() |
|
|
| const redactedUrl = redactUrlCredentials(url) |
| safeCallProgress(onProgress, `Downloading marketplace from ${redactedUrl}`) |
| logForDebugging(`Downloading marketplace from URL: ${redactedUrl}`) |
| if (customHeaders && Object.keys(customHeaders).length > 0) { |
| logForDebugging( |
| `Using custom headers: ${jsonStringify(redactHeaders(customHeaders))}`, |
| ) |
| } |
|
|
| const headers = { |
| ...customHeaders, |
| |
| 'User-Agent': 'Claude-Code-Plugin-Manager', |
| } |
|
|
| let response |
| const fetchStarted = performance.now() |
| try { |
| response = await axios.get(url, { |
| timeout: 10000, |
| headers, |
| }) |
| } catch (error) { |
| logPluginFetch( |
| 'marketplace_url', |
| url, |
| 'failure', |
| performance.now() - fetchStarted, |
| classifyFetchError(error), |
| ) |
| if (axios.isAxiosError(error)) { |
| if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { |
| throw new Error( |
| `Could not connect to ${redactedUrl}. Please check your internet connection and verify the URL is correct.\n\nTechnical details: ${error.message}`, |
| ) |
| } |
| if (error.code === 'ETIMEDOUT') { |
| throw new Error( |
| `Request timed out while downloading marketplace from ${redactedUrl}. The server may be slow or unreachable.\n\nTechnical details: ${error.message}`, |
| ) |
| } |
| if (error.response) { |
| throw new Error( |
| `HTTP ${error.response.status} error while downloading marketplace from ${redactedUrl}. The marketplace file may not exist at this URL.\n\nTechnical details: ${error.message}`, |
| ) |
| } |
| } |
| throw new Error( |
| `Failed to download marketplace from ${redactedUrl}: ${errorMessage(error)}`, |
| ) |
| } |
|
|
| safeCallProgress(onProgress, 'Validating marketplace data') |
| |
| const result = PluginMarketplaceSchema().safeParse(response.data) |
| if (!result.success) { |
| logPluginFetch( |
| 'marketplace_url', |
| url, |
| 'failure', |
| performance.now() - fetchStarted, |
| 'invalid_schema', |
| ) |
| throw new ConfigParseError( |
| `Invalid marketplace schema from URL: ${result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`, |
| redactedUrl, |
| response.data, |
| ) |
| } |
| logPluginFetch( |
| 'marketplace_url', |
| url, |
| 'success', |
| performance.now() - fetchStarted, |
| ) |
|
|
| safeCallProgress(onProgress, 'Saving marketplace to cache') |
| |
| const cacheDir = join(cachePath, '..') |
| await fs.mkdir(cacheDir) |
|
|
| |
| writeFileSync_DEPRECATED(cachePath, jsonStringify(result.data, null, 2), { |
| encoding: 'utf-8', |
| flush: true, |
| }) |
| } |
|
|
| |
| |
| |
| function getCachePathForSource(source: MarketplaceSource): string { |
| const tempName = |
| source.source === 'github' |
| ? source.repo.replace('/', '-') |
| : source.source === 'npm' |
| ? source.package.replace('@', '').replace('/', '-') |
| : source.source === 'file' |
| ? basename(source.path).replace('.json', '') |
| : source.source === 'directory' |
| ? basename(source.path) |
| : 'temp_' + Date.now() |
| return tempName |
| } |
|
|
| |
| |
| |
| async function parseFileWithSchema<T>( |
| filePath: string, |
| schema: { |
| safeParse: (data: unknown) => { |
| success: boolean |
| data?: T |
| error?: { |
| issues: Array<{ path: PropertyKey[]; message: string }> |
| } |
| } |
| }, |
| ): Promise<T> { |
| const fs = getFsImplementation() |
| const content = await fs.readFile(filePath, { encoding: 'utf-8' }) |
| let data: unknown |
| try { |
| data = jsonParse(content) |
| } catch (error) { |
| throw new ConfigParseError( |
| `Invalid JSON in ${filePath}: ${errorMessage(error)}`, |
| filePath, |
| content, |
| ) |
| } |
| const result = schema.safeParse(data) |
| if (!result.success) { |
| throw new ConfigParseError( |
| `Invalid schema: ${filePath} ${result.error?.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`, |
| filePath, |
| data, |
| ) |
| } |
| return result.data! |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function loadAndCacheMarketplace( |
| source: MarketplaceSource, |
| onProgress?: MarketplaceProgressCallback, |
| ): Promise<LoadedPluginMarketplace> { |
| const fs = getFsImplementation() |
| const cacheDir = getMarketplacesCacheDir() |
|
|
| |
| await fs.mkdir(cacheDir) |
|
|
| let temporaryCachePath: string |
| let marketplacePath: string |
| let cleanupNeeded = false |
|
|
| |
| const tempName = getCachePathForSource(source) |
|
|
| try { |
| switch (source.source) { |
| case 'url': { |
| |
| temporaryCachePath = join(cacheDir, `${tempName}.json`) |
| cleanupNeeded = true |
| await cacheMarketplaceFromUrl( |
| source.url, |
| temporaryCachePath, |
| source.headers, |
| onProgress, |
| ) |
| marketplacePath = temporaryCachePath |
| break |
| } |
|
|
| case 'github': { |
| |
| |
| const sshUrl = `git@github.com:${source.repo}.git` |
| const httpsUrl = `https://github.com/${source.repo}.git` |
| temporaryCachePath = join(cacheDir, tempName) |
| cleanupNeeded = true |
|
|
| let lastError: Error | null = null |
|
|
| |
| const sshConfigured = await isGitHubSshLikelyConfigured() |
|
|
| if (sshConfigured) { |
| |
| safeCallProgress(onProgress, `Cloning via SSH: ${sshUrl}`) |
| try { |
| await cacheMarketplaceFromGit( |
| sshUrl, |
| temporaryCachePath, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| ) |
| } catch (err) { |
| lastError = toError(err) |
|
|
| |
| logError(lastError) |
|
|
| |
| safeCallProgress( |
| onProgress, |
| `SSH clone failed, retrying with HTTPS: ${httpsUrl}`, |
| ) |
|
|
| logForDebugging( |
| `SSH clone failed for ${source.repo} despite SSH being configured, falling back to HTTPS`, |
| { level: 'info' }, |
| ) |
|
|
| |
| await fs.rm(temporaryCachePath, { recursive: true, force: true }) |
|
|
| |
| try { |
| await cacheMarketplaceFromGit( |
| httpsUrl, |
| temporaryCachePath, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| ) |
| lastError = null |
| } catch (httpsErr) { |
| |
| lastError = toError(httpsErr) |
|
|
| |
| logError(lastError) |
| } |
| } |
| } else { |
| |
| safeCallProgress( |
| onProgress, |
| `SSH not configured, cloning via HTTPS: ${httpsUrl}`, |
| ) |
|
|
| logForDebugging( |
| `SSH not configured for GitHub, using HTTPS for ${source.repo}`, |
| { level: 'info' }, |
| ) |
|
|
| try { |
| await cacheMarketplaceFromGit( |
| httpsUrl, |
| temporaryCachePath, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| ) |
| } catch (err) { |
| lastError = toError(err) |
|
|
| |
| |
| logError(lastError) |
|
|
| |
| safeCallProgress( |
| onProgress, |
| `HTTPS clone failed, retrying with SSH: ${sshUrl}`, |
| ) |
|
|
| logForDebugging( |
| `HTTPS clone failed for ${source.repo} (${lastError.message}), falling back to SSH`, |
| { level: 'info' }, |
| ) |
|
|
| |
| await fs.rm(temporaryCachePath, { recursive: true, force: true }) |
|
|
| |
| try { |
| await cacheMarketplaceFromGit( |
| sshUrl, |
| temporaryCachePath, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| ) |
| lastError = null |
| } catch (sshErr) { |
| |
| lastError = toError(sshErr) |
|
|
| |
| logError(lastError) |
| } |
| } |
| } |
|
|
| |
| if (lastError) { |
| throw lastError |
| } |
|
|
| marketplacePath = join( |
| temporaryCachePath, |
| source.path || '.claude-plugin/marketplace.json', |
| ) |
| break |
| } |
|
|
| case 'git': { |
| temporaryCachePath = join(cacheDir, tempName) |
| cleanupNeeded = true |
| await cacheMarketplaceFromGit( |
| source.url, |
| temporaryCachePath, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| ) |
| marketplacePath = join( |
| temporaryCachePath, |
| source.path || '.claude-plugin/marketplace.json', |
| ) |
| break |
| } |
|
|
| case 'npm': { |
| |
| throw new Error('NPM marketplace sources not yet implemented') |
| } |
|
|
| case 'file': { |
| |
| |
| |
| |
| |
| const absPath = resolve(source.path) |
| marketplacePath = absPath |
| temporaryCachePath = dirname(dirname(absPath)) |
| cleanupNeeded = false |
| break |
| } |
|
|
| case 'directory': { |
| |
| |
| |
| const absPath = resolve(source.path) |
| marketplacePath = join(absPath, '.claude-plugin', 'marketplace.json') |
| temporaryCachePath = absPath |
| cleanupNeeded = false |
| break |
| } |
|
|
| case 'settings': { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| temporaryCachePath = join(cacheDir, source.name) |
| marketplacePath = join( |
| temporaryCachePath, |
| '.claude-plugin', |
| 'marketplace.json', |
| ) |
| cleanupNeeded = false |
| await fs.mkdir(dirname(marketplacePath)) |
| |
| |
| |
| |
| await writeFile( |
| marketplacePath, |
| jsonStringify( |
| { |
| name: source.name, |
| owner: source.owner ?? { name: 'settings' }, |
| plugins: source.plugins, |
| }, |
| null, |
| 2, |
| ), |
| ) |
| break |
| } |
|
|
| default: |
| throw new Error(`Unsupported marketplace source type`) |
| } |
|
|
| |
| logForDebugging(`Reading marketplace from ${marketplacePath}`) |
| let marketplace: PluginMarketplace |
| try { |
| marketplace = await parseFileWithSchema( |
| marketplacePath, |
| PluginMarketplaceSchema(), |
| ) |
| } catch (e) { |
| if (isENOENT(e)) { |
| throw new Error(`Marketplace file not found at ${marketplacePath}`) |
| } |
| throw new Error( |
| `Failed to parse marketplace file at ${marketplacePath}: ${errorMessage(e)}`, |
| ) |
| } |
|
|
| |
| const finalCachePath = join(cacheDir, marketplace.name) |
| |
| |
| |
| |
| const resolvedFinal = resolve(finalCachePath) |
| const resolvedCacheDir = resolve(cacheDir) |
| if (!resolvedFinal.startsWith(resolvedCacheDir + sep)) { |
| throw new Error( |
| `Marketplace name '${marketplace.name}' resolves to a path outside the cache directory`, |
| ) |
| } |
| |
| if ( |
| temporaryCachePath !== finalCachePath && |
| !isLocalMarketplaceSource(source) |
| ) { |
| try { |
| |
| try { |
| onProgress?.('Cleaning up old marketplace cache…') |
| } catch (callbackError) { |
| logForDebugging( |
| `Progress callback error: ${errorMessage(callbackError)}`, |
| { level: 'warn' }, |
| ) |
| } |
| await fs.rm(finalCachePath, { recursive: true, force: true }) |
| |
| await fs.rename(temporaryCachePath, finalCachePath) |
| temporaryCachePath = finalCachePath |
| cleanupNeeded = false |
| } catch (error) { |
| const errorMsg = errorMessage(error) |
| throw new Error( |
| `Failed to finalize marketplace cache. Please manually delete the directory at ${finalCachePath} if it exists and try again.\n\nTechnical details: ${errorMsg}`, |
| ) |
| } |
| } |
|
|
| return { marketplace, cachePath: temporaryCachePath } |
| } catch (error) { |
| |
| if ( |
| cleanupNeeded && |
| temporaryCachePath! && |
| !isLocalMarketplaceSource(source) |
| ) { |
| try { |
| await fs.rm(temporaryCachePath!, { recursive: true, force: true }) |
| } catch (cleanupError) { |
| logForDebugging( |
| `Warning: Failed to clean up temporary marketplace cache at ${temporaryCachePath}: ${errorMessage(cleanupError)}`, |
| { level: 'warn' }, |
| ) |
| } |
| } |
| throw error |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function addMarketplaceSource( |
| source: MarketplaceSource, |
| onProgress?: MarketplaceProgressCallback, |
| ): Promise<{ |
| name: string |
| alreadyMaterialized: boolean |
| resolvedSource: MarketplaceSource |
| }> { |
| |
| let resolvedSource = source |
| if (isLocalMarketplaceSource(source) && !isAbsolute(source.path)) { |
| resolvedSource = { ...source, path: resolve(source.path) } |
| } |
|
|
| |
| |
| if (!isSourceAllowedByPolicy(resolvedSource)) { |
| |
| if (isSourceInBlocklist(resolvedSource)) { |
| throw new Error( |
| `Marketplace source '${formatSourceForDisplay(resolvedSource)}' is blocked by enterprise policy.`, |
| ) |
| } |
| |
| const allowlist = getStrictKnownMarketplaces() || [] |
| const hostPatterns = getHostPatternsFromAllowlist() |
| const sourceHost = extractHostFromSource(resolvedSource) |
|
|
| let errorMessage = `Marketplace source '${formatSourceForDisplay(resolvedSource)}'` |
| if (sourceHost) { |
| errorMessage += ` (${sourceHost})` |
| } |
| errorMessage += ' is blocked by enterprise policy.' |
|
|
| if (allowlist.length > 0) { |
| errorMessage += ` Allowed sources: ${allowlist.map(s => formatSourceForDisplay(s)).join(', ')}` |
| } else { |
| errorMessage += ' No external marketplaces are allowed.' |
| } |
|
|
| |
| if (resolvedSource.source === 'github' && hostPatterns.length > 0) { |
| errorMessage += |
| `\n\nTip: The shorthand "${resolvedSource.repo}" assumes github.com. ` + |
| `For internal GitHub Enterprise, use the full URL:\n` + |
| ` git@your-github-host.com:${resolvedSource.repo}.git` |
| } |
|
|
| throw new Error(errorMessage) |
| } |
|
|
| |
| const existingConfig = await loadKnownMarketplacesConfig() |
| for (const [existingName, existingEntry] of Object.entries(existingConfig)) { |
| if (isEqual(existingEntry.source, resolvedSource)) { |
| logForDebugging( |
| `Source already materialized as '${existingName}', skipping clone`, |
| ) |
| return { name: existingName, alreadyMaterialized: true, resolvedSource } |
| } |
| } |
|
|
| |
| const { marketplace, cachePath } = await loadAndCacheMarketplace( |
| resolvedSource, |
| onProgress, |
| ) |
|
|
| |
| const sourceValidationError = validateOfficialNameSource( |
| marketplace.name, |
| resolvedSource, |
| ) |
| if (sourceValidationError) { |
| throw new Error(sourceValidationError) |
| } |
|
|
| |
| |
| |
| const config = await loadKnownMarketplacesConfig() |
| const oldEntry = config[marketplace.name] |
| if (oldEntry) { |
| const seedDir = seedDirFor(oldEntry.installLocation) |
| if (seedDir) { |
| throw new Error( |
| `Marketplace '${marketplace.name}' is seed-managed (${seedDir}). ` + |
| `To use a different source, ask your admin to update the seed, ` + |
| `or use a different marketplace name.`, |
| ) |
| } |
| logForDebugging( |
| `Marketplace '${marketplace.name}' exists with different source — overwriting`, |
| ) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (!isLocalMarketplaceSource(oldEntry.source)) { |
| const cacheDir = resolve(getMarketplacesCacheDir()) |
| const resolvedOld = resolve(oldEntry.installLocation) |
| const resolvedNew = resolve(cachePath) |
| if (resolvedOld === resolvedNew) { |
| |
| |
| } else if ( |
| resolvedOld === cacheDir || |
| resolvedOld.startsWith(cacheDir + sep) |
| ) { |
| const fs = getFsImplementation() |
| await fs.rm(oldEntry.installLocation, { recursive: true, force: true }) |
| } else { |
| logForDebugging( |
| `Skipping cleanup of old installLocation (${oldEntry.installLocation}) — ` + |
| `outside ${cacheDir}. The path is corrupted; leaving it alone and ` + |
| `overwriting the config entry.`, |
| { level: 'warn' }, |
| ) |
| } |
| } |
| } |
|
|
| |
| config[marketplace.name] = { |
| source: resolvedSource, |
| installLocation: cachePath, |
| lastUpdated: new Date().toISOString(), |
| } |
| await saveKnownMarketplacesConfig(config) |
|
|
| logForDebugging(`Added marketplace source: ${marketplace.name}`) |
|
|
| return { name: marketplace.name, alreadyMaterialized: false, resolvedSource } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function removeMarketplaceSource(name: string): Promise<void> { |
| const config = await loadKnownMarketplacesConfig() |
|
|
| if (!config[name]) { |
| throw new Error(`Marketplace '${name}' not found`) |
| } |
|
|
| |
| |
| |
| const entry = config[name] |
| const seedDir = seedDirFor(entry.installLocation) |
| if (seedDir) { |
| throw new Error( |
| `Marketplace '${name}' is registered from the read-only seed directory ` + |
| `(${seedDir}) and will be re-registered on next startup. ` + |
| `To stop using its plugins: claude plugin disable <plugin>@${name}`, |
| ) |
| } |
|
|
| |
| delete config[name] |
| await saveKnownMarketplacesConfig(config) |
|
|
| |
| const fs = getFsImplementation() |
| const cacheDir = getMarketplacesCacheDir() |
| const cachePath = join(cacheDir, name) |
| await fs.rm(cachePath, { recursive: true, force: true }) |
| const jsonCachePath = join(cacheDir, `${name}.json`) |
| await fs.rm(jsonCachePath, { force: true }) |
|
|
| |
| |
|
|
| |
| const editableSources: Array< |
| 'userSettings' | 'projectSettings' | 'localSettings' |
| > = ['userSettings', 'projectSettings', 'localSettings'] |
|
|
| for (const source of editableSources) { |
| const settings = getSettingsForSource(source) |
| if (!settings) continue |
|
|
| let needsUpdate = false |
| const updates: { |
| extraKnownMarketplaces?: typeof settings.extraKnownMarketplaces |
| enabledPlugins?: typeof settings.enabledPlugins |
| } = {} |
|
|
| |
| if (settings.extraKnownMarketplaces?.[name]) { |
| const updatedMarketplaces: Partial< |
| SettingsJson['extraKnownMarketplaces'] |
| > = { ...settings.extraKnownMarketplaces } |
| |
| updatedMarketplaces[name] = undefined |
| updates.extraKnownMarketplaces = |
| updatedMarketplaces as SettingsJson['extraKnownMarketplaces'] |
| needsUpdate = true |
| } |
|
|
| |
| if (settings.enabledPlugins) { |
| const marketplaceSuffix = `@${name}` |
| const updatedPlugins = { ...settings.enabledPlugins } |
| let removedPlugins = false |
|
|
| for (const pluginId in updatedPlugins) { |
| if (pluginId.endsWith(marketplaceSuffix)) { |
| updatedPlugins[pluginId] = undefined |
| removedPlugins = true |
| } |
| } |
|
|
| if (removedPlugins) { |
| updates.enabledPlugins = updatedPlugins |
| needsUpdate = true |
| } |
| } |
|
|
| |
| if (needsUpdate) { |
| const result = updateSettingsForSource(source, updates) |
| if (result.error) { |
| logError(result.error) |
| logForDebugging( |
| `Failed to clean up marketplace '${name}' from ${source} settings: ${result.error.message}`, |
| ) |
| } else { |
| logForDebugging( |
| `Cleaned up marketplace '${name}' from ${source} settings`, |
| ) |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| const { orphanedPaths, removedPluginIds } = |
| removeAllPluginsForMarketplace(name) |
| for (const installPath of orphanedPaths) { |
| await markPluginVersionOrphaned(installPath) |
| } |
| for (const pluginId of removedPluginIds) { |
| deletePluginOptions(pluginId) |
| await deletePluginDataDir(pluginId) |
| } |
|
|
| logForDebugging(`Removed marketplace source: ${name}`) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function readCachedMarketplace( |
| installLocation: string, |
| ): Promise<PluginMarketplace> { |
| |
| |
| |
| |
| const nestedPath = join(installLocation, '.claude-plugin', 'marketplace.json') |
| try { |
| return await parseFileWithSchema(nestedPath, PluginMarketplaceSchema()) |
| } catch (e) { |
| if (e instanceof ConfigParseError) throw e |
| const code = getErrnoCode(e) |
| if (code !== 'ENOENT' && code !== 'ENOTDIR') throw e |
| } |
| return await parseFileWithSchema(installLocation, PluginMarketplaceSchema()) |
| } |
|
|
| |
| |
| |
| |
| |
| export async function getMarketplaceCacheOnly( |
| name: string, |
| ): Promise<PluginMarketplace | null> { |
| const fs = getFsImplementation() |
| const configFile = getKnownMarketplacesFile() |
|
|
| try { |
| const content = await fs.readFile(configFile, { encoding: 'utf-8' }) |
| const config = jsonParse(content) as KnownMarketplacesConfig |
| const entry = config[name] |
|
|
| if (!entry) { |
| return null |
| } |
|
|
| return await readCachedMarketplace(entry.installLocation) |
| } catch (error) { |
| if (isENOENT(error)) { |
| return null |
| } |
| logForDebugging( |
| `Failed to read cached marketplace ${name}: ${errorMessage(error)}`, |
| { level: 'warn' }, |
| ) |
| return null |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const getMarketplace = memoize( |
| async (name: string): Promise<PluginMarketplace> => { |
| const config = await loadKnownMarketplacesConfig() |
| const entry = config[name] |
|
|
| if (!entry) { |
| throw new Error( |
| `Marketplace '${name}' not found in configuration. Available marketplaces: ${Object.keys(config).join(', ')}`, |
| ) |
| } |
|
|
| |
| |
| |
| |
| if ( |
| isLocalMarketplaceSource(entry.source) && |
| !isAbsolute(entry.source.path) |
| ) { |
| throw new Error( |
| `Marketplace "${name}" has a relative source path (${entry.source.path}) ` + |
| `in known_marketplaces.json — this is stale state from an older ` + |
| `Claude Code version. Run 'claude marketplace remove ${name}' and ` + |
| `re-add it from the original project directory.`, |
| ) |
| } |
|
|
| |
| try { |
| return await readCachedMarketplace(entry.installLocation) |
| } catch (error) { |
| |
| logForDebugging( |
| `Cache corrupted or missing for marketplace ${name}, re-fetching from source: ${errorMessage(error)}`, |
| { |
| level: 'warn', |
| }, |
| ) |
| } |
|
|
| |
| let marketplace: PluginMarketplace |
| try { |
| ;({ marketplace } = await loadAndCacheMarketplace(entry.source)) |
| } catch (error) { |
| throw new Error( |
| `Failed to load marketplace "${name}" from source (${entry.source.source}): ${errorMessage(error)}`, |
| ) |
| } |
|
|
| |
| config[name]!.lastUpdated = new Date().toISOString() |
| await saveKnownMarketplacesConfig(config) |
|
|
| return marketplace |
| }, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function getPluginByIdCacheOnly(pluginId: string): Promise<{ |
| entry: PluginMarketplaceEntry |
| marketplaceInstallLocation: string |
| } | null> { |
| const { name: pluginName, marketplace: marketplaceName } = |
| parsePluginIdentifier(pluginId) |
| if (!pluginName || !marketplaceName) { |
| return null |
| } |
|
|
| const fs = getFsImplementation() |
| const configFile = getKnownMarketplacesFile() |
|
|
| try { |
| const content = await fs.readFile(configFile, { encoding: 'utf-8' }) |
| const config = jsonParse(content) as KnownMarketplacesConfig |
| const marketplaceConfig = config[marketplaceName] |
|
|
| if (!marketplaceConfig) { |
| return null |
| } |
|
|
| const marketplace = await getMarketplaceCacheOnly(marketplaceName) |
| if (!marketplace) { |
| return null |
| } |
|
|
| const plugin = marketplace.plugins.find(p => p.name === pluginName) |
| if (!plugin) { |
| return null |
| } |
|
|
| return { |
| entry: plugin, |
| marketplaceInstallLocation: marketplaceConfig.installLocation, |
| } |
| } catch { |
| return null |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function getPluginById(pluginId: string): Promise<{ |
| entry: PluginMarketplaceEntry |
| marketplaceInstallLocation: string |
| } | null> { |
| |
| const cached = await getPluginByIdCacheOnly(pluginId) |
| if (cached) { |
| return cached |
| } |
|
|
| |
| const { name: pluginName, marketplace: marketplaceName } = |
| parsePluginIdentifier(pluginId) |
| if (!pluginName || !marketplaceName) { |
| return null |
| } |
|
|
| try { |
| const config = await loadKnownMarketplacesConfig() |
| const marketplaceConfig = config[marketplaceName] |
| if (!marketplaceConfig) { |
| return null |
| } |
|
|
| const marketplace = await getMarketplace(marketplaceName) |
| const plugin = marketplace.plugins.find(p => p.name === pluginName) |
|
|
| if (!plugin) { |
| return null |
| } |
|
|
| return { |
| entry: plugin, |
| marketplaceInstallLocation: marketplaceConfig.installLocation, |
| } |
| } catch (error) { |
| logForDebugging( |
| `Could not find plugin ${pluginId}: ${errorMessage(error)}`, |
| { level: 'debug' }, |
| ) |
| return null |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function refreshAllMarketplaces(): Promise<void> { |
| const config = await loadKnownMarketplacesConfig() |
|
|
| for (const [name, entry] of Object.entries(config)) { |
| |
| |
| if (seedDirFor(entry.installLocation)) { |
| logForDebugging( |
| `Skipping seed-managed marketplace '${name}' in bulk refresh`, |
| ) |
| continue |
| } |
| |
| if (entry.source.source === 'settings') { |
| continue |
| } |
| |
| |
| if (name === OFFICIAL_MARKETPLACE_NAME) { |
| const sha = await fetchOfficialMarketplaceFromGcs( |
| entry.installLocation, |
| getMarketplacesCacheDir(), |
| ) |
| if (sha !== null) { |
| config[name]!.lastUpdated = new Date().toISOString() |
| continue |
| } |
| if ( |
| !getFeatureValue_CACHED_MAY_BE_STALE( |
| 'tengu_plugin_official_mkt_git_fallback', |
| true, |
| ) |
| ) { |
| logForDebugging( |
| `Skipping official marketplace bulk refresh: GCS failed, git fallback disabled`, |
| ) |
| continue |
| } |
| |
| } |
| try { |
| const { cachePath } = await loadAndCacheMarketplace(entry.source) |
| config[name]!.lastUpdated = new Date().toISOString() |
| config[name]!.installLocation = cachePath |
| } catch (error) { |
| logForDebugging( |
| `Failed to refresh marketplace ${name}: ${errorMessage(error)}`, |
| { |
| level: 'error', |
| }, |
| ) |
| } |
| } |
|
|
| await saveKnownMarketplacesConfig(config) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function refreshMarketplace( |
| name: string, |
| onProgress?: MarketplaceProgressCallback, |
| options?: { disableCredentialHelper?: boolean }, |
| ): Promise<void> { |
| const config = await loadKnownMarketplacesConfig() |
| const entry = config[name] |
|
|
| if (!entry) { |
| throw new Error( |
| `Marketplace '${name}' not found. Available marketplaces: ${Object.keys(config).join(', ')}`, |
| ) |
| } |
|
|
| |
| getMarketplace.cache?.delete?.(name) |
|
|
| |
| |
| |
| if (entry.source.source === 'settings') { |
| logForDebugging( |
| `Skipping refresh for settings-sourced marketplace '${name}' — no upstream`, |
| ) |
| return |
| } |
|
|
| try { |
| |
| const installLocation = entry.installLocation |
| const source = entry.source |
|
|
| |
| |
| |
| const seedDir = seedDirFor(installLocation) |
| if (seedDir) { |
| throw new Error( |
| `Marketplace '${name}' is seed-managed (${seedDir}) and its content is ` + |
| `controlled by the seed image. To update: ask your admin to update the seed.`, |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| if (!isLocalMarketplaceSource(source)) { |
| const cacheDir = resolve(getMarketplacesCacheDir()) |
| const resolvedLoc = resolve(installLocation) |
| if (resolvedLoc !== cacheDir && !resolvedLoc.startsWith(cacheDir + sep)) { |
| throw new Error( |
| `Marketplace '${name}' has a corrupted installLocation ` + |
| `(${installLocation}) — expected a path inside ${cacheDir}. ` + |
| `This can happen after cross-platform path writes or manual edits ` + |
| `to known_marketplaces.json. ` + |
| `Run: claude plugin marketplace remove "${name}" and re-add it.`, |
| ) |
| } |
| } |
|
|
| |
| |
| |
| |
| if (name === OFFICIAL_MARKETPLACE_NAME) { |
| const sha = await fetchOfficialMarketplaceFromGcs( |
| installLocation, |
| getMarketplacesCacheDir(), |
| ) |
| if (sha !== null) { |
| config[name] = { ...entry, lastUpdated: new Date().toISOString() } |
| await saveKnownMarketplacesConfig(config) |
| return |
| } |
| |
| |
| |
| |
| if ( |
| !getFeatureValue_CACHED_MAY_BE_STALE( |
| 'tengu_plugin_official_mkt_git_fallback', |
| true, |
| ) |
| ) { |
| |
| |
| |
| |
| throw new Error( |
| 'Official marketplace GCS fetch failed and git fallback is disabled', |
| ) |
| } |
| logForDebugging('Official marketplace GCS failed; falling back to git', { |
| level: 'warn', |
| }) |
| |
| } |
|
|
| |
| if (source.source === 'github' || source.source === 'git') { |
| |
| if (source.source === 'github') { |
| |
| |
| |
| const sshUrl = `git@github.com:${source.repo}.git` |
| const httpsUrl = `https://github.com/${source.repo}.git` |
|
|
| if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { |
| |
| await cacheMarketplaceFromGit( |
| httpsUrl, |
| installLocation, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| options, |
| ) |
| } else { |
| const sshConfigured = await isGitHubSshLikelyConfigured() |
| const primaryUrl = sshConfigured ? sshUrl : httpsUrl |
| const fallbackUrl = sshConfigured ? httpsUrl : sshUrl |
|
|
| try { |
| await cacheMarketplaceFromGit( |
| primaryUrl, |
| installLocation, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| options, |
| ) |
| } catch { |
| logForDebugging( |
| `Marketplace refresh failed with ${sshConfigured ? 'SSH' : 'HTTPS'} for ${source.repo}, falling back to ${sshConfigured ? 'HTTPS' : 'SSH'}`, |
| { level: 'info' }, |
| ) |
| await cacheMarketplaceFromGit( |
| fallbackUrl, |
| installLocation, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| options, |
| ) |
| } |
| } |
| } else { |
| |
| await cacheMarketplaceFromGit( |
| source.url, |
| installLocation, |
| source.ref, |
| source.sparsePaths, |
| onProgress, |
| options, |
| ) |
| } |
| |
| |
| try { |
| await readCachedMarketplace(installLocation) |
| } catch { |
| const sourceDisplay = |
| source.source === 'github' |
| ? source.repo |
| : redactUrlCredentials(source.url) |
| const reason = |
| name === 'claude-code-plugins' |
| ? `We've deprecated "claude-code-plugins" in favor of "claude-plugins-official".` |
| : `This marketplace may have been deprecated or moved to a new location.` |
| throw new Error( |
| `The marketplace.json file is no longer present in this repository.\n\n` + |
| `${reason}\n` + |
| `Source: ${sourceDisplay}\n\n` + |
| `You can remove this marketplace with: claude plugin marketplace remove "${name}"`, |
| ) |
| } |
| } else if (source.source === 'url') { |
| |
| await cacheMarketplaceFromUrl( |
| source.url, |
| installLocation, |
| source.headers, |
| onProgress, |
| ) |
| } else if (isLocalMarketplaceSource(source)) { |
| |
| safeCallProgress(onProgress, 'Validating local marketplace') |
| |
| await readCachedMarketplace(installLocation) |
| } else { |
| throw new Error(`Unsupported marketplace source type for refresh`) |
| } |
|
|
| |
| config[name]!.lastUpdated = new Date().toISOString() |
| await saveKnownMarketplacesConfig(config) |
|
|
| logForDebugging(`Successfully refreshed marketplace: ${name}`) |
| } catch (error) { |
| const errorMessage = error instanceof Error ? error.message : String(error) |
| logForDebugging(`Failed to refresh marketplace ${name}: ${errorMessage}`, { |
| level: 'error', |
| }) |
| throw new Error(`Failed to refresh marketplace '${name}': ${errorMessage}`) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function setMarketplaceAutoUpdate( |
| name: string, |
| autoUpdate: boolean, |
| ): Promise<void> { |
| const config = await loadKnownMarketplacesConfig() |
| const entry = config[name] |
|
|
| if (!entry) { |
| throw new Error( |
| `Marketplace '${name}' not found. Available marketplaces: ${Object.keys(config).join(', ')}`, |
| ) |
| } |
|
|
| |
| |
| |
| const seedDir = seedDirFor(entry.installLocation) |
| if (seedDir) { |
| throw new Error( |
| `Marketplace '${name}' is seed-managed (${seedDir}) and ` + |
| `auto-update is always disabled for seed content. ` + |
| `To update: ask your admin to update the seed.`, |
| ) |
| } |
|
|
| |
| if (entry.autoUpdate === autoUpdate) { |
| return |
| } |
|
|
| config[name] = { |
| ...entry, |
| autoUpdate, |
| } |
| await saveKnownMarketplacesConfig(config) |
|
|
| |
| |
| const declaringSource = getMarketplaceDeclaringSource(name) |
| if (declaringSource) { |
| const declared = |
| getSettingsForSource(declaringSource)?.extraKnownMarketplaces?.[name] |
| if (declared) { |
| saveMarketplaceToSettings( |
| name, |
| { source: declared.source, autoUpdate }, |
| declaringSource, |
| ) |
| } |
| } |
|
|
| logForDebugging(`Set autoUpdate=${autoUpdate} for marketplace: ${name}`) |
| } |
|
|
| export const _test = { |
| redactUrlCredentials, |
| } |
|
|