const fs = require('../fs-native'); const os = require('node:os'); const path = require('node:path'); const { execSync } = require('node:child_process'); const prompts = require('../prompts'); const { RegistryClient } = require('./registry-client'); const { decideChannelForModule } = require('./channel-plan'); const { parseGitHubRepo, tagExists } = require('./channel-resolver'); const MARKETPLACE_OWNER = 'bmad-code-org'; const MARKETPLACE_REPO = 'bmad-plugins-marketplace'; const MARKETPLACE_REF = 'main'; /** * Manages community modules from the BMad marketplace registry. * Fetches community-index.yaml and categories.yaml from GitHub. * Returns empty results when the registry is unreachable. * Community modules are pinned to approved SHA when set; uses HEAD otherwise. */ function quoteShellRef(ref) { if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) { throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`); } return `"${ref}"`; } class CommunityModuleManager { // moduleCode → { channel, version, sha, registryApprovedTag, registryApprovedSha, repoUrl, bypassedCurator } // Shared across all instances; the manifest writer often uses a fresh instance. static _resolutions = new Map(); // moduleCode → ResolvedModule (from PluginResolver) when the cloned repo ships // a `.claude-plugin/marketplace.json`. Lets community installs reuse the same // skill-level install pipeline as custom-source installs (installFromResolution). static _pluginResolutions = new Map(); constructor() { this._client = new RegistryClient(); this._cachedIndex = null; this._cachedCategories = null; } /** Get the most recent channel resolution for a community module. */ getResolution(moduleCode) { return CommunityModuleManager._resolutions.get(moduleCode) || null; } /** Get the marketplace.json-derived plugin resolution for a community module, if any. */ getPluginResolution(moduleCode) { return CommunityModuleManager._pluginResolutions.get(moduleCode) || null; } // ─── Data Loading ────────────────────────────────────────────────────────── /** * Load the community module index from the marketplace repo. * Returns empty when the registry is unreachable. * @returns {Object} Parsed YAML with modules array */ async loadCommunityIndex() { if (this._cachedIndex) return this._cachedIndex; try { const config = await this._client.fetchGitHubYaml( MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/community-index.yaml', MARKETPLACE_REF, ); if (config?.modules?.length) { this._cachedIndex = config; return config; } } catch { // Registry unreachable - no community modules available } return { modules: [] }; } /** * Load categories from the marketplace repo. * Returns empty when the registry is unreachable. * @returns {Object} Parsed categories.yaml content */ async loadCategories() { if (this._cachedCategories) return this._cachedCategories; try { const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF); if (config?.categories) { this._cachedCategories = config; return config; } } catch { // Registry unreachable - no categories available } return { categories: {} }; } // ─── Listing & Filtering ────────────────────────────────────────────────── /** * Get all community modules, normalized. * @returns {Array} Normalized community modules */ async listAll() { const index = await this.loadCommunityIndex(); return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod)); } /** * Get community modules filtered to a category. * @param {string} categorySlug - Category slug (e.g., 'design-and-creative') * @returns {Array} Filtered modules */ async listByCategory(categorySlug) { const all = await this.listAll(); return all.filter((mod) => mod.category === categorySlug); } /** * Get promoted/featured community modules, sorted by rank. * @returns {Array} Featured modules */ async listFeatured() { const all = await this.listAll(); return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999)); } /** * Search community modules by keyword. * Matches against name, display name, description, and keywords array. * @param {string} query - Search query * @returns {Array} Matching modules */ async searchByKeyword(query) { const all = await this.listAll(); const q = query.toLowerCase(); return all.filter((mod) => { const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase(); return searchable.includes(q); }); } /** * Get categories with module counts for UI display. * Only returns categories that have at least one community module. * @returns {Array} Array of { slug, name, moduleCount } */ async getCategoryList() { const all = await this.listAll(); const categoriesData = await this.loadCategories(); const categories = categoriesData.categories || {}; // Count modules per category const counts = {}; for (const mod of all) { counts[mod.category] = (counts[mod.category] || 0) + 1; } // Build list with display names from categories.yaml const result = []; for (const [slug, count] of Object.entries(counts)) { const catInfo = categories[slug]; result.push({ slug, name: catInfo?.name || slug, moduleCount: count, }); } // Sort alphabetically by name result.sort((a, b) => a.name.localeCompare(b.name)); return result; } // ─── Module Lookup ──────────────────────────────────────────────────────── /** * Get a community module by its code. * @param {string} code - Module code (e.g., 'wds') * @returns {Object|null} Normalized module or null */ async getModuleByCode(code) { const all = await this.listAll(); return all.find((m) => m.code === code) || null; } // ─── Clone with Tag Pinning ─────────────────────────────────────────────── /** * Get the cache directory for community modules. * @returns {string} Path to the community modules cache directory */ getCacheDir() { return path.join(os.homedir(), '.bmad', 'cache', 'community-modules'); } /** * Clone a community module repository, pinned to its approved tag. * @param {string} moduleCode - Module code * @param {Object} [options] - Clone options * @param {boolean} [options.silent] - Suppress spinner output * @returns {string} Path to the cloned repository */ async cloneModule(moduleCode, options = {}) { const moduleInfo = await this.getModuleByCode(moduleCode); if (!moduleInfo) { throw new Error(`Community module '${moduleCode}' not found in the registry`); } const cacheDir = this.getCacheDir(); const moduleCacheDir = path.join(cacheDir, moduleCode); const silent = options.silent || false; await fs.ensureDir(cacheDir); const createSpinner = async () => { if (silent) { return { start() {}, stop() {}, error() {}, message() {} }; } return await prompts.spinner(); }; // ─── Resolve channel plan ────────────────────────────────────────────── // Default community behavior (stable channel) honors the curator's // approved SHA. --next=CODE and --pin CODE=TAG override the curator; we // warn the user before bypassing the approved version. const planEntry = decideChannelForModule({ code: moduleCode, channelOptions: options.channelOptions, registryDefault: 'stable', }); const approvedSha = moduleInfo.approvedSha; const approvedTag = moduleInfo.approvedTag; let bypassedCurator = false; if (planEntry.channel !== 'stable') { bypassedCurator = true; if (!silent) { const approvedLabel = approvedTag || approvedSha || 'curator-approved version'; await prompts.log.warn( `WARNING: Installing '${moduleCode}' from ${ planEntry.channel === 'pinned' ? `tag ${planEntry.pin}` : 'main HEAD' } bypasses the curator-approved ${approvedLabel}. Proceed only if you trust this source.`, ); if (!options.channelOptions?.acceptBypass) { const proceed = await prompts.confirm({ message: `Continue installing '${moduleCode}' with curator bypass?`, default: false, }); if (!proceed) { throw new Error(`Install of community module '${moduleCode}' cancelled by user.`); } } } } let needsDependencyInstall = false; let wasNewClone = false; if (await fs.pathExists(moduleCacheDir)) { // Already cloned — refresh to the correct ref for the resolved channel. // A pinned install must not reset to origin/HEAD (it would silently drift // to main on every re-install). Stable + approvedSha is handled below // by the curator-SHA checkout logic. const fetchSpinner = await createSpinner(); fetchSpinner.start(`Checking ${moduleInfo.displayName}...`); try { const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); execSync('git fetch origin --depth 1', { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); if (planEntry.channel === 'pinned') { // Fetch the pin tag specifically and check it out. execSync(`git fetch --depth 1 origin ${quoteShellRef(planEntry.pin)} --no-tags`, { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); execSync('git checkout --quiet FETCH_HEAD', { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], }); } else { // stable (approvedSha path re-checks out below) and next: track main. execSync('git reset --hard origin/HEAD', { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], }); } const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); if (currentRef !== newRef) needsDependencyInstall = true; fetchSpinner.stop(`Verified ${moduleInfo.displayName}`); } catch { fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`); await fs.remove(moduleCacheDir); wasNewClone = true; } } else { wasNewClone = true; } if (wasNewClone) { const fetchSpinner = await createSpinner(); fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`); try { if (planEntry.channel === 'pinned') { execSync(`git clone --depth 1 --branch ${quoteShellRef(planEntry.pin)} "${moduleInfo.url}" "${moduleCacheDir}"`, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); } else { execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); } fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`); needsDependencyInstall = true; } catch (error) { fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`); throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`); } } // ─── Check out the resolved ref per channel ────────────────────────── if (planEntry.channel === 'stable' && approvedSha) { // Default path: pin to the curator-approved SHA. Refuse install if the SHA // is unreachable (tag may have been deleted or rewritten) — security requirement. const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); if (headSha !== approvedSha) { try { execSync(`git fetch --depth 1 origin ${quoteShellRef(approvedSha)}`, { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); execSync(`git checkout ${quoteShellRef(approvedSha)}`, { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], }); needsDependencyInstall = true; } catch { await fs.remove(moduleCacheDir); throw new Error( `Community module '${moduleCode}' could not be pinned to its approved commit (${approvedSha}). ` + `Installation refused for security. The module registry entry may need updating, ` + `or use --next=${moduleCode} / --pin ${moduleCode}= to explicitly bypass.`, ); } } } else if (planEntry.channel === 'stable' && !approvedSha) { // Registry data gap: tag or SHA missing. Warn but proceed at HEAD (pre-existing behavior). if (!silent) { await prompts.log.warn(`Community module '${moduleCode}' has no curator-approved SHA in the registry; installing from main HEAD.`); } } else if (planEntry.channel === 'pinned') { // We cloned the tag directly above (via --branch), but ensure HEAD matches. // No additional checkout needed. } // else: 'next' channel — already at origin/HEAD from the fetch/reset above. // Record the resolution so the manifest writer can pick up channel/version/sha. const installedSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); const recordedVersion = planEntry.channel === 'pinned' ? planEntry.pin : planEntry.channel === 'next' ? 'main' : approvedTag || installedSha.slice(0, 7); CommunityModuleManager._resolutions.set(moduleCode, { channel: planEntry.channel, version: recordedVersion, sha: installedSha, registryApprovedTag: approvedTag || null, registryApprovedSha: approvedSha || null, repoUrl: moduleInfo.url, bypassedCurator, planSource: planEntry.source, }); // If the repo ships a marketplace.json, route through PluginResolver so the // skill-level install pipeline (installFromResolution) handles the copy. // Repos without marketplace.json fall through to the legacy findModuleSource // path unchanged. await this._tryResolveMarketplacePlugin(moduleCacheDir, moduleInfo, { channel: planEntry.channel, version: recordedVersion, sha: installedSha, approvedTag, approvedSha, }); // Install dependencies if needed const packageJsonPath = path.join(moduleCacheDir, 'package.json'); if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) { const installSpinner = await createSpinner(); installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`); try { execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, }); installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`); } catch (error) { installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`); if (!silent) await prompts.log.warn(` ${error.message}`); } } return moduleCacheDir; } // ─── Marketplace.json Resolution ────────────────────────────────────────── /** * Detect `.claude-plugin/marketplace.json` in a cloned community repo and * route through PluginResolver. When successful, caches the resolution so * OfficialModulesManager.install() can route the copy through * installFromResolution() — the same path used by custom-source installs. * * Silent no-op when marketplace.json is absent or the resolver returns no * matches; the legacy findModuleSource path then handles the install. * * @param {string} repoPath - Absolute path to the cloned repo * @param {Object} moduleInfo - Normalized community module info * @param {Object} resolution - Resolution metadata from cloneModule * @param {string} resolution.channel - Channel ('stable' | 'next' | 'pinned') * @param {string} resolution.version - Recorded version string * @param {string} resolution.sha - Resolved git SHA * @param {string|null} resolution.approvedTag - Registry approved tag * @param {string|null} resolution.approvedSha - Registry approved SHA */ async _tryResolveMarketplacePlugin(repoPath, moduleInfo, resolution) { const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json'); if (!(await fs.pathExists(marketplacePath))) return; let marketplaceData; try { marketplaceData = JSON.parse(await fs.readFile(marketplacePath, 'utf8')); } catch { // Malformed marketplace.json — fall through to legacy path. return; } const plugins = Array.isArray(marketplaceData?.plugins) ? marketplaceData.plugins : []; if (plugins.length === 0) return; const plugin = this._selectPluginForModule(plugins, moduleInfo); if (!plugin) { await prompts.log .warn?.( `Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` + `Falling back to legacy install path.`, ) .catch(() => {}); return; } const { PluginResolver } = require('./plugin-resolver'); const resolver = new PluginResolver(); const resolved = await resolver.resolve(repoPath, plugin); if (!resolved || resolved.length === 0) return; // The registry registers a single code per module. If the resolver returns // multiple modules (Strategy 4: multiple standalone skills), accept only // the entry whose code matches the registry. Other entries are ignored — // they belong to plugins not registered in the community catalog. const matched = resolved.find((mod) => mod.code === moduleInfo.code) || (resolved.length === 1 ? resolved[0] : null); if (!matched) return; // Stamp registry/clone provenance so installFromResolution and downstream // manifest writers see the same channel/sha/tag as the legacy path. matched.code = moduleInfo.code; matched.repoUrl = moduleInfo.url; matched.cloneRef = resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null; matched.cloneSha = resolution.sha; matched.communitySource = true; matched.communityChannel = resolution.channel; matched.communityVersion = resolution.version; matched.registryApprovedTag = resolution.approvedTag; matched.registryApprovedSha = resolution.approvedSha; CommunityModuleManager._pluginResolutions.set(moduleInfo.code, matched); } /** * Pick which plugin entry from marketplace.json represents this community module. * Precedence: * 1. Exact match on `plugin.name === moduleInfo.code` * 2. Trailing directory of `module_definition` matches `plugin.name` * 3. Single plugin in marketplace.json — use it * Otherwise null (caller falls back to legacy path). */ _selectPluginForModule(plugins, moduleInfo) { const byCode = plugins.find((p) => p && p.name === moduleInfo.code); if (byCode) return byCode; if (moduleInfo.moduleDefinition) { // module_definition like "src/skills/suno-setup/assets/module.yaml" → // hint segment "suno-setup". Match that against plugin names. const segments = moduleInfo.moduleDefinition.split('/').filter(Boolean); const setupIdx = segments.findIndex((s) => s.endsWith('-setup')); if (setupIdx !== -1) { const hint = segments[setupIdx]; const byHint = plugins.find((p) => p && p.name === hint); if (byHint) return byHint; } } if (plugins.length === 1) return plugins[0]; return null; } // ─── Source Finding ─────────────────────────────────────────────────────── /** * Find the source path for a community module (clone + locate module.yaml). * @param {string} moduleCode - Module code * @param {Object} [options] - Options passed to cloneModule * @returns {string|null} Path to the module source or null */ async findModuleSource(moduleCode, options = {}) { const moduleInfo = await this.getModuleByCode(moduleCode); if (!moduleInfo) return null; const cloneDir = await this.cloneModule(moduleCode, options); // Check configured module_definition path first if (moduleInfo.moduleDefinition) { const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition); if (await fs.pathExists(configuredPath)) { return path.dirname(configuredPath); } } // Fallback: search skills/ and src/ directories for (const dir of ['skills', 'src']) { const rootCandidate = path.join(cloneDir, dir, 'module.yaml'); if (await fs.pathExists(rootCandidate)) { return path.dirname(rootCandidate); } const dirPath = path.join(cloneDir, dir); if (await fs.pathExists(dirPath)) { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const subCandidate = path.join(dirPath, entry.name, 'module.yaml'); if (await fs.pathExists(subCandidate)) { return path.dirname(subCandidate); } } } } } // Check repo root const rootCandidate = path.join(cloneDir, 'module.yaml'); if (await fs.pathExists(rootCandidate)) { return path.dirname(rootCandidate); } return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null; } // ─── Normalization ──────────────────────────────────────────────────────── /** * Normalize a community module entry to a consistent shape. * @param {Object} mod - Raw module from community-index.yaml * @returns {Object} Normalized module info */ _normalizeCommunityModule(mod) { return { key: mod.name, code: mod.code, name: mod.display_name || mod.name, displayName: mod.display_name || mod.name, description: mod.description || '', url: mod.repository || mod.url, moduleDefinition: mod.module_definition || mod['module-definition'], npmPackage: mod.npm_package || mod.npmPackage || null, author: mod.author || '', license: mod.license || '', type: 'community', category: mod.category || '', subcategory: mod.subcategory || '', keywords: mod.keywords || [], version: mod.version || null, approvedTag: mod.approved_tag || null, approvedSha: mod.approved_sha || null, approvedDate: mod.approved_date || null, reviewer: mod.reviewer || null, trustTier: mod.trust_tier || 'unverified', promoted: mod.promoted === true, promotedRank: mod.promoted_rank || null, defaultSelected: false, builtIn: false, isExternal: true, }; } } module.exports = { CommunityModuleManager };