source dump of claude code
at main 2643 lines 93 kB view raw
1/** 2 * Marketplace manager for Claude Code plugins 3 * 4 * This module provides functionality to: 5 * - Manage known marketplace sources (URLs, GitHub repos, npm packages, local files) 6 * - Cache marketplace manifests locally for offline access 7 * - Install plugins from marketplace entries 8 * - Track and update marketplace configurations 9 * 10 * File structure managed by this module: 11 * ~/.claude/ 12 * └── plugins/ 13 * ├── known_marketplaces.json # Configuration of all known marketplaces 14 * └── marketplaces/ # Cache directory for marketplace data 15 * ├── my-marketplace.json # Cached marketplace from URL source 16 * └── github-marketplace/ # Cloned repository for GitHub source 17 * └── .claude-plugin/ 18 * └── marketplace.json 19 */ 20 21import axios from 'axios' 22import { writeFile } from 'fs/promises' 23import isEqual from 'lodash-es/isEqual.js' 24import memoize from 'lodash-es/memoize.js' 25import { basename, dirname, isAbsolute, join, resolve, sep } from 'path' 26import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 27import { logForDebugging } from '../debug.js' 28import { isEnvTruthy } from '../envUtils.js' 29import { 30 ConfigParseError, 31 errorMessage, 32 getErrnoCode, 33 isENOENT, 34 toError, 35} from '../errors.js' 36import { execFileNoThrow, execFileNoThrowWithCwd } from '../execFileNoThrow.js' 37import { getFsImplementation } from '../fsOperations.js' 38import { gitExe } from '../git.js' 39import { logError } from '../log.js' 40import { 41 getInitialSettings, 42 getSettingsForSource, 43 updateSettingsForSource, 44} from '../settings/settings.js' 45import type { SettingsJson } from '../settings/types.js' 46import { 47 jsonParse, 48 jsonStringify, 49 writeFileSync_DEPRECATED, 50} from '../slowOperations.js' 51import { 52 getAddDirEnabledPlugins, 53 getAddDirExtraMarketplaces, 54} from './addDirPluginSettings.js' 55import { markPluginVersionOrphaned } from './cacheUtils.js' 56import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js' 57import { removeAllPluginsForMarketplace } from './installedPluginsManager.js' 58import { 59 extractHostFromSource, 60 formatSourceForDisplay, 61 getHostPatternsFromAllowlist, 62 getStrictKnownMarketplaces, 63 isSourceAllowedByPolicy, 64 isSourceInBlocklist, 65} from './marketplaceHelpers.js' 66import { 67 OFFICIAL_MARKETPLACE_NAME, 68 OFFICIAL_MARKETPLACE_SOURCE, 69} from './officialMarketplace.js' 70import { fetchOfficialMarketplaceFromGcs } from './officialMarketplaceGcs.js' 71import { 72 deletePluginDataDir, 73 getPluginSeedDirs, 74 getPluginsDirectory, 75} from './pluginDirectories.js' 76import { parsePluginIdentifier } from './pluginIdentifier.js' 77import { deletePluginOptions } from './pluginOptionsStorage.js' 78import { 79 isLocalMarketplaceSource, 80 type KnownMarketplace, 81 type KnownMarketplacesFile, 82 KnownMarketplacesFileSchema, 83 type MarketplaceSource, 84 type PluginMarketplace, 85 type PluginMarketplaceEntry, 86 PluginMarketplaceSchema, 87 validateOfficialNameSource, 88} from './schemas.js' 89 90/** 91 * Result of loading and caching a marketplace 92 */ 93type LoadedPluginMarketplace = { 94 marketplace: PluginMarketplace 95 cachePath: string 96} 97 98/** 99 * Get the path to the known marketplaces configuration file 100 * Using a function instead of a constant allows proper mocking in tests 101 */ 102function getKnownMarketplacesFile(): string { 103 return join(getPluginsDirectory(), 'known_marketplaces.json') 104} 105 106/** 107 * Get the path to the marketplaces cache directory 108 * Using a function instead of a constant allows proper mocking in tests 109 */ 110export function getMarketplacesCacheDir(): string { 111 return join(getPluginsDirectory(), 'marketplaces') 112} 113 114/** 115 * Memoized inner function to get marketplace data. 116 * This caches the marketplace in memory after loading from disk or network. 117 */ 118 119/** 120 * Clear all cached marketplace data (for testing) 121 */ 122export function clearMarketplacesCache(): void { 123 getMarketplace.cache?.clear?.() 124} 125 126/** 127 * Configuration for known marketplaces 128 */ 129export type KnownMarketplacesConfig = KnownMarketplacesFile 130 131/** 132 * Declared marketplace entry (intent layer). 133 * 134 * Structurally compatible with settings `extraKnownMarketplaces` entries, but 135 * adds `sourceIsFallback` for implicit built-in declarations. This is NOT a 136 * settings-schema field — it's only ever set in code (never parsed from JSON). 137 */ 138export type DeclaredMarketplace = { 139 source: MarketplaceSource 140 installLocation?: string 141 autoUpdate?: boolean 142 /** 143 * Presence suffices. When set, diffMarketplaces treats an already-materialized 144 * entry as upToDate regardless of source shape — never reports sourceChanged. 145 * 146 * Used for the implicit official-marketplace declaration: we want "clone from 147 * GitHub if missing", not "replace with GitHub if present under a different 148 * source". Without this, a seed dir that registers the official marketplace 149 * under e.g. an internal-mirror source would be stomped by a GitHub re-clone. 150 */ 151 sourceIsFallback?: boolean 152} 153 154/** 155 * Get declared marketplace intent from merged settings and --add-dir sources. 156 * This is what SHOULD exist — used by the reconciler to find gaps. 157 * 158 * The official marketplace is implicitly declared with `sourceIsFallback: true` 159 * when any enabled plugin references it. 160 */ 161export function getDeclaredMarketplaces(): Record<string, DeclaredMarketplace> { 162 const implicit: Record<string, DeclaredMarketplace> = {} 163 164 // Only the official marketplace can be implicitly declared — it's the one 165 // built-in source we know. Other marketplaces have no default source to inject. 166 // Explicitly-disabled entries (value: false) don't count. 167 const enabledPlugins = { 168 ...getAddDirEnabledPlugins(), 169 ...(getInitialSettings().enabledPlugins ?? {}), 170 } 171 for (const [pluginId, value] of Object.entries(enabledPlugins)) { 172 if ( 173 value && 174 parsePluginIdentifier(pluginId).marketplace === OFFICIAL_MARKETPLACE_NAME 175 ) { 176 implicit[OFFICIAL_MARKETPLACE_NAME] = { 177 source: OFFICIAL_MARKETPLACE_SOURCE, 178 sourceIsFallback: true, 179 } 180 break 181 } 182 } 183 184 // Lowest precedence: implicit < --add-dir < merged settings. 185 // An explicit extraKnownMarketplaces entry for claude-plugins-official 186 // in --add-dir or settings wins. 187 return { 188 ...implicit, 189 ...getAddDirExtraMarketplaces(), 190 ...(getInitialSettings().extraKnownMarketplaces ?? {}), 191 } 192} 193 194/** 195 * Find which editable settings source declared a marketplace. 196 * Checks in reverse precedence order (highest priority last) so the 197 * result is the source that "wins" in the merged view. 198 * Returns null if the marketplace isn't declared in any editable source. 199 */ 200export function getMarketplaceDeclaringSource( 201 name: string, 202): 'userSettings' | 'projectSettings' | 'localSettings' | null { 203 // Check highest-precedence editable sources first — the one that wins 204 // in the merged view is the one we should write back to. 205 const editableSources: Array< 206 'localSettings' | 'projectSettings' | 'userSettings' 207 > = ['localSettings', 'projectSettings', 'userSettings'] 208 209 for (const source of editableSources) { 210 const settings = getSettingsForSource(source) 211 if (settings?.extraKnownMarketplaces?.[name]) { 212 return source 213 } 214 } 215 return null 216} 217 218/** 219 * Save a marketplace entry to settings (intent layer). 220 * Does NOT touch known_marketplaces.json (state layer). 221 * 222 * @param name - The marketplace name 223 * @param entry - The marketplace config 224 * @param settingSource - Which settings source to write to (defaults to userSettings) 225 */ 226export function saveMarketplaceToSettings( 227 name: string, 228 entry: DeclaredMarketplace, 229 settingSource: 230 | 'userSettings' 231 | 'projectSettings' 232 | 'localSettings' = 'userSettings', 233): void { 234 const existing = getSettingsForSource(settingSource) ?? {} 235 const current = { ...existing.extraKnownMarketplaces } 236 current[name] = entry 237 updateSettingsForSource(settingSource, { extraKnownMarketplaces: current }) 238} 239 240/** 241 * Load known marketplaces configuration from disk 242 * 243 * Reads the configuration file at ~/.claude/plugins/known_marketplaces.json 244 * which contains a mapping of marketplace names to their sources and metadata. 245 * 246 * Example configuration file content: 247 * ```json 248 * { 249 * "official-marketplace": { 250 * "source": { "source": "url", "url": "https://example.com/marketplace.json" }, 251 * "installLocation": "/Users/me/.claude/plugins/marketplaces/official-marketplace.json", 252 * "lastUpdated": "2024-01-15T10:30:00.000Z" 253 * }, 254 * "company-plugins": { 255 * "source": { "source": "github", "repo": "mycompany/plugins" }, 256 * "installLocation": "/Users/me/.claude/plugins/marketplaces/company-plugins", 257 * "lastUpdated": "2024-01-14T15:45:00.000Z" 258 * } 259 * } 260 * ``` 261 * 262 * @returns Configuration object mapping marketplace names to their metadata 263 */ 264export async function loadKnownMarketplacesConfig(): Promise<KnownMarketplacesConfig> { 265 const fs = getFsImplementation() 266 const configFile = getKnownMarketplacesFile() 267 268 try { 269 const content = await fs.readFile(configFile, { 270 encoding: 'utf-8', 271 }) 272 const data = jsonParse(content) 273 // Validate against schema 274 const parsed = KnownMarketplacesFileSchema().safeParse(data) 275 if (!parsed.success) { 276 const errorMsg = `Marketplace configuration file is corrupted: ${parsed.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}` 277 logForDebugging(errorMsg, { 278 level: 'error', 279 }) 280 throw new ConfigParseError(errorMsg, configFile, data) 281 } 282 return parsed.data 283 } catch (error) { 284 if (isENOENT(error)) { 285 return {} 286 } 287 // If it's already a ConfigParseError, re-throw it 288 if (error instanceof ConfigParseError) { 289 throw error 290 } 291 // For JSON parse errors or I/O errors, throw with helpful message 292 const errorMsg = `Failed to load marketplace configuration: ${errorMessage(error)}` 293 logForDebugging(errorMsg, { 294 level: 'error', 295 }) 296 throw new Error(errorMsg) 297 } 298} 299 300/** 301 * Load known marketplaces config, returning {} on any error instead of throwing. 302 * 303 * Use this on read-only paths (plugin loading, feature checks) where a corrupted 304 * config should degrade gracefully rather than crash. DO NOT use on load→mutate→save 305 * paths — returning {} there would cause the save to overwrite the corrupted file 306 * with just the new entry, permanently destroying the user's other entries. The 307 * throwing variant preserves the file so the user can fix the corruption and recover. 308 */ 309export async function loadKnownMarketplacesConfigSafe(): Promise<KnownMarketplacesConfig> { 310 try { 311 return await loadKnownMarketplacesConfig() 312 } catch { 313 // Inner function already logged via logForDebugging. Don't logError here — 314 // corrupted user config isn't a Claude Code bug, shouldn't hit the error file. 315 return {} 316 } 317} 318 319/** 320 * Save known marketplaces configuration to disk 321 * 322 * Writes the configuration to ~/.claude/plugins/known_marketplaces.json, 323 * creating the directory structure if it doesn't exist. 324 * 325 * @param config - The marketplace configuration to save 326 */ 327export async function saveKnownMarketplacesConfig( 328 config: KnownMarketplacesConfig, 329): Promise<void> { 330 // Validate before saving 331 const parsed = KnownMarketplacesFileSchema().safeParse(config) 332 const configFile = getKnownMarketplacesFile() 333 334 if (!parsed.success) { 335 throw new ConfigParseError( 336 `Invalid marketplace config: ${parsed.error.message}`, 337 configFile, 338 config, 339 ) 340 } 341 342 const fs = getFsImplementation() 343 // Get directory from config file path to ensure consistency 344 const dir = join(configFile, '..') 345 await fs.mkdir(dir) 346 writeFileSync_DEPRECATED(configFile, jsonStringify(parsed.data, null, 2), { 347 encoding: 'utf-8', 348 flush: true, 349 }) 350} 351 352/** 353 * Register marketplaces from the read-only seed directories into the primary 354 * known_marketplaces.json. 355 * 356 * The seed's known_marketplaces.json contains installLocation paths pointing 357 * into the seed dir itself. Registering those entries into the primary JSON 358 * makes them visible to all marketplace readers (getMarketplaceCacheOnly, 359 * getPluginByIdCacheOnly, etc.) without any loader changes — they just follow 360 * the installLocation wherever it points. 361 * 362 * Seed entries always win for marketplaces declared in the seed — the seed is 363 * admin-managed (baked into the container image). If admin updates the seed 364 * in a new image, those changes propagate on next boot. Users opt out of seed 365 * plugins via `plugin disable`, not by removing the marketplace. 366 * 367 * With multiple seed dirs (path-delimiter-separated), first-seed-wins: a 368 * marketplace name claimed by an earlier seed is skipped by later seeds. 369 * 370 * autoUpdate is forced to false since the seed is read-only and git-pull would 371 * fail. installLocation is computed from the runtime seedDir, not trusted from 372 * the seed's JSON (handles multi-stage Docker mount-path drift). 373 * 374 * Idempotent: second call with unchanged seed writes nothing. 375 * 376 * @returns true if any marketplace entries were written/changed (caller should 377 * clear caches so earlier plugin-load passes don't keep stale "marketplace 378 * not found" state) 379 */ 380export async function registerSeedMarketplaces(): Promise<boolean> { 381 const seedDirs = getPluginSeedDirs() 382 if (seedDirs.length === 0) return false 383 384 const primary = await loadKnownMarketplacesConfig() 385 // First-seed-wins across this registration pass. Can't use the isEqual check 386 // alone — two seeds with the same name will have different installLocations. 387 const claimed = new Set<string>() 388 let changed = 0 389 390 for (const seedDir of seedDirs) { 391 const seedConfig = await readSeedKnownMarketplaces(seedDir) 392 if (!seedConfig) continue 393 394 for (const [name, seedEntry] of Object.entries(seedConfig)) { 395 if (claimed.has(name)) continue 396 397 // Compute installLocation relative to THIS seedDir, not the build-time 398 // path baked into the seed's JSON. Handles multi-stage Docker builds 399 // where the seed is mounted at a different path than where it was built. 400 const resolvedLocation = await findSeedMarketplaceLocation(seedDir, name) 401 if (!resolvedLocation) { 402 // Seed content missing (incomplete build) — leave primary alone, but 403 // don't claim the name either: a later seed may have working content. 404 logForDebugging( 405 `Seed marketplace '${name}' not found under ${seedDir}/marketplaces/, skipping`, 406 { level: 'warn' }, 407 ) 408 continue 409 } 410 claimed.add(name) 411 412 const desired: KnownMarketplace = { 413 source: seedEntry.source, 414 installLocation: resolvedLocation, 415 lastUpdated: seedEntry.lastUpdated, 416 autoUpdate: false, 417 } 418 419 // Skip if primary already matches — idempotent no-op, no write. 420 if (isEqual(primary[name], desired)) continue 421 422 // Seed wins — admin-managed. Overwrite any existing primary entry. 423 primary[name] = desired 424 changed++ 425 } 426 } 427 428 if (changed > 0) { 429 await saveKnownMarketplacesConfig(primary) 430 logForDebugging(`Synced ${changed} marketplace(s) from seed dir(s)`) 431 return true 432 } 433 return false 434} 435 436async function readSeedKnownMarketplaces( 437 seedDir: string, 438): Promise<KnownMarketplacesConfig | null> { 439 const seedJsonPath = join(seedDir, 'known_marketplaces.json') 440 try { 441 const content = await getFsImplementation().readFile(seedJsonPath, { 442 encoding: 'utf-8', 443 }) 444 const parsed = KnownMarketplacesFileSchema().safeParse(jsonParse(content)) 445 if (!parsed.success) { 446 logForDebugging( 447 `Seed known_marketplaces.json invalid at ${seedDir}: ${parsed.error.message}`, 448 { level: 'warn' }, 449 ) 450 return null 451 } 452 return parsed.data 453 } catch (e) { 454 if (!isENOENT(e)) { 455 logForDebugging( 456 `Failed to read seed known_marketplaces.json at ${seedDir}: ${e}`, 457 { level: 'warn' }, 458 ) 459 } 460 return null 461 } 462} 463 464/** 465 * Locate a marketplace in the seed directory by name. 466 * 467 * Probes the canonical locations under seedDir/marketplaces/ rather than 468 * trusting the seed's stored installLocation (which may have a stale absolute 469 * path from a different build-time mount point). 470 * 471 * @returns Readable location, or null if neither format exists/validates 472 */ 473async function findSeedMarketplaceLocation( 474 seedDir: string, 475 name: string, 476): Promise<string | null> { 477 const dirCandidate = join(seedDir, 'marketplaces', name) 478 const jsonCandidate = join(seedDir, 'marketplaces', `${name}.json`) 479 for (const candidate of [dirCandidate, jsonCandidate]) { 480 try { 481 await readCachedMarketplace(candidate) 482 return candidate 483 } catch { 484 // Try next candidate 485 } 486 } 487 return null 488} 489 490/** 491 * If installLocation points into a configured seed directory, return that seed 492 * directory. Seed-managed entries are admin-controlled — users can't 493 * remove/refresh/modify them (they'd be overwritten by registerSeedMarketplaces 494 * on next startup). Returning the specific seed lets error messages name it. 495 */ 496function seedDirFor(installLocation: string): string | undefined { 497 return getPluginSeedDirs().find( 498 d => installLocation === d || installLocation.startsWith(d + sep), 499 ) 500} 501 502/** 503 * Git pull operation (exported for testing) 504 * 505 * Pulls latest changes with a configurable timeout (default 120s, override via CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS). 506 * Provides helpful error messages for common failure scenarios. 507 * If a ref is specified, fetches and checks out that specific branch or tag. 508 */ 509// Environment variables to prevent git from prompting for credentials 510const GIT_NO_PROMPT_ENV = { 511 GIT_TERMINAL_PROMPT: '0', // Prevent terminal credential prompts 512 GIT_ASKPASS: '', // Disable askpass GUI programs 513} 514 515const DEFAULT_PLUGIN_GIT_TIMEOUT_MS = 120 * 1000 516 517function getPluginGitTimeoutMs(): number { 518 const envValue = process.env.CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS 519 if (envValue) { 520 const parsed = parseInt(envValue, 10) 521 if (!isNaN(parsed) && parsed > 0) { 522 return parsed 523 } 524 } 525 return DEFAULT_PLUGIN_GIT_TIMEOUT_MS 526} 527 528export async function gitPull( 529 cwd: string, 530 ref?: string, 531 options?: { disableCredentialHelper?: boolean; sparsePaths?: string[] }, 532): Promise<{ code: number; stderr: string }> { 533 logForDebugging(`git pull: cwd=${cwd} ref=${ref ?? 'default'}`) 534 const env = { ...process.env, ...GIT_NO_PROMPT_ENV } 535 const credentialArgs = options?.disableCredentialHelper 536 ? ['-c', 'credential.helper='] 537 : [] 538 539 if (ref) { 540 const fetchResult = await execFileNoThrowWithCwd( 541 gitExe(), 542 [...credentialArgs, 'fetch', 'origin', ref], 543 { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, 544 ) 545 546 if (fetchResult.code !== 0) { 547 return enhanceGitPullErrorMessages(fetchResult) 548 } 549 550 const checkoutResult = await execFileNoThrowWithCwd( 551 gitExe(), 552 [...credentialArgs, 'checkout', ref], 553 { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, 554 ) 555 556 if (checkoutResult.code !== 0) { 557 return enhanceGitPullErrorMessages(checkoutResult) 558 } 559 560 const pullResult = await execFileNoThrowWithCwd( 561 gitExe(), 562 [...credentialArgs, 'pull', 'origin', ref], 563 { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, 564 ) 565 if (pullResult.code !== 0) { 566 return enhanceGitPullErrorMessages(pullResult) 567 } 568 await gitSubmoduleUpdate(cwd, credentialArgs, env, options?.sparsePaths) 569 return pullResult 570 } 571 572 const result = await execFileNoThrowWithCwd( 573 gitExe(), 574 [...credentialArgs, 'pull', 'origin', 'HEAD'], 575 { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, 576 ) 577 if (result.code !== 0) { 578 return enhanceGitPullErrorMessages(result) 579 } 580 await gitSubmoduleUpdate(cwd, credentialArgs, env, options?.sparsePaths) 581 return result 582} 583 584/** 585 * Sync submodule working dirs after a successful pull. gitClone() uses 586 * --recurse-submodules, but gitPull() didn't — the parent repo's submodule 587 * pointer would advance while the working dir stayed at the old commit, 588 * making plugin sources in submodules unresolvable after marketplace update. 589 * Non-fatal: a failed submodule update logs a warning; most marketplaces 590 * don't use submodules at all. (gh-30696) 591 * 592 * Skipped for sparse clones — gitClone's sparse path intentionally omits 593 * --recurse-submodules to preserve partial-clone bandwidth savings, and 594 * .gitmodules is a root file that cone-mode sparse-checkout always 595 * materializes, so the .gitmodules gate alone can't distinguish sparse repos. 596 * 597 * Perf: git-submodule is a bash script that spawns ~20 subprocesses (~35ms+) 598 * even when no submodules exist. .gitmodules is a tracked file — pull 599 * materializes it iff the repo has submodules — so gate on its presence to 600 * skip the spawn for the common case. 601 * 602 * --init performs first-contact clone of newly-added submodules, so maintain 603 * parity with gitClone's non-sparse path: StrictHostKeyChecking=yes for 604 * fail-closed SSH (unknown hosts reject rather than silently populate 605 * known_hosts), and --depth 1 for shallow clone (matching --shallow-submodules). 606 * --depth only affects not-yet-initialized submodules; existing shallow 607 * submodules are unaffected. 608 */ 609async function gitSubmoduleUpdate( 610 cwd: string, 611 credentialArgs: string[], 612 env: NodeJS.ProcessEnv, 613 sparsePaths: string[] | undefined, 614): Promise<void> { 615 if (sparsePaths && sparsePaths.length > 0) return 616 const hasGitmodules = await getFsImplementation() 617 .stat(join(cwd, '.gitmodules')) 618 .then( 619 () => true, 620 () => false, 621 ) 622 if (!hasGitmodules) return 623 const result = await execFileNoThrowWithCwd( 624 gitExe(), 625 [ 626 '-c', 627 'core.sshCommand=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes', 628 ...credentialArgs, 629 'submodule', 630 'update', 631 '--init', 632 '--recursive', 633 '--depth', 634 '1', 635 ], 636 { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, 637 ) 638 if (result.code !== 0) { 639 logForDebugging( 640 `git submodule update failed (non-fatal): ${result.stderr}`, 641 { level: 'warn' }, 642 ) 643 } 644} 645 646/** 647 * Enhance error messages for git pull failures 648 */ 649function enhanceGitPullErrorMessages(result: { 650 code: number 651 stderr: string 652 error?: string 653}): { code: number; stderr: string } { 654 if (result.code === 0) { 655 return result 656 } 657 658 // Detect execa timeout kills via the error field (stderr won't contain "timed out" 659 // when the process is killed by SIGTERM — the timeout info is only in error) 660 if (result.error?.includes('timed out')) { 661 const timeoutSec = Math.round(getPluginGitTimeoutMs() / 1000) 662 return { 663 ...result, 664 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}`, 665 } 666 } 667 668 // Detect SSH host key verification failures (check before the generic 669 // 'Could not read from remote' catch — that string appears in both cases). 670 // OpenSSH emits "Host key verification failed" for BOTH host-not-in-known_hosts 671 // and host-key-has-changed — the latter also includes the "REMOTE HOST 672 // IDENTIFICATION HAS CHANGED" banner, which needs different remediation. 673 if (result.stderr.includes('REMOTE HOST IDENTIFICATION HAS CHANGED')) { 674 return { 675 ...result, 676 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}`, 677 } 678 } 679 if (result.stderr.includes('Host key verification failed')) { 680 return { 681 ...result, 682 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}`, 683 } 684 } 685 686 // Detect SSH authentication failures 687 if ( 688 result.stderr.includes('Permission denied (publickey)') || 689 result.stderr.includes('Could not read from remote repository') 690 ) { 691 return { 692 ...result, 693 stderr: `SSH authentication failed while updating marketplace. Please ensure your SSH keys are configured.\n\nOriginal error: ${result.stderr}`, 694 } 695 } 696 697 // Detect network issues 698 if ( 699 result.stderr.includes('timed out') || 700 result.stderr.includes('Could not resolve host') 701 ) { 702 return { 703 ...result, 704 stderr: `Network error while updating marketplace. Please check your internet connection.\n\nOriginal error: ${result.stderr}`, 705 } 706 } 707 708 return result 709} 710 711/** 712 * Check if SSH is likely to work for GitHub 713 * This is a quick heuristic check that avoids the full clone timeout 714 * 715 * Uses StrictHostKeyChecking=yes (not accept-new) so an unknown github.com 716 * host key fails closed rather than being silently added to known_hosts. 717 * This prevents a network-level MITM from poisoning known_hosts on first 718 * contact. Users who already have github.com in known_hosts see no change; 719 * users who don't are routed to the HTTPS clone path. 720 * 721 * @returns true if SSH auth succeeds and github.com is already trusted 722 */ 723async function isGitHubSshLikelyConfigured(): Promise<boolean> { 724 try { 725 // Quick SSH connection test with 2 second timeout 726 // This fails fast if SSH isn't configured 727 const result = await execFileNoThrow( 728 'ssh', 729 [ 730 '-T', 731 '-o', 732 'BatchMode=yes', 733 '-o', 734 'ConnectTimeout=2', 735 '-o', 736 'StrictHostKeyChecking=yes', 737 'git@github.com', 738 ], 739 { 740 timeout: 3000, // 3 second total timeout 741 }, 742 ) 743 744 // SSH to github.com always returns exit code 1 with "successfully authenticated" 745 // or exit code 255 with "Permission denied" - we want the former 746 const configured = 747 result.code === 1 && 748 (result.stderr?.includes('successfully authenticated') || 749 result.stdout?.includes('successfully authenticated')) 750 logForDebugging( 751 `SSH config check: code=${result.code} configured=${configured}`, 752 ) 753 return configured 754 } catch (error) { 755 // Any error means SSH isn't configured properly 756 logForDebugging(`SSH configuration check failed: ${errorMessage(error)}`, { 757 level: 'warn', 758 }) 759 return false 760 } 761} 762 763/** 764 * Check if a git error indicates authentication failure. 765 * Used to provide enhanced error messages for auth failures. 766 */ 767function isAuthenticationError(stderr: string): boolean { 768 return ( 769 stderr.includes('Authentication failed') || 770 stderr.includes('could not read Username') || 771 stderr.includes('terminal prompts disabled') || 772 stderr.includes('403') || 773 stderr.includes('401') 774 ) 775} 776 777/** 778 * Extract the SSH host from a git URL for error messaging. 779 * Matches the SSH format user@host:path (e.g., git@github.com:owner/repo.git). 780 */ 781function extractSshHost(gitUrl: string): string | null { 782 const match = gitUrl.match(/^[^@]+@([^:]+):/) 783 return match?.[1] ?? null 784} 785 786/** 787 * Git clone operation (exported for testing) 788 * 789 * Clones a git repository with a configurable timeout (default 120s, override via CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS) 790 * and larger repositories. Provides helpful error messages for common failure scenarios. 791 * Optionally checks out a specific branch or tag. 792 * 793 * Does NOT disable credential helpers — this allows the user's existing auth setup 794 * (gh auth, keychain, git-credential-store, etc.) to work natively for private repos. 795 * Interactive prompts are still prevented via GIT_TERMINAL_PROMPT=0, GIT_ASKPASS='', 796 * stdin: 'ignore', and BatchMode=yes for SSH. 797 * 798 * Uses StrictHostKeyChecking=yes (not accept-new): unknown SSH hosts fail closed 799 * with a clear message rather than being silently trusted on first contact. For 800 * the github source type, the preflight check routes unknown-host users to HTTPS 801 * automatically; for explicit git@host:… URLs, users see an actionable error. 802 */ 803export async function gitClone( 804 gitUrl: string, 805 targetPath: string, 806 ref?: string, 807 sparsePaths?: string[], 808): Promise<{ code: number; stderr: string }> { 809 const useSparse = sparsePaths && sparsePaths.length > 0 810 const args = [ 811 '-c', 812 'core.sshCommand=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes', 813 'clone', 814 '--depth', 815 '1', 816 ] 817 818 if (useSparse) { 819 // Partial clone: skip blob download until checkout, defer checkout until 820 // after sparse-checkout is configured. Submodules are intentionally dropped 821 // for sparse clones — sparse monorepos rarely need them, and recursing 822 // submodules would defeat the partial-clone bandwidth savings. 823 args.push('--filter=blob:none', '--no-checkout') 824 } else { 825 args.push('--recurse-submodules', '--shallow-submodules') 826 } 827 828 if (ref) { 829 args.push('--branch', ref) 830 } 831 832 args.push(gitUrl, targetPath) 833 834 const timeoutMs = getPluginGitTimeoutMs() 835 logForDebugging( 836 `git clone: url=${redactUrlCredentials(gitUrl)} ref=${ref ?? 'default'} timeout=${timeoutMs}ms`, 837 ) 838 839 const result = await execFileNoThrowWithCwd(gitExe(), args, { 840 timeout: timeoutMs, 841 stdin: 'ignore', 842 env: { ...process.env, ...GIT_NO_PROMPT_ENV }, 843 }) 844 845 // Scrub credentials from execa's error/stderr fields before any logging or 846 // returning. execa's shortMessage embeds the full command line (including 847 // the credentialed URL), and result.stderr may also contain it on some git 848 // versions. 849 const redacted = redactUrlCredentials(gitUrl) 850 if (gitUrl !== redacted) { 851 if (result.error) result.error = result.error.replaceAll(gitUrl, redacted) 852 if (result.stderr) 853 result.stderr = result.stderr.replaceAll(gitUrl, redacted) 854 } 855 856 if (result.code === 0) { 857 if (useSparse) { 858 // Configure the sparse cone, then materialize only those paths. 859 // `sparse-checkout set --cone` handles both init and path selection 860 // in a single step on git >= 2.25. 861 const sparseResult = await execFileNoThrowWithCwd( 862 gitExe(), 863 ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths], 864 { 865 cwd: targetPath, 866 timeout: timeoutMs, 867 stdin: 'ignore', 868 env: { ...process.env, ...GIT_NO_PROMPT_ENV }, 869 }, 870 ) 871 if (sparseResult.code !== 0) { 872 return { 873 code: sparseResult.code, 874 stderr: `git sparse-checkout set failed: ${sparseResult.stderr}`, 875 } 876 } 877 878 const checkoutResult = await execFileNoThrowWithCwd( 879 gitExe(), 880 // ref was already passed to clone via --branch, so HEAD points to it; 881 // if no ref, HEAD points to the remote's default branch. 882 ['checkout', 'HEAD'], 883 { 884 cwd: targetPath, 885 timeout: timeoutMs, 886 stdin: 'ignore', 887 env: { ...process.env, ...GIT_NO_PROMPT_ENV }, 888 }, 889 ) 890 if (checkoutResult.code !== 0) { 891 return { 892 code: checkoutResult.code, 893 stderr: `git checkout after sparse-checkout failed: ${checkoutResult.stderr}`, 894 } 895 } 896 } 897 logForDebugging(`git clone succeeded: ${redactUrlCredentials(gitUrl)}`) 898 return result 899 } 900 901 logForDebugging( 902 `git clone failed: url=${redactUrlCredentials(gitUrl)} code=${result.code} error=${result.error ?? 'none'} stderr=${result.stderr}`, 903 { level: 'warn' }, 904 ) 905 906 // Detect timeout kills — when execFileNoThrowWithCwd kills the process via SIGTERM, 907 // stderr may only contain partial output (e.g. "Cloning into '...'") with no 908 // "timed out" string. Check the error field from execa which contains the 909 // timeout message. 910 if (result.error?.includes('timed out')) { 911 return { 912 ...result, 913 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}`, 914 } 915 } 916 917 // Enhance error messages for common scenarios 918 if (result.stderr) { 919 // Host key verification failure — check FIRST, before the generic 920 // 'Could not read from remote repository' catch (that string appears 921 // in both stderr outputs, so order matters). OpenSSH emits 922 // "Host key verification failed" for BOTH host-not-in-known_hosts and 923 // host-key-has-changed; distinguish them by the key-change banner. 924 if (result.stderr.includes('REMOTE HOST IDENTIFICATION HAS CHANGED')) { 925 const host = extractSshHost(gitUrl) 926 const removeHint = host ? `ssh-keygen -R ${host}` : 'ssh-keygen -R <host>' 927 return { 928 ...result, 929 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}`, 930 } 931 } 932 if (result.stderr.includes('Host key verification failed')) { 933 const host = extractSshHost(gitUrl) 934 const connectHint = host ? `ssh -T git@${host}` : 'ssh -T git@<host>' 935 return { 936 ...result, 937 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}`, 938 } 939 } 940 941 if ( 942 result.stderr.includes('Permission denied (publickey)') || 943 result.stderr.includes('Could not read from remote repository') 944 ) { 945 return { 946 ...result, 947 stderr: `SSH authentication failed. Please ensure your SSH keys are configured for GitHub, or use an HTTPS URL instead.\n\nOriginal error: ${result.stderr}`, 948 } 949 } 950 951 if (isAuthenticationError(result.stderr)) { 952 return { 953 ...result, 954 stderr: `HTTPS authentication failed. Please ensure your credential helper is configured (e.g., gh auth login).\n\nOriginal error: ${result.stderr}`, 955 } 956 } 957 958 if ( 959 result.stderr.includes('timed out') || 960 result.stderr.includes('timeout') || 961 result.stderr.includes('Could not resolve host') 962 ) { 963 return { 964 ...result, 965 stderr: `Network error or timeout while cloning repository. Please check your internet connection and try again.\n\nOriginal error: ${result.stderr}`, 966 } 967 } 968 } 969 970 // Fallback for empty stderr — gh-28373: user saw "Failed to clone 971 // marketplace repository:" with nothing after the colon. Git CAN fail 972 // without writing to stderr (stdout instead, or output swallowed by 973 // credential helper / signal). execa's error field has the execa-level 974 // message (command, exit code, signal); exit code is the minimum. 975 if (!result.stderr) { 976 return { 977 code: result.code, 978 stderr: 979 result.error || 980 `git clone exited with code ${result.code} (no stderr output). Run with --debug to see the full command.`, 981 } 982 } 983 984 return result 985} 986 987/** 988 * Progress callback for marketplace operations. 989 * 990 * This callback is invoked at various stages during marketplace operations 991 * (downloading, git operations, validation, etc.) to provide user feedback. 992 * 993 * IMPORTANT: Implementations should handle errors internally and not throw exceptions. 994 * If a callback throws, it will be caught and logged but won't abort the operation. 995 * 996 * @param message - Human-readable progress message to display to the user 997 */ 998export type MarketplaceProgressCallback = (message: string) => void 999 1000/** 1001 * Safely invoke a progress callback, catching and logging any errors. 1002 * Prevents callback errors from aborting marketplace operations. 1003 * 1004 * @param onProgress - The progress callback to invoke 1005 * @param message - Progress message to pass to the callback 1006 */ 1007function safeCallProgress( 1008 onProgress: MarketplaceProgressCallback | undefined, 1009 message: string, 1010): void { 1011 if (!onProgress) return 1012 try { 1013 onProgress(message) 1014 } catch (callbackError) { 1015 logForDebugging(`Progress callback error: ${errorMessage(callbackError)}`, { 1016 level: 'warn', 1017 }) 1018 } 1019} 1020 1021/** 1022 * Reconcile the on-disk sparse-checkout state with the desired config. 1023 * 1024 * Runs before gitPull to handle transitions: 1025 * - Full→Sparse or SparseA→SparseB: run `sparse-checkout set --cone` (idempotent) 1026 * - Sparse→Full: return non-zero so caller falls back to rm+reclone. Avoids 1027 * `sparse-checkout disable` on a --filter=blob:none partial clone, which would 1028 * trigger a lazy fetch of every blob in the monorepo. 1029 * - Full→Full (common case): single local `git config --get` check, no-op. 1030 * 1031 * Failures here (ENOENT, not a repo) are harmless — gitPull will also fail and 1032 * trigger the clone path, which establishes the correct state from scratch. 1033 */ 1034export async function reconcileSparseCheckout( 1035 cwd: string, 1036 sparsePaths: string[] | undefined, 1037): Promise<{ code: number; stderr: string }> { 1038 const env = { ...process.env, ...GIT_NO_PROMPT_ENV } 1039 1040 if (sparsePaths && sparsePaths.length > 0) { 1041 return execFileNoThrowWithCwd( 1042 gitExe(), 1043 ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths], 1044 { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env }, 1045 ) 1046 } 1047 1048 const check = await execFileNoThrowWithCwd( 1049 gitExe(), 1050 ['config', '--get', 'core.sparseCheckout'], 1051 { cwd, stdin: 'ignore', env }, 1052 ) 1053 if (check.code === 0 && check.stdout.trim() === 'true') { 1054 return { 1055 code: 1, 1056 stderr: 1057 'sparsePaths removed from config but repository is sparse; re-cloning for full checkout', 1058 } 1059 } 1060 return { code: 0, stderr: '' } 1061} 1062 1063/** 1064 * Cache a marketplace from a git repository 1065 * 1066 * Clones or updates a git repository containing marketplace data. 1067 * If the repository already exists at cachePath, pulls the latest changes. 1068 * If pulling fails, removes the directory and re-clones. 1069 * 1070 * Example repository structure: 1071 * ``` 1072 * my-marketplace/ 1073 * ├── .claude-plugin/ 1074 * │ └── marketplace.json # Default location for marketplace manifest 1075 * ├── plugins/ # Plugin implementations 1076 * └── README.md 1077 * ``` 1078 * 1079 * @param gitUrl - The git URL to clone (https or ssh) 1080 * @param cachePath - Local directory path to clone/update the repository 1081 * @param ref - Optional git branch or tag to checkout 1082 * @param onProgress - Optional callback to report progress 1083 */ 1084async function cacheMarketplaceFromGit( 1085 gitUrl: string, 1086 cachePath: string, 1087 ref?: string, 1088 sparsePaths?: string[], 1089 onProgress?: MarketplaceProgressCallback, 1090 options?: { disableCredentialHelper?: boolean }, 1091): Promise<void> { 1092 const fs = getFsImplementation() 1093 1094 // Attempt incremental update; fall back to re-clone if the repo is absent, 1095 // stale, or otherwise not updatable. Using pull-first avoids a stat-before-operate 1096 // TOCTOU check: gitPull returns non-zero when cachePath is missing or has no .git. 1097 const timeoutSec = Math.round(getPluginGitTimeoutMs() / 1000) 1098 safeCallProgress( 1099 onProgress, 1100 `Refreshing marketplace cache (timeout: ${timeoutSec}s)…`, 1101 ) 1102 1103 // Reconcile sparse-checkout config before pulling. If this requires a re-clone 1104 // (Sparse→Full transition) or fails (missing dir, not a repo), skip straight 1105 // to the rm+clone fallback. 1106 const reconcileResult = await reconcileSparseCheckout(cachePath, sparsePaths) 1107 if (reconcileResult.code === 0) { 1108 const pullStarted = performance.now() 1109 const pullResult = await gitPull(cachePath, ref, { 1110 disableCredentialHelper: options?.disableCredentialHelper, 1111 sparsePaths, 1112 }) 1113 logPluginFetch( 1114 'marketplace_pull', 1115 gitUrl, 1116 pullResult.code === 0 ? 'success' : 'failure', 1117 performance.now() - pullStarted, 1118 pullResult.code === 0 ? undefined : classifyFetchError(pullResult.stderr), 1119 ) 1120 if (pullResult.code === 0) return 1121 logForDebugging(`git pull failed, will re-clone: ${pullResult.stderr}`, { 1122 level: 'warn', 1123 }) 1124 } else { 1125 logForDebugging( 1126 `sparse-checkout reconcile requires re-clone: ${reconcileResult.stderr}`, 1127 ) 1128 } 1129 1130 try { 1131 await fs.rm(cachePath, { recursive: true }) 1132 // rm succeeded — a stale or partially-cloned directory existed; log for diagnostics 1133 logForDebugging( 1134 `Found stale marketplace directory at ${cachePath}, cleaning up to allow re-clone`, 1135 { level: 'warn' }, 1136 ) 1137 safeCallProgress( 1138 onProgress, 1139 'Found stale directory, cleaning up and re-cloning…', 1140 ) 1141 } catch (rmError) { 1142 if (!isENOENT(rmError)) { 1143 const rmErrorMsg = errorMessage(rmError) 1144 throw new Error( 1145 `Failed to clean up existing marketplace directory. Please manually delete the directory at ${cachePath} and try again.\n\nTechnical details: ${rmErrorMsg}`, 1146 ) 1147 } 1148 // ENOENT — cachePath didn't exist, this is a fresh install, nothing to clean up 1149 } 1150 1151 // Clone the repository (one attempt — no internal retry loop) 1152 const refMessage = ref ? ` (ref: ${ref})` : '' 1153 safeCallProgress( 1154 onProgress, 1155 `Cloning repository (timeout: ${timeoutSec}s): ${redactUrlCredentials(gitUrl)}${refMessage}`, 1156 ) 1157 const cloneStarted = performance.now() 1158 const result = await gitClone(gitUrl, cachePath, ref, sparsePaths) 1159 logPluginFetch( 1160 'marketplace_clone', 1161 gitUrl, 1162 result.code === 0 ? 'success' : 'failure', 1163 performance.now() - cloneStarted, 1164 result.code === 0 ? undefined : classifyFetchError(result.stderr), 1165 ) 1166 if (result.code !== 0) { 1167 // Clean up any partial directory created by the failed clone so the next 1168 // attempt starts fresh. Best-effort: if this fails, the stale dir will be 1169 // auto-detected and removed at the top of the next call. 1170 try { 1171 await fs.rm(cachePath, { recursive: true, force: true }) 1172 } catch { 1173 // ignore 1174 } 1175 throw new Error(`Failed to clone marketplace repository: ${result.stderr}`) 1176 } 1177 safeCallProgress(onProgress, 'Clone complete, validating marketplace…') 1178} 1179 1180/** 1181 * Redact header values for safe logging 1182 * 1183 * @param headers - Headers to redact 1184 * @returns Headers with values replaced by '***REDACTED***' 1185 */ 1186function redactHeaders( 1187 headers: Record<string, string>, 1188): Record<string, string> { 1189 return Object.fromEntries( 1190 Object.entries(headers).map(([key]) => [key, '***REDACTED***']), 1191 ) 1192} 1193 1194/** 1195 * Redact userinfo (username:password) in a URL to avoid logging credentials. 1196 * 1197 * Marketplace URLs may embed credentials (e.g. GitHub PATs in 1198 * `https://user:token@github.com/org/repo`). Debug logs and progress output 1199 * are written to disk and may be included in bug reports, so credentials must 1200 * be redacted before logging. 1201 * 1202 * Redacts all credentials from http(s) URLs: 1203 * https://user:token@github.com/repo → https://***:***@github.com/repo 1204 * https://:token@github.com/repo → https://:***@github.com/repo 1205 * https://token@github.com/repo → https://***@github.com/repo 1206 * 1207 * Both username and password are redacted unconditionally on http(s) because 1208 * it is impossible to distinguish `placeholder:secret` (e.g. x-access-token:ghp_...) 1209 * from `secret:placeholder` (e.g. ghp_...:x-oauth-basic) by parsing alone. 1210 * Non-http(s) schemes (ssh://git@...) and non-URL inputs (`owner/repo` shorthand) 1211 * pass through unchanged. 1212 */ 1213function redactUrlCredentials(urlString: string): string { 1214 try { 1215 const parsed = new URL(urlString) 1216 const isHttp = parsed.protocol === 'http:' || parsed.protocol === 'https:' 1217 if (isHttp && (parsed.username || parsed.password)) { 1218 if (parsed.username) parsed.username = '***' 1219 if (parsed.password) parsed.password = '***' 1220 return parsed.toString() 1221 } 1222 } catch { 1223 // Not a valid URL — safe as-is 1224 } 1225 return urlString 1226} 1227 1228/** 1229 * Cache a marketplace from a URL 1230 * 1231 * Downloads a marketplace.json file from a URL and saves it locally. 1232 * Creates the cache directory structure if it doesn't exist. 1233 * 1234 * Example marketplace.json structure: 1235 * ```json 1236 * { 1237 * "name": "my-marketplace", 1238 * "owner": { "name": "John Doe", "email": "john@example.com" }, 1239 * "plugins": [ 1240 * { 1241 * "id": "my-plugin", 1242 * "name": "My Plugin", 1243 * "source": "./plugins/my-plugin.json", 1244 * "category": "productivity", 1245 * "description": "A helpful plugin" 1246 * } 1247 * ] 1248 * } 1249 * ``` 1250 * 1251 * @param url - The URL to download the marketplace.json from 1252 * @param cachePath - Local file path to save the downloaded marketplace 1253 * @param customHeaders - Optional custom HTTP headers for authentication 1254 * @param onProgress - Optional callback to report progress 1255 */ 1256async function cacheMarketplaceFromUrl( 1257 url: string, 1258 cachePath: string, 1259 customHeaders?: Record<string, string>, 1260 onProgress?: MarketplaceProgressCallback, 1261): Promise<void> { 1262 const fs = getFsImplementation() 1263 1264 const redactedUrl = redactUrlCredentials(url) 1265 safeCallProgress(onProgress, `Downloading marketplace from ${redactedUrl}`) 1266 logForDebugging(`Downloading marketplace from URL: ${redactedUrl}`) 1267 if (customHeaders && Object.keys(customHeaders).length > 0) { 1268 logForDebugging( 1269 `Using custom headers: ${jsonStringify(redactHeaders(customHeaders))}`, 1270 ) 1271 } 1272 1273 const headers = { 1274 ...customHeaders, 1275 // User-Agent must come last to prevent override (for consistency with WebFetch) 1276 'User-Agent': 'Claude-Code-Plugin-Manager', 1277 } 1278 1279 let response 1280 const fetchStarted = performance.now() 1281 try { 1282 response = await axios.get(url, { 1283 timeout: 10000, 1284 headers, 1285 }) 1286 } catch (error) { 1287 logPluginFetch( 1288 'marketplace_url', 1289 url, 1290 'failure', 1291 performance.now() - fetchStarted, 1292 classifyFetchError(error), 1293 ) 1294 if (axios.isAxiosError(error)) { 1295 if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { 1296 throw new Error( 1297 `Could not connect to ${redactedUrl}. Please check your internet connection and verify the URL is correct.\n\nTechnical details: ${error.message}`, 1298 ) 1299 } 1300 if (error.code === 'ETIMEDOUT') { 1301 throw new Error( 1302 `Request timed out while downloading marketplace from ${redactedUrl}. The server may be slow or unreachable.\n\nTechnical details: ${error.message}`, 1303 ) 1304 } 1305 if (error.response) { 1306 throw new Error( 1307 `HTTP ${error.response.status} error while downloading marketplace from ${redactedUrl}. The marketplace file may not exist at this URL.\n\nTechnical details: ${error.message}`, 1308 ) 1309 } 1310 } 1311 throw new Error( 1312 `Failed to download marketplace from ${redactedUrl}: ${errorMessage(error)}`, 1313 ) 1314 } 1315 1316 safeCallProgress(onProgress, 'Validating marketplace data') 1317 // Validate the response is a valid marketplace 1318 const result = PluginMarketplaceSchema().safeParse(response.data) 1319 if (!result.success) { 1320 logPluginFetch( 1321 'marketplace_url', 1322 url, 1323 'failure', 1324 performance.now() - fetchStarted, 1325 'invalid_schema', 1326 ) 1327 throw new ConfigParseError( 1328 `Invalid marketplace schema from URL: ${result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`, 1329 redactedUrl, 1330 response.data, 1331 ) 1332 } 1333 logPluginFetch( 1334 'marketplace_url', 1335 url, 1336 'success', 1337 performance.now() - fetchStarted, 1338 ) 1339 1340 safeCallProgress(onProgress, 'Saving marketplace to cache') 1341 // Ensure cache directory exists 1342 const cacheDir = join(cachePath, '..') 1343 await fs.mkdir(cacheDir) 1344 1345 // Write the validated marketplace file 1346 writeFileSync_DEPRECATED(cachePath, jsonStringify(result.data, null, 2), { 1347 encoding: 'utf-8', 1348 flush: true, 1349 }) 1350} 1351 1352/** 1353 * Generate a cache path for a marketplace source 1354 */ 1355function getCachePathForSource(source: MarketplaceSource): string { 1356 const tempName = 1357 source.source === 'github' 1358 ? source.repo.replace('/', '-') 1359 : source.source === 'npm' 1360 ? source.package.replace('@', '').replace('/', '-') 1361 : source.source === 'file' 1362 ? basename(source.path).replace('.json', '') 1363 : source.source === 'directory' 1364 ? basename(source.path) 1365 : 'temp_' + Date.now() 1366 return tempName 1367} 1368 1369/** 1370 * Parse and validate JSON file with a Zod schema 1371 */ 1372async function parseFileWithSchema<T>( 1373 filePath: string, 1374 schema: { 1375 safeParse: (data: unknown) => { 1376 success: boolean 1377 data?: T 1378 error?: { 1379 issues: Array<{ path: PropertyKey[]; message: string }> 1380 } 1381 } 1382 }, 1383): Promise<T> { 1384 const fs = getFsImplementation() 1385 const content = await fs.readFile(filePath, { encoding: 'utf-8' }) 1386 let data: unknown 1387 try { 1388 data = jsonParse(content) 1389 } catch (error) { 1390 throw new ConfigParseError( 1391 `Invalid JSON in ${filePath}: ${errorMessage(error)}`, 1392 filePath, 1393 content, 1394 ) 1395 } 1396 const result = schema.safeParse(data) 1397 if (!result.success) { 1398 throw new ConfigParseError( 1399 `Invalid schema: ${filePath} ${result.error?.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`, 1400 filePath, 1401 data, 1402 ) 1403 } 1404 return result.data! 1405} 1406 1407/** 1408 * Load and cache a marketplace from its source 1409 * 1410 * Handles different source types: 1411 * - URL: Downloads marketplace.json directly 1412 * - GitHub: Clones repo and looks for .claude-plugin/marketplace.json 1413 * - Git: Clones repository from git URL 1414 * - NPM: (Not yet implemented) Would fetch from npm package 1415 * - File: Reads from local filesystem 1416 * 1417 * After loading, validates the marketplace schema and renames the cache 1418 * to match the marketplace's actual name from the manifest. 1419 * 1420 * Cache structure: 1421 * ~/.claude/plugins/marketplaces/ 1422 * ├── official-marketplace.json # From URL source 1423 * ├── github-marketplace/ # From GitHub/Git source 1424 * │ └── .claude-plugin/ 1425 * │ └── marketplace.json 1426 * └── local-marketplace.json # From file source 1427 * 1428 * @param source - The marketplace source to load from 1429 * @param onProgress - Optional callback to report progress 1430 * @returns Object containing the validated marketplace and its cache path 1431 * @throws If marketplace file not found or validation fails 1432 */ 1433async function loadAndCacheMarketplace( 1434 source: MarketplaceSource, 1435 onProgress?: MarketplaceProgressCallback, 1436): Promise<LoadedPluginMarketplace> { 1437 const fs = getFsImplementation() 1438 const cacheDir = getMarketplacesCacheDir() 1439 1440 // Ensure cache directory exists 1441 await fs.mkdir(cacheDir) 1442 1443 let temporaryCachePath: string 1444 let marketplacePath: string 1445 let cleanupNeeded = false 1446 1447 // Generate a temp name for the cache path 1448 const tempName = getCachePathForSource(source) 1449 1450 try { 1451 switch (source.source) { 1452 case 'url': { 1453 // Direct URL to marketplace.json 1454 temporaryCachePath = join(cacheDir, `${tempName}.json`) 1455 cleanupNeeded = true 1456 await cacheMarketplaceFromUrl( 1457 source.url, 1458 temporaryCachePath, 1459 source.headers, 1460 onProgress, 1461 ) 1462 marketplacePath = temporaryCachePath 1463 break 1464 } 1465 1466 case 'github': { 1467 // Smart SSH/HTTPS selection: check if SSH is configured before trying it 1468 // This avoids waiting for timeout on SSH when it's not configured 1469 const sshUrl = `git@github.com:${source.repo}.git` 1470 const httpsUrl = `https://github.com/${source.repo}.git` 1471 temporaryCachePath = join(cacheDir, tempName) 1472 cleanupNeeded = true 1473 1474 let lastError: Error | null = null 1475 1476 // Quick check if SSH is likely to work 1477 const sshConfigured = await isGitHubSshLikelyConfigured() 1478 1479 if (sshConfigured) { 1480 // SSH looks good, try it first 1481 safeCallProgress(onProgress, `Cloning via SSH: ${sshUrl}`) 1482 try { 1483 await cacheMarketplaceFromGit( 1484 sshUrl, 1485 temporaryCachePath, 1486 source.ref, 1487 source.sparsePaths, 1488 onProgress, 1489 ) 1490 } catch (err) { 1491 lastError = toError(err) 1492 1493 // Log SSH failure for monitoring 1494 logError(lastError) 1495 1496 // SSH failed despite being configured, try HTTPS fallback 1497 safeCallProgress( 1498 onProgress, 1499 `SSH clone failed, retrying with HTTPS: ${httpsUrl}`, 1500 ) 1501 1502 logForDebugging( 1503 `SSH clone failed for ${source.repo} despite SSH being configured, falling back to HTTPS`, 1504 { level: 'info' }, 1505 ) 1506 1507 // Clean up failed SSH attempt if it created anything 1508 await fs.rm(temporaryCachePath, { recursive: true, force: true }) 1509 1510 // Try HTTPS 1511 try { 1512 await cacheMarketplaceFromGit( 1513 httpsUrl, 1514 temporaryCachePath, 1515 source.ref, 1516 source.sparsePaths, 1517 onProgress, 1518 ) 1519 lastError = null // Success! 1520 } catch (httpsErr) { 1521 // HTTPS also failed - use HTTPS error as the final error 1522 lastError = toError(httpsErr) 1523 1524 // Log HTTPS failure for monitoring (both SSH and HTTPS failed) 1525 logError(lastError) 1526 } 1527 } 1528 } else { 1529 // SSH not configured, go straight to HTTPS 1530 safeCallProgress( 1531 onProgress, 1532 `SSH not configured, cloning via HTTPS: ${httpsUrl}`, 1533 ) 1534 1535 logForDebugging( 1536 `SSH not configured for GitHub, using HTTPS for ${source.repo}`, 1537 { level: 'info' }, 1538 ) 1539 1540 try { 1541 await cacheMarketplaceFromGit( 1542 httpsUrl, 1543 temporaryCachePath, 1544 source.ref, 1545 source.sparsePaths, 1546 onProgress, 1547 ) 1548 } catch (err) { 1549 lastError = toError(err) 1550 1551 // Always try SSH as fallback for ANY HTTPS failure 1552 // Log HTTPS failure for monitoring 1553 logError(lastError) 1554 1555 // HTTPS failed, try SSH as fallback 1556 safeCallProgress( 1557 onProgress, 1558 `HTTPS clone failed, retrying with SSH: ${sshUrl}`, 1559 ) 1560 1561 logForDebugging( 1562 `HTTPS clone failed for ${source.repo} (${lastError.message}), falling back to SSH`, 1563 { level: 'info' }, 1564 ) 1565 1566 // Clean up failed HTTPS attempt if it created anything 1567 await fs.rm(temporaryCachePath, { recursive: true, force: true }) 1568 1569 // Try SSH 1570 try { 1571 await cacheMarketplaceFromGit( 1572 sshUrl, 1573 temporaryCachePath, 1574 source.ref, 1575 source.sparsePaths, 1576 onProgress, 1577 ) 1578 lastError = null // Success! 1579 } catch (sshErr) { 1580 // SSH also failed - use SSH error as the final error 1581 lastError = toError(sshErr) 1582 1583 // Log SSH failure for monitoring (both HTTPS and SSH failed) 1584 logError(lastError) 1585 } 1586 } 1587 } 1588 1589 // If we still have an error, throw it 1590 if (lastError) { 1591 throw lastError 1592 } 1593 1594 marketplacePath = join( 1595 temporaryCachePath, 1596 source.path || '.claude-plugin/marketplace.json', 1597 ) 1598 break 1599 } 1600 1601 case 'git': { 1602 temporaryCachePath = join(cacheDir, tempName) 1603 cleanupNeeded = true 1604 await cacheMarketplaceFromGit( 1605 source.url, 1606 temporaryCachePath, 1607 source.ref, 1608 source.sparsePaths, 1609 onProgress, 1610 ) 1611 marketplacePath = join( 1612 temporaryCachePath, 1613 source.path || '.claude-plugin/marketplace.json', 1614 ) 1615 break 1616 } 1617 1618 case 'npm': { 1619 // TODO: Implement npm package support 1620 throw new Error('NPM marketplace sources not yet implemented') 1621 } 1622 1623 case 'file': { 1624 // For local files, resolve paths relative to marketplace root directory 1625 // File sources point to .claude-plugin/marketplace.json, so the marketplace 1626 // root is two directories up (parent of .claude-plugin/) 1627 // Resolve to absolute so error messages show the actual path checked 1628 // (legacy known_marketplaces.json entries may have relative paths) 1629 const absPath = resolve(source.path) 1630 marketplacePath = absPath 1631 temporaryCachePath = dirname(dirname(absPath)) 1632 cleanupNeeded = false 1633 break 1634 } 1635 1636 case 'directory': { 1637 // For directories, look for .claude-plugin/marketplace.json 1638 // Resolve to absolute so error messages show the actual path checked 1639 // (legacy known_marketplaces.json entries may have relative paths) 1640 const absPath = resolve(source.path) 1641 marketplacePath = join(absPath, '.claude-plugin', 'marketplace.json') 1642 temporaryCachePath = absPath 1643 cleanupNeeded = false 1644 break 1645 } 1646 1647 case 'settings': { 1648 // Inline manifest from settings.json — no fetch. Synthesize the 1649 // marketplace.json on disk so getMarketplaceCacheOnly reads it 1650 // like any other source. The plugins array already passed 1651 // PluginMarketplaceEntrySchema validation when settings were parsed; 1652 // the post-switch parseFileWithSchema re-validates the full 1653 // PluginMarketplaceSchema (catches schema drift between the two). 1654 // 1655 // Writing to source.name up front means the rename below is a no-op 1656 // (temporaryCachePath === finalCachePath). known_marketplaces.json 1657 // stores this source object including the plugins array, so 1658 // diffMarketplaces detects settings edits via isEqual — no special 1659 // dirty-tracking needed. 1660 temporaryCachePath = join(cacheDir, source.name) 1661 marketplacePath = join( 1662 temporaryCachePath, 1663 '.claude-plugin', 1664 'marketplace.json', 1665 ) 1666 cleanupNeeded = false 1667 await fs.mkdir(dirname(marketplacePath)) 1668 // No `satisfies PluginMarketplace` here: source.plugins is the narrow 1669 // SettingsMarketplacePlugin type (no strict/.default(), no manifest 1670 // fields). The parseFileWithSchema(PluginMarketplaceSchema()) call 1671 // below widens and validates — that's the real check. 1672 await writeFile( 1673 marketplacePath, 1674 jsonStringify( 1675 { 1676 name: source.name, 1677 owner: source.owner ?? { name: 'settings' }, 1678 plugins: source.plugins, 1679 }, 1680 null, 1681 2, 1682 ), 1683 ) 1684 break 1685 } 1686 1687 default: 1688 throw new Error(`Unsupported marketplace source type`) 1689 } 1690 1691 // Load and validate the marketplace 1692 logForDebugging(`Reading marketplace from ${marketplacePath}`) 1693 let marketplace: PluginMarketplace 1694 try { 1695 marketplace = await parseFileWithSchema( 1696 marketplacePath, 1697 PluginMarketplaceSchema(), 1698 ) 1699 } catch (e) { 1700 if (isENOENT(e)) { 1701 throw new Error(`Marketplace file not found at ${marketplacePath}`) 1702 } 1703 throw new Error( 1704 `Failed to parse marketplace file at ${marketplacePath}: ${errorMessage(e)}`, 1705 ) 1706 } 1707 1708 // Now rename the cache path to use the marketplace's actual name 1709 const finalCachePath = join(cacheDir, marketplace.name) 1710 // Defense-in-depth: the schema rejects path separators, .., and . in marketplace.name, 1711 // but verify the computed path is a strict subdirectory of cacheDir before fs.rm. 1712 // A malicious marketplace.json with a crafted name must never cause us to rm outside 1713 // cacheDir, nor rm cacheDir itself (e.g. name "." → join normalizes to cacheDir). 1714 const resolvedFinal = resolve(finalCachePath) 1715 const resolvedCacheDir = resolve(cacheDir) 1716 if (!resolvedFinal.startsWith(resolvedCacheDir + sep)) { 1717 throw new Error( 1718 `Marketplace name '${marketplace.name}' resolves to a path outside the cache directory`, 1719 ) 1720 } 1721 // Don't rename if it's a local file or directory, or already has the right name 1722 if ( 1723 temporaryCachePath !== finalCachePath && 1724 !isLocalMarketplaceSource(source) 1725 ) { 1726 try { 1727 // Remove the destination if it already exists, then rename 1728 try { 1729 onProgress?.('Cleaning up old marketplace cache…') 1730 } catch (callbackError) { 1731 logForDebugging( 1732 `Progress callback error: ${errorMessage(callbackError)}`, 1733 { level: 'warn' }, 1734 ) 1735 } 1736 await fs.rm(finalCachePath, { recursive: true, force: true }) 1737 // Rename temp cache to final name 1738 await fs.rename(temporaryCachePath, finalCachePath) 1739 temporaryCachePath = finalCachePath 1740 cleanupNeeded = false // Successfully renamed, no cleanup needed 1741 } catch (error) { 1742 const errorMsg = errorMessage(error) 1743 throw new Error( 1744 `Failed to finalize marketplace cache. Please manually delete the directory at ${finalCachePath} if it exists and try again.\n\nTechnical details: ${errorMsg}`, 1745 ) 1746 } 1747 } 1748 1749 return { marketplace, cachePath: temporaryCachePath } 1750 } catch (error) { 1751 // Clean up any temporary files/directories on error 1752 if ( 1753 cleanupNeeded && 1754 temporaryCachePath! && 1755 !isLocalMarketplaceSource(source) 1756 ) { 1757 try { 1758 await fs.rm(temporaryCachePath!, { recursive: true, force: true }) 1759 } catch (cleanupError) { 1760 logForDebugging( 1761 `Warning: Failed to clean up temporary marketplace cache at ${temporaryCachePath}: ${errorMessage(cleanupError)}`, 1762 { level: 'warn' }, 1763 ) 1764 } 1765 } 1766 throw error 1767 } 1768} 1769 1770/** 1771 * Add a marketplace source to the known marketplaces 1772 * 1773 * The marketplace is fetched, validated, and cached locally. 1774 * The configuration is saved to ~/.claude/plugins/known_marketplaces.json. 1775 * 1776 * @param source - MarketplaceSource object representing the marketplace source. 1777 * Callers should parse user input into MarketplaceSource format 1778 * (see AddMarketplace.parseMarketplaceInput for handling shortcuts like "owner/repo"). 1779 * @param onProgress - Optional callback for progress updates during marketplace installation 1780 * @throws If source format is invalid or marketplace cannot be loaded 1781 */ 1782export async function addMarketplaceSource( 1783 source: MarketplaceSource, 1784 onProgress?: MarketplaceProgressCallback, 1785): Promise<{ 1786 name: string 1787 alreadyMaterialized: boolean 1788 resolvedSource: MarketplaceSource 1789}> { 1790 // Resolve relative directory/file paths to absolute so state is cwd-independent 1791 let resolvedSource = source 1792 if (isLocalMarketplaceSource(source) && !isAbsolute(source.path)) { 1793 resolvedSource = { ...source, path: resolve(source.path) } 1794 } 1795 1796 // Check policy FIRST, before any network/filesystem operations 1797 // This prevents downloading/cloning when the source is blocked 1798 if (!isSourceAllowedByPolicy(resolvedSource)) { 1799 // Check if explicitly blocked vs not in allowlist for better error messages 1800 if (isSourceInBlocklist(resolvedSource)) { 1801 throw new Error( 1802 `Marketplace source '${formatSourceForDisplay(resolvedSource)}' is blocked by enterprise policy.`, 1803 ) 1804 } 1805 // Not in allowlist - build helpful error message 1806 const allowlist = getStrictKnownMarketplaces() || [] 1807 const hostPatterns = getHostPatternsFromAllowlist() 1808 const sourceHost = extractHostFromSource(resolvedSource) 1809 1810 let errorMessage = `Marketplace source '${formatSourceForDisplay(resolvedSource)}'` 1811 if (sourceHost) { 1812 errorMessage += ` (${sourceHost})` 1813 } 1814 errorMessage += ' is blocked by enterprise policy.' 1815 1816 if (allowlist.length > 0) { 1817 errorMessage += ` Allowed sources: ${allowlist.map(s => formatSourceForDisplay(s)).join(', ')}` 1818 } else { 1819 errorMessage += ' No external marketplaces are allowed.' 1820 } 1821 1822 // If source is a github shorthand and there are hostPatterns, suggest using full URL 1823 if (resolvedSource.source === 'github' && hostPatterns.length > 0) { 1824 errorMessage += 1825 `\n\nTip: The shorthand "${resolvedSource.repo}" assumes github.com. ` + 1826 `For internal GitHub Enterprise, use the full URL:\n` + 1827 ` git@your-github-host.com:${resolvedSource.repo}.git` 1828 } 1829 1830 throw new Error(errorMessage) 1831 } 1832 1833 // Source-idempotency: if this exact source already exists, skip clone 1834 const existingConfig = await loadKnownMarketplacesConfig() 1835 for (const [existingName, existingEntry] of Object.entries(existingConfig)) { 1836 if (isEqual(existingEntry.source, resolvedSource)) { 1837 logForDebugging( 1838 `Source already materialized as '${existingName}', skipping clone`, 1839 ) 1840 return { name: existingName, alreadyMaterialized: true, resolvedSource } 1841 } 1842 } 1843 1844 // Load and cache the marketplace to validate it and get its name 1845 const { marketplace, cachePath } = await loadAndCacheMarketplace( 1846 resolvedSource, 1847 onProgress, 1848 ) 1849 1850 // Validate that reserved names come from official sources 1851 const sourceValidationError = validateOfficialNameSource( 1852 marketplace.name, 1853 resolvedSource, 1854 ) 1855 if (sourceValidationError) { 1856 throw new Error(sourceValidationError) 1857 } 1858 1859 // Name collision with different source: overwrite (settings intent wins). 1860 // Seed-managed entries are admin-controlled and cannot be overwritten. 1861 // Re-read config after clone (may take a while; another process may have written). 1862 const config = await loadKnownMarketplacesConfig() 1863 const oldEntry = config[marketplace.name] 1864 if (oldEntry) { 1865 const seedDir = seedDirFor(oldEntry.installLocation) 1866 if (seedDir) { 1867 throw new Error( 1868 `Marketplace '${marketplace.name}' is seed-managed (${seedDir}). ` + 1869 `To use a different source, ask your admin to update the seed, ` + 1870 `or use a different marketplace name.`, 1871 ) 1872 } 1873 logForDebugging( 1874 `Marketplace '${marketplace.name}' exists with different source — overwriting`, 1875 ) 1876 // Clean up the old cache if it's not a user-owned local path AND it 1877 // actually differs from the new cachePath. loadAndCacheMarketplace writes 1878 // to cachePath BEFORE we get here — rm-ing the same dir deletes the fresh 1879 // write. Settings sources always land on the same dir (name → path); 1880 // git sources hit this latently when the source repo changes but the 1881 // fetched marketplace.json declares the same name. Only rm when locations 1882 // genuinely differ (the only case where there's a stale dir to clean). 1883 // 1884 // Defensively validate the stored path before rm: a corrupted 1885 // installLocation (gh-32793, gh-32661) could point at the user's project 1886 // dir. If it's outside the cache dir, skip cleanup — the stale dir (if 1887 // any) is harmless, and blocking the re-add would prevent the user from 1888 // fixing the corruption. 1889 if (!isLocalMarketplaceSource(oldEntry.source)) { 1890 const cacheDir = resolve(getMarketplacesCacheDir()) 1891 const resolvedOld = resolve(oldEntry.installLocation) 1892 const resolvedNew = resolve(cachePath) 1893 if (resolvedOld === resolvedNew) { 1894 // Same dir — loadAndCacheMarketplace already overwrote in place. 1895 // Nothing to clean. 1896 } else if ( 1897 resolvedOld === cacheDir || 1898 resolvedOld.startsWith(cacheDir + sep) 1899 ) { 1900 const fs = getFsImplementation() 1901 await fs.rm(oldEntry.installLocation, { recursive: true, force: true }) 1902 } else { 1903 logForDebugging( 1904 `Skipping cleanup of old installLocation (${oldEntry.installLocation}) — ` + 1905 `outside ${cacheDir}. The path is corrupted; leaving it alone and ` + 1906 `overwriting the config entry.`, 1907 { level: 'warn' }, 1908 ) 1909 } 1910 } 1911 } 1912 1913 // Update config using the marketplace's actual name 1914 config[marketplace.name] = { 1915 source: resolvedSource, 1916 installLocation: cachePath, 1917 lastUpdated: new Date().toISOString(), 1918 } 1919 await saveKnownMarketplacesConfig(config) 1920 1921 logForDebugging(`Added marketplace source: ${marketplace.name}`) 1922 1923 return { name: marketplace.name, alreadyMaterialized: false, resolvedSource } 1924} 1925 1926/** 1927 * Remove a marketplace source from known marketplaces 1928 * 1929 * Removes the marketplace configuration and cleans up cached files. 1930 * Deletes both directory caches (for git sources) and file caches (for URL sources). 1931 * Also cleans up the marketplace from settings.json (extraKnownMarketplaces) and 1932 * removes related plugin entries from enabledPlugins. 1933 * 1934 * @param name - The marketplace name to remove 1935 * @throws If marketplace with given name is not found 1936 */ 1937export async function removeMarketplaceSource(name: string): Promise<void> { 1938 const config = await loadKnownMarketplacesConfig() 1939 1940 if (!config[name]) { 1941 throw new Error(`Marketplace '${name}' not found`) 1942 } 1943 1944 // Seed-registered marketplaces are admin-baked into the container — removing 1945 // them is a category error. They'd resurrect on next startup anyway. Guide 1946 // the user to the right action instead. 1947 const entry = config[name] 1948 const seedDir = seedDirFor(entry.installLocation) 1949 if (seedDir) { 1950 throw new Error( 1951 `Marketplace '${name}' is registered from the read-only seed directory ` + 1952 `(${seedDir}) and will be re-registered on next startup. ` + 1953 `To stop using its plugins: claude plugin disable <plugin>@${name}`, 1954 ) 1955 } 1956 1957 // Remove from config 1958 delete config[name] 1959 await saveKnownMarketplacesConfig(config) 1960 1961 // Clean up cached files (both directory and JSON formats) 1962 const fs = getFsImplementation() 1963 const cacheDir = getMarketplacesCacheDir() 1964 const cachePath = join(cacheDir, name) 1965 await fs.rm(cachePath, { recursive: true, force: true }) 1966 const jsonCachePath = join(cacheDir, `${name}.json`) 1967 await fs.rm(jsonCachePath, { force: true }) 1968 1969 // Clean up settings.json - remove marketplace from extraKnownMarketplaces 1970 // and remove related plugin entries from enabledPlugins 1971 1972 // Check each editable settings source 1973 const editableSources: Array< 1974 'userSettings' | 'projectSettings' | 'localSettings' 1975 > = ['userSettings', 'projectSettings', 'localSettings'] 1976 1977 for (const source of editableSources) { 1978 const settings = getSettingsForSource(source) 1979 if (!settings) continue 1980 1981 let needsUpdate = false 1982 const updates: { 1983 extraKnownMarketplaces?: typeof settings.extraKnownMarketplaces 1984 enabledPlugins?: typeof settings.enabledPlugins 1985 } = {} 1986 1987 // Remove from extraKnownMarketplaces if present 1988 if (settings.extraKnownMarketplaces?.[name]) { 1989 const updatedMarketplaces: Partial< 1990 SettingsJson['extraKnownMarketplaces'] 1991 > = { ...settings.extraKnownMarketplaces } 1992 // Use undefined values (NOT delete) to signal key removal via mergeWith 1993 updatedMarketplaces[name] = undefined 1994 updates.extraKnownMarketplaces = 1995 updatedMarketplaces as SettingsJson['extraKnownMarketplaces'] 1996 needsUpdate = true 1997 } 1998 1999 // Remove related plugins from enabledPlugins (format: "plugin@marketplace") 2000 if (settings.enabledPlugins) { 2001 const marketplaceSuffix = `@${name}` 2002 const updatedPlugins = { ...settings.enabledPlugins } 2003 let removedPlugins = false 2004 2005 for (const pluginId in updatedPlugins) { 2006 if (pluginId.endsWith(marketplaceSuffix)) { 2007 updatedPlugins[pluginId] = undefined 2008 removedPlugins = true 2009 } 2010 } 2011 2012 if (removedPlugins) { 2013 updates.enabledPlugins = updatedPlugins 2014 needsUpdate = true 2015 } 2016 } 2017 2018 // Update settings if changes were made 2019 if (needsUpdate) { 2020 const result = updateSettingsForSource(source, updates) 2021 if (result.error) { 2022 logError(result.error) 2023 logForDebugging( 2024 `Failed to clean up marketplace '${name}' from ${source} settings: ${result.error.message}`, 2025 ) 2026 } else { 2027 logForDebugging( 2028 `Cleaned up marketplace '${name}' from ${source} settings`, 2029 ) 2030 } 2031 } 2032 } 2033 2034 // Remove plugins from installed_plugins.json and mark orphaned paths. 2035 // Also wipe their stored options/secrets — after marketplace removal 2036 // zero installations remain, same "last scope gone" condition as 2037 // uninstallPluginOp. 2038 const { orphanedPaths, removedPluginIds } = 2039 removeAllPluginsForMarketplace(name) 2040 for (const installPath of orphanedPaths) { 2041 await markPluginVersionOrphaned(installPath) 2042 } 2043 for (const pluginId of removedPluginIds) { 2044 deletePluginOptions(pluginId) 2045 await deletePluginDataDir(pluginId) 2046 } 2047 2048 logForDebugging(`Removed marketplace source: ${name}`) 2049} 2050 2051/** 2052 * Read a cached marketplace from disk without updating it 2053 * 2054 * @param installLocation - Path to the cached marketplace 2055 * @returns The marketplace object 2056 * @throws If marketplace file not found or invalid 2057 */ 2058async function readCachedMarketplace( 2059 installLocation: string, 2060): Promise<PluginMarketplace> { 2061 // For git-sourced directories, the manifest lives at .claude-plugin/marketplace.json. 2062 // For url/file/directory sources it is the installLocation itself. 2063 // Try the nested path first; fall back to installLocation when it is a plain file 2064 // (ENOTDIR) or the nested file is simply missing (ENOENT). 2065 const nestedPath = join(installLocation, '.claude-plugin', 'marketplace.json') 2066 try { 2067 return await parseFileWithSchema(nestedPath, PluginMarketplaceSchema()) 2068 } catch (e) { 2069 if (e instanceof ConfigParseError) throw e 2070 const code = getErrnoCode(e) 2071 if (code !== 'ENOENT' && code !== 'ENOTDIR') throw e 2072 } 2073 return await parseFileWithSchema(installLocation, PluginMarketplaceSchema()) 2074} 2075 2076/** 2077 * Get a specific marketplace by name from cache only (no network). 2078 * Returns null if cache is missing or corrupted. 2079 * Use this for startup paths that should never block on network. 2080 */ 2081export async function getMarketplaceCacheOnly( 2082 name: string, 2083): Promise<PluginMarketplace | null> { 2084 const fs = getFsImplementation() 2085 const configFile = getKnownMarketplacesFile() 2086 2087 try { 2088 const content = await fs.readFile(configFile, { encoding: 'utf-8' }) 2089 const config = jsonParse(content) as KnownMarketplacesConfig 2090 const entry = config[name] 2091 2092 if (!entry) { 2093 return null 2094 } 2095 2096 return await readCachedMarketplace(entry.installLocation) 2097 } catch (error) { 2098 if (isENOENT(error)) { 2099 return null 2100 } 2101 logForDebugging( 2102 `Failed to read cached marketplace ${name}: ${errorMessage(error)}`, 2103 { level: 'warn' }, 2104 ) 2105 return null 2106 } 2107} 2108 2109/** 2110 * Get a specific marketplace by name 2111 * 2112 * First attempts to read from cache. Only fetches from source if: 2113 * - No cached version exists 2114 * - Cache is invalid/corrupted 2115 * 2116 * This avoids unnecessary network/git operations on every access. 2117 * Use refreshMarketplace() to explicitly update from source. 2118 * 2119 * @param name - The marketplace name to fetch 2120 * @returns The marketplace object or null if not found/failed 2121 */ 2122export const getMarketplace = memoize( 2123 async (name: string): Promise<PluginMarketplace> => { 2124 const config = await loadKnownMarketplacesConfig() 2125 const entry = config[name] 2126 2127 if (!entry) { 2128 throw new Error( 2129 `Marketplace '${name}' not found in configuration. Available marketplaces: ${Object.keys(config).join(', ')}`, 2130 ) 2131 } 2132 2133 // Legacy entries (pre-#19708) may have relative paths in global config. 2134 // These are meaningless outside the project that wrote them — resolving 2135 // against process.cwd() produces the wrong path. Give actionable guidance 2136 // instead of a misleading ENOENT. 2137 if ( 2138 isLocalMarketplaceSource(entry.source) && 2139 !isAbsolute(entry.source.path) 2140 ) { 2141 throw new Error( 2142 `Marketplace "${name}" has a relative source path (${entry.source.path}) ` + 2143 `in known_marketplaces.json — this is stale state from an older ` + 2144 `Claude Code version. Run 'claude marketplace remove ${name}' and ` + 2145 `re-add it from the original project directory.`, 2146 ) 2147 } 2148 2149 // Try to read from disk cache 2150 try { 2151 return await readCachedMarketplace(entry.installLocation) 2152 } catch (error) { 2153 // Log cache corruption before re-fetching 2154 logForDebugging( 2155 `Cache corrupted or missing for marketplace ${name}, re-fetching from source: ${errorMessage(error)}`, 2156 { 2157 level: 'warn', 2158 }, 2159 ) 2160 } 2161 2162 // Cache doesn't exist or is invalid, fetch from source 2163 let marketplace: PluginMarketplace 2164 try { 2165 ;({ marketplace } = await loadAndCacheMarketplace(entry.source)) 2166 } catch (error) { 2167 throw new Error( 2168 `Failed to load marketplace "${name}" from source (${entry.source.source}): ${errorMessage(error)}`, 2169 ) 2170 } 2171 2172 // Update lastUpdated only when we actually fetch 2173 config[name]!.lastUpdated = new Date().toISOString() 2174 await saveKnownMarketplacesConfig(config) 2175 2176 return marketplace 2177 }, 2178) 2179 2180/** 2181 * Get plugin by ID from cache only (no network calls). 2182 * Returns null if marketplace cache is missing or corrupted. 2183 * Use this for startup paths that should never block on network. 2184 * 2185 * @param pluginId - The plugin ID in format "name@marketplace" 2186 * @returns The plugin entry or null if not found/cache missing 2187 */ 2188export async function getPluginByIdCacheOnly(pluginId: string): Promise<{ 2189 entry: PluginMarketplaceEntry 2190 marketplaceInstallLocation: string 2191} | null> { 2192 const { name: pluginName, marketplace: marketplaceName } = 2193 parsePluginIdentifier(pluginId) 2194 if (!pluginName || !marketplaceName) { 2195 return null 2196 } 2197 2198 const fs = getFsImplementation() 2199 const configFile = getKnownMarketplacesFile() 2200 2201 try { 2202 const content = await fs.readFile(configFile, { encoding: 'utf-8' }) 2203 const config = jsonParse(content) as KnownMarketplacesConfig 2204 const marketplaceConfig = config[marketplaceName] 2205 2206 if (!marketplaceConfig) { 2207 return null 2208 } 2209 2210 const marketplace = await getMarketplaceCacheOnly(marketplaceName) 2211 if (!marketplace) { 2212 return null 2213 } 2214 2215 const plugin = marketplace.plugins.find(p => p.name === pluginName) 2216 if (!plugin) { 2217 return null 2218 } 2219 2220 return { 2221 entry: plugin, 2222 marketplaceInstallLocation: marketplaceConfig.installLocation, 2223 } 2224 } catch { 2225 return null 2226 } 2227} 2228 2229/** 2230 * Get plugin by ID from a specific marketplace 2231 * 2232 * First tries cache-only lookup. If cache is missing/corrupted, 2233 * falls back to fetching from source. 2234 * 2235 * @param pluginId - The plugin ID in format "name@marketplace" 2236 * @returns The plugin entry or null if not found 2237 */ 2238export async function getPluginById(pluginId: string): Promise<{ 2239 entry: PluginMarketplaceEntry 2240 marketplaceInstallLocation: string 2241} | null> { 2242 // Try cache-only first (fast path) 2243 const cached = await getPluginByIdCacheOnly(pluginId) 2244 if (cached) { 2245 return cached 2246 } 2247 2248 // Cache miss - try fetching from source 2249 const { name: pluginName, marketplace: marketplaceName } = 2250 parsePluginIdentifier(pluginId) 2251 if (!pluginName || !marketplaceName) { 2252 return null 2253 } 2254 2255 try { 2256 const config = await loadKnownMarketplacesConfig() 2257 const marketplaceConfig = config[marketplaceName] 2258 if (!marketplaceConfig) { 2259 return null 2260 } 2261 2262 const marketplace = await getMarketplace(marketplaceName) 2263 const plugin = marketplace.plugins.find(p => p.name === pluginName) 2264 2265 if (!plugin) { 2266 return null 2267 } 2268 2269 return { 2270 entry: plugin, 2271 marketplaceInstallLocation: marketplaceConfig.installLocation, 2272 } 2273 } catch (error) { 2274 logForDebugging( 2275 `Could not find plugin ${pluginId}: ${errorMessage(error)}`, 2276 { level: 'debug' }, 2277 ) 2278 return null 2279 } 2280} 2281 2282/** 2283 * Refresh all marketplace caches 2284 * 2285 * Updates all configured marketplaces from their sources. 2286 * Continues refreshing even if some marketplaces fail. 2287 * Updates lastUpdated timestamps for successful refreshes. 2288 * 2289 * This is useful for: 2290 * - Periodic updates to get new plugins 2291 * - Syncing after network connectivity is restored 2292 * - Ensuring caches are up-to-date before browsing 2293 * 2294 * @returns Promise that resolves when all refresh attempts complete 2295 */ 2296export async function refreshAllMarketplaces(): Promise<void> { 2297 const config = await loadKnownMarketplacesConfig() 2298 2299 for (const [name, entry] of Object.entries(config)) { 2300 // Seed-managed marketplaces are controlled by the seed image — refreshing 2301 // them is pointless (registerSeedMarketplaces overwrites on next startup). 2302 if (seedDirFor(entry.installLocation)) { 2303 logForDebugging( 2304 `Skipping seed-managed marketplace '${name}' in bulk refresh`, 2305 ) 2306 continue 2307 } 2308 // settings-sourced marketplaces have no upstream — see refreshMarketplace. 2309 if (entry.source.source === 'settings') { 2310 continue 2311 } 2312 // inc-5046: same GCS intercept as refreshMarketplace() — bulk update 2313 // hits this path on `claude plugin marketplace update` (no name arg). 2314 if (name === OFFICIAL_MARKETPLACE_NAME) { 2315 const sha = await fetchOfficialMarketplaceFromGcs( 2316 entry.installLocation, 2317 getMarketplacesCacheDir(), 2318 ) 2319 if (sha !== null) { 2320 config[name]!.lastUpdated = new Date().toISOString() 2321 continue 2322 } 2323 if ( 2324 !getFeatureValue_CACHED_MAY_BE_STALE( 2325 'tengu_plugin_official_mkt_git_fallback', 2326 true, 2327 ) 2328 ) { 2329 logForDebugging( 2330 `Skipping official marketplace bulk refresh: GCS failed, git fallback disabled`, 2331 ) 2332 continue 2333 } 2334 // fall through to git 2335 } 2336 try { 2337 const { cachePath } = await loadAndCacheMarketplace(entry.source) 2338 config[name]!.lastUpdated = new Date().toISOString() 2339 config[name]!.installLocation = cachePath 2340 } catch (error) { 2341 logForDebugging( 2342 `Failed to refresh marketplace ${name}: ${errorMessage(error)}`, 2343 { 2344 level: 'error', 2345 }, 2346 ) 2347 } 2348 } 2349 2350 await saveKnownMarketplacesConfig(config) 2351} 2352 2353/** 2354 * Refresh a single marketplace cache 2355 * 2356 * Updates a specific marketplace from its source by doing an in-place update. 2357 * For git sources, runs git pull in the existing directory. 2358 * For URL sources, re-downloads to the existing file. 2359 * Clears the memoization cache and updates the lastUpdated timestamp. 2360 * 2361 * @param name - The name of the marketplace to refresh 2362 * @param onProgress - Optional callback to report progress 2363 * @throws If marketplace not found or refresh fails 2364 */ 2365export async function refreshMarketplace( 2366 name: string, 2367 onProgress?: MarketplaceProgressCallback, 2368 options?: { disableCredentialHelper?: boolean }, 2369): Promise<void> { 2370 const config = await loadKnownMarketplacesConfig() 2371 const entry = config[name] 2372 2373 if (!entry) { 2374 throw new Error( 2375 `Marketplace '${name}' not found. Available marketplaces: ${Object.keys(config).join(', ')}`, 2376 ) 2377 } 2378 2379 // Clear the memoization cache for this specific marketplace 2380 getMarketplace.cache?.delete?.(name) 2381 2382 // settings-sourced marketplaces have no upstream to pull. Edits to the 2383 // inline plugins array surface as sourceChanged in the reconciler, which 2384 // re-materializes via addMarketplaceSource — refresh is not the vehicle. 2385 if (entry.source.source === 'settings') { 2386 logForDebugging( 2387 `Skipping refresh for settings-sourced marketplace '${name}' — no upstream`, 2388 ) 2389 return 2390 } 2391 2392 try { 2393 // For updates, use the existing installLocation directly (in-place update) 2394 const installLocation = entry.installLocation 2395 const source = entry.source 2396 2397 // Seed-managed marketplaces are controlled by the seed image. Refreshing 2398 // would be pointless — registerSeedMarketplaces() overwrites installLocation 2399 // back to seed on next startup. Error with guidance instead. 2400 const seedDir = seedDirFor(installLocation) 2401 if (seedDir) { 2402 throw new Error( 2403 `Marketplace '${name}' is seed-managed (${seedDir}) and its content is ` + 2404 `controlled by the seed image. To update: ask your admin to update the seed.`, 2405 ) 2406 } 2407 2408 // For remote sources (github/git/url), installLocation must be inside the 2409 // marketplaces cache dir. A corrupted value (gh-32793, gh-32661 — e.g. 2410 // Windows path read on WSL, literal tilde, manual edit) can point at the 2411 // user's project. cacheMarketplaceFromGit would then run git ops with that 2412 // cwd (git walks up to the user's .git) and fs.rm it on pull failure. 2413 // Refuse instead of auto-fixing so the user knows their state is corrupted. 2414 if (!isLocalMarketplaceSource(source)) { 2415 const cacheDir = resolve(getMarketplacesCacheDir()) 2416 const resolvedLoc = resolve(installLocation) 2417 if (resolvedLoc !== cacheDir && !resolvedLoc.startsWith(cacheDir + sep)) { 2418 throw new Error( 2419 `Marketplace '${name}' has a corrupted installLocation ` + 2420 `(${installLocation}) — expected a path inside ${cacheDir}. ` + 2421 `This can happen after cross-platform path writes or manual edits ` + 2422 `to known_marketplaces.json. ` + 2423 `Run: claude plugin marketplace remove "${name}" and re-add it.`, 2424 ) 2425 } 2426 } 2427 2428 // inc-5046: official marketplace fetches from a GCS mirror instead of 2429 // git-cloning GitHub. Special-cased by NAME (not a new source type) so 2430 // no data migration is needed — existing known_marketplaces.json entries 2431 // still say source:'github', which is true (GCS is a mirror). 2432 if (name === OFFICIAL_MARKETPLACE_NAME) { 2433 const sha = await fetchOfficialMarketplaceFromGcs( 2434 installLocation, 2435 getMarketplacesCacheDir(), 2436 ) 2437 if (sha !== null) { 2438 config[name] = { ...entry, lastUpdated: new Date().toISOString() } 2439 await saveKnownMarketplacesConfig(config) 2440 return 2441 } 2442 // GCS failed — fall through to git ONLY if the kill-switch allows. 2443 // Default true (backend write perms are pending as of inc-5046); flip 2444 // to false via GrowthBook once the backend is confirmed live so new 2445 // clients NEVER hit GitHub for the official marketplace. 2446 if ( 2447 !getFeatureValue_CACHED_MAY_BE_STALE( 2448 'tengu_plugin_official_mkt_git_fallback', 2449 true, 2450 ) 2451 ) { 2452 // Throw, don't return — every other failure path in this function 2453 // throws, and callers like ManageMarketplaces.tsx:259 increment 2454 // updatedCount on any non-throwing return. A silent return would 2455 // report "Updated 1 marketplace" when nothing was refreshed. 2456 throw new Error( 2457 'Official marketplace GCS fetch failed and git fallback is disabled', 2458 ) 2459 } 2460 logForDebugging('Official marketplace GCS failed; falling back to git', { 2461 level: 'warn', 2462 }) 2463 // ...falls through to source.source === 'github' branch below 2464 } 2465 2466 // Update based on source type 2467 if (source.source === 'github' || source.source === 'git') { 2468 // Git sources: do in-place git pull 2469 if (source.source === 'github') { 2470 // Same SSH/HTTPS fallback as loadAndCacheMarketplace: if the pull 2471 // succeeds the remote URL in .git/config is used, but a re-clone 2472 // needs a URL — pick the right protocol up-front and fall back. 2473 const sshUrl = `git@github.com:${source.repo}.git` 2474 const httpsUrl = `https://github.com/${source.repo}.git` 2475 2476 if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { 2477 // CCR: always HTTPS (no SSH keys available) 2478 await cacheMarketplaceFromGit( 2479 httpsUrl, 2480 installLocation, 2481 source.ref, 2482 source.sparsePaths, 2483 onProgress, 2484 options, 2485 ) 2486 } else { 2487 const sshConfigured = await isGitHubSshLikelyConfigured() 2488 const primaryUrl = sshConfigured ? sshUrl : httpsUrl 2489 const fallbackUrl = sshConfigured ? httpsUrl : sshUrl 2490 2491 try { 2492 await cacheMarketplaceFromGit( 2493 primaryUrl, 2494 installLocation, 2495 source.ref, 2496 source.sparsePaths, 2497 onProgress, 2498 options, 2499 ) 2500 } catch { 2501 logForDebugging( 2502 `Marketplace refresh failed with ${sshConfigured ? 'SSH' : 'HTTPS'} for ${source.repo}, falling back to ${sshConfigured ? 'HTTPS' : 'SSH'}`, 2503 { level: 'info' }, 2504 ) 2505 await cacheMarketplaceFromGit( 2506 fallbackUrl, 2507 installLocation, 2508 source.ref, 2509 source.sparsePaths, 2510 onProgress, 2511 options, 2512 ) 2513 } 2514 } 2515 } else { 2516 // Explicit git URL: use as-is (no fallback available) 2517 await cacheMarketplaceFromGit( 2518 source.url, 2519 installLocation, 2520 source.ref, 2521 source.sparsePaths, 2522 onProgress, 2523 options, 2524 ) 2525 } 2526 // Validate that marketplace.json still exists after update 2527 // The repo may have been restructured or deprecated 2528 try { 2529 await readCachedMarketplace(installLocation) 2530 } catch { 2531 const sourceDisplay = 2532 source.source === 'github' 2533 ? source.repo 2534 : redactUrlCredentials(source.url) 2535 const reason = 2536 name === 'claude-code-plugins' 2537 ? `We've deprecated "claude-code-plugins" in favor of "claude-plugins-official".` 2538 : `This marketplace may have been deprecated or moved to a new location.` 2539 throw new Error( 2540 `The marketplace.json file is no longer present in this repository.\n\n` + 2541 `${reason}\n` + 2542 `Source: ${sourceDisplay}\n\n` + 2543 `You can remove this marketplace with: claude plugin marketplace remove "${name}"`, 2544 ) 2545 } 2546 } else if (source.source === 'url') { 2547 // URL sources: re-download to existing file 2548 await cacheMarketplaceFromUrl( 2549 source.url, 2550 installLocation, 2551 source.headers, 2552 onProgress, 2553 ) 2554 } else if (isLocalMarketplaceSource(source)) { 2555 // Local sources: no remote to update from, but validate the file still exists and is valid 2556 safeCallProgress(onProgress, 'Validating local marketplace') 2557 // Read and validate to ensure the marketplace file is still valid 2558 await readCachedMarketplace(installLocation) 2559 } else { 2560 throw new Error(`Unsupported marketplace source type for refresh`) 2561 } 2562 2563 // Update lastUpdated timestamp 2564 config[name]!.lastUpdated = new Date().toISOString() 2565 await saveKnownMarketplacesConfig(config) 2566 2567 logForDebugging(`Successfully refreshed marketplace: ${name}`) 2568 } catch (error) { 2569 const errorMessage = error instanceof Error ? error.message : String(error) 2570 logForDebugging(`Failed to refresh marketplace ${name}: ${errorMessage}`, { 2571 level: 'error', 2572 }) 2573 throw new Error(`Failed to refresh marketplace '${name}': ${errorMessage}`) 2574 } 2575} 2576 2577/** 2578 * Set the autoUpdate flag for a marketplace 2579 * 2580 * When autoUpdate is enabled, the marketplace and its installed plugins 2581 * will be automatically updated on startup. 2582 * 2583 * @param name - The name of the marketplace to update 2584 * @param autoUpdate - Whether to enable auto-update 2585 * @throws If marketplace not found 2586 */ 2587export async function setMarketplaceAutoUpdate( 2588 name: string, 2589 autoUpdate: boolean, 2590): Promise<void> { 2591 const config = await loadKnownMarketplacesConfig() 2592 const entry = config[name] 2593 2594 if (!entry) { 2595 throw new Error( 2596 `Marketplace '${name}' not found. Available marketplaces: ${Object.keys(config).join(', ')}`, 2597 ) 2598 } 2599 2600 // Seed-managed marketplaces always have autoUpdate: false (read-only, git-pull 2601 // would fail). Toggle appears to work but registerSeedMarketplaces overwrites 2602 // it on next startup. Error with guidance instead of silent revert. 2603 const seedDir = seedDirFor(entry.installLocation) 2604 if (seedDir) { 2605 throw new Error( 2606 `Marketplace '${name}' is seed-managed (${seedDir}) and ` + 2607 `auto-update is always disabled for seed content. ` + 2608 `To update: ask your admin to update the seed.`, 2609 ) 2610 } 2611 2612 // Only update if the value is actually changing 2613 if (entry.autoUpdate === autoUpdate) { 2614 return 2615 } 2616 2617 config[name] = { 2618 ...entry, 2619 autoUpdate, 2620 } 2621 await saveKnownMarketplacesConfig(config) 2622 2623 // Also update intent in settings if declared there — write to the SAME 2624 // source that declared it to avoid creating duplicates at wrong scope 2625 const declaringSource = getMarketplaceDeclaringSource(name) 2626 if (declaringSource) { 2627 const declared = 2628 getSettingsForSource(declaringSource)?.extraKnownMarketplaces?.[name] 2629 if (declared) { 2630 saveMarketplaceToSettings( 2631 name, 2632 { source: declared.source, autoUpdate }, 2633 declaringSource, 2634 ) 2635 } 2636 } 2637 2638 logForDebugging(`Set autoUpdate=${autoUpdate} for marketplace: ${name}`) 2639} 2640 2641export const _test = { 2642 redactUrlCredentials, 2643}