diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 82094165a..45c3ea19c 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -1723,6 +1723,258 @@ async function runTests() { console.log(''); + // ============================================================ + // Test Suite 33: Community & Custom Module Managers + // ============================================================ + console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`); + + // --- CustomModuleManager.validateGitHubUrl --- + { + const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager'); + const mgr = new CustomModuleManager(); + + const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo'); + assert(https1.isValid === true, 'validateGitHubUrl accepts HTTPS URL'); + assert(https1.owner === 'owner' && https1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from HTTPS'); + + const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git'); + assert(https2.isValid === true, 'validateGitHubUrl accepts HTTPS URL with .git'); + assert(https2.repo === 'repo', 'validateGitHubUrl strips .git suffix'); + + const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git'); + assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL'); + assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH'); + + const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo'); + assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL'); + + const bad2 = mgr.validateGitHubUrl(''); + assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string'); + + const bad3 = mgr.validateGitHubUrl(null); + assert(bad3.isValid === false, 'validateGitHubUrl rejects null'); + + const bad4 = mgr.validateGitHubUrl('https://github.com/owner'); + assert(bad4.isValid === false, 'validateGitHubUrl rejects URL without repo'); + } + + // --- CustomModuleManager._normalizeCustomModule --- + { + const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager'); + const mgr = new CustomModuleManager(); + + const plugin = { name: 'test-plugin', description: 'A test', version: '1.0.0', author: 'tester', source: './src' }; + const data = { owner: 'Fallback Owner' }; + const result = mgr._normalizeCustomModule(plugin, 'https://github.com/o/r', data); + + assert(result.code === 'test-plugin', 'normalizeCustomModule sets code from plugin name'); + assert(result.type === 'custom', 'normalizeCustomModule sets type to custom'); + assert(result.trustTier === 'unverified', 'normalizeCustomModule sets trustTier to unverified'); + assert(result.version === '1.0.0', 'normalizeCustomModule preserves version'); + assert(result.author === 'tester', 'normalizeCustomModule uses plugin author over data.owner'); + + const pluginNoAuthor = { name: 'x', description: '', version: null }; + const result2 = mgr._normalizeCustomModule(pluginNoAuthor, 'https://github.com/o/r', data); + assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner'); + } + + // --- CommunityModuleManager._normalizeCommunityModule --- + { + const { CommunityModuleManager } = require('../tools/installer/modules/community-manager'); + const mgr = new CommunityModuleManager(); + + const mod = { + name: 'test-mod', + display_name: 'Test Module', + code: 'tm', + description: 'desc', + repository: 'https://github.com/o/r', + module_definition: 'src/module.yaml', + category: 'software-development', + subcategory: 'dev-tools', + trust_tier: 'bmad-certified', + version: '2.0.0', + approved_sha: 'abc123', + promoted: true, + promoted_rank: 1, + keywords: ['test', 'module'], + }; + const result = mgr._normalizeCommunityModule(mod); + + assert(result.code === 'tm', 'normalizeCommunityModule sets code'); + assert(result.displayName === 'Test Module', 'normalizeCommunityModule sets displayName from display_name'); + assert(result.type === 'community', 'normalizeCommunityModule sets type to community'); + assert(result.category === 'software-development', 'normalizeCommunityModule preserves category'); + assert(result.trustTier === 'bmad-certified', 'normalizeCommunityModule maps trust_tier'); + assert(result.approvedSha === 'abc123', 'normalizeCommunityModule maps approved_sha'); + assert(result.promoted === true, 'normalizeCommunityModule maps promoted'); + assert(result.promotedRank === 1, 'normalizeCommunityModule maps promoted_rank'); + assert(result.builtIn === false, 'normalizeCommunityModule sets builtIn false'); + } + + // --- CommunityModuleManager.searchByKeyword (with injected cache) --- + { + const { CommunityModuleManager } = require('../tools/installer/modules/community-manager'); + const mgr = new CommunityModuleManager(); + + // Inject cached index to avoid network call + mgr._cachedIndex = { + modules: [ + { name: 'mod-a', display_name: 'Alpha', code: 'a', description: 'testing tools', category: 'dev', keywords: ['test'] }, + { name: 'mod-b', display_name: 'Beta', code: 'b', description: 'design suite', category: 'design', keywords: ['ux'] }, + { name: 'mod-c', display_name: 'Gamma', code: 'c', description: 'game engine', category: 'game', keywords: ['unity'] }, + ], + }; + + const r1 = await mgr.searchByKeyword('test'); + assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword'); + + const r2 = await mgr.searchByKeyword('design'); + assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description'); + + const r3 = await mgr.searchByKeyword('alpha'); + assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name'); + + const r4 = await mgr.searchByKeyword('xyz'); + assert(r4.length === 0, 'searchByKeyword returns empty for no match'); + + const r5 = await mgr.searchByKeyword('UNITY'); + assert(r5.length === 1 && r5[0].code === 'c', 'searchByKeyword is case-insensitive'); + } + + // --- CommunityModuleManager.listFeatured (with injected cache) --- + { + const { CommunityModuleManager } = require('../tools/installer/modules/community-manager'); + const mgr = new CommunityModuleManager(); + + mgr._cachedIndex = { + modules: [ + { name: 'a', code: 'a', promoted: true, promoted_rank: 3 }, + { name: 'b', code: 'b', promoted: false }, + { name: 'c', code: 'c', promoted: true, promoted_rank: 1 }, + ], + }; + + const featured = await mgr.listFeatured(); + assert(featured.length === 2, 'listFeatured returns only promoted modules'); + assert(featured[0].code === 'c' && featured[1].code === 'a', 'listFeatured sorts by promoted_rank ascending'); + } + + // --- CommunityModuleManager.getCategoryList (with injected cache) --- + { + const { CommunityModuleManager } = require('../tools/installer/modules/community-manager'); + const mgr = new CommunityModuleManager(); + + mgr._cachedIndex = { + modules: [ + { name: 'a', code: 'a', category: 'software-development' }, + { name: 'b', code: 'b', category: 'design-and-creative' }, + { name: 'c', code: 'c', category: 'software-development' }, + ], + }; + mgr._cachedCategories = { + categories: { + 'software-development': { name: 'Software Development' }, + 'design-and-creative': { name: 'Design & Creative' }, + }, + }; + + const cats = await mgr.getCategoryList(); + assert(cats.length === 2, 'getCategoryList returns categories with modules'); + const swDev = cats.find((c) => c.slug === 'software-development'); + assert(swDev && swDev.moduleCount === 2, 'getCategoryList counts modules per category'); + assert(cats[0].name === 'Design & Creative', 'getCategoryList sorts alphabetically'); + } + + // --- CommunityModuleManager SHA pinning normalization --- + { + const { CommunityModuleManager } = require('../tools/installer/modules/community-manager'); + const mgr = new CommunityModuleManager(); + + // Module with SHA set + const withSha = mgr._normalizeCommunityModule({ + name: 'pinned-mod', + code: 'pm', + approved_sha: 'abc123def456', + approved_tag: 'v1.0.0', + }); + assert(withSha.approvedSha === 'abc123def456', 'SHA is preserved when set'); + assert(withSha.approvedTag === 'v1.0.0', 'Tag is preserved as metadata'); + + // Module with null SHA (trusted contributor) + const noSha = mgr._normalizeCommunityModule({ + name: 'trusted-mod', + code: 'tm', + approved_sha: null, + }); + assert(noSha.approvedSha === null, 'Null SHA means no pinning (trusted contributor)'); + } + + // --- CommunityModuleManager.listByCategory (with injected cache) --- + { + const { CommunityModuleManager } = require('../tools/installer/modules/community-manager'); + const mgr = new CommunityModuleManager(); + + mgr._cachedIndex = { + modules: [ + { name: 'a', code: 'a', category: 'design-and-creative' }, + { name: 'b', code: 'b', category: 'software-development' }, + { name: 'c', code: 'c', category: 'design-and-creative' }, + { name: 'd', code: 'd', category: 'game-development' }, + ], + }; + + const design = await mgr.listByCategory('design-and-creative'); + assert(design.length === 2, 'listByCategory filters to matching category'); + assert( + design.every((m) => m.category === 'design-and-creative'), + 'listByCategory returns only matching modules', + ); + + const empty = await mgr.listByCategory('nonexistent'); + assert(empty.length === 0, 'listByCategory returns empty for unknown category'); + } + + // --- CommunityModuleManager.getModuleByCode (with injected cache) --- + { + const { CommunityModuleManager } = require('../tools/installer/modules/community-manager'); + const mgr = new CommunityModuleManager(); + + mgr._cachedIndex = { + modules: [ + { name: 'test-mod', code: 'tm', display_name: 'Test Module' }, + { name: 'other-mod', code: 'om', display_name: 'Other Module' }, + ], + }; + + const found = await mgr.getModuleByCode('tm'); + assert(found !== null && found.code === 'tm', 'getModuleByCode finds existing module'); + + const notFound = await mgr.getModuleByCode('xyz'); + assert(notFound === null, 'getModuleByCode returns null for unknown code'); + } + + // --- CustomModuleManager URL edge cases --- + { + const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager'); + const mgr = new CustomModuleManager(); + + // HTTP (not HTTPS) should work + const http = mgr.validateGitHubUrl('http://github.com/owner/repo'); + assert(http.isValid === true, 'validateGitHubUrl accepts HTTP URL'); + + // Trailing slash should be rejected (strict matching) + const trailing = mgr.validateGitHubUrl('https://github.com/owner/repo/'); + assert(trailing.isValid === false, 'validateGitHubUrl rejects trailing slash'); + + // SSH without .git should work + const sshNoDotGit = mgr.validateGitHubUrl('git@github.com:owner/repo'); + assert(sshNoDotGit.isValid === true, 'validateGitHubUrl accepts SSH without .git'); + assert(sshNoDotGit.repo === 'repo', 'validateGitHubUrl extracts repo from SSH without .git'); + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 60245ce1d..6096e3211 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -1161,6 +1161,38 @@ class Installer { } } + // Add installed community modules to available modules + const { CommunityModuleManager } = require('../modules/community-manager'); + const communityMgr = new CommunityModuleManager(); + const communityModules = await communityMgr.listAll(); + for (const communityModule of communityModules) { + if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) { + availableModules.push({ + id: communityModule.code, + name: communityModule.displayName, + isExternal: true, + fromCommunity: true, + }); + } + } + + // Add installed custom modules to available modules + const { CustomModuleManager } = require('../modules/custom-module-manager'); + const customMgr = new CustomModuleManager(); + for (const moduleId of installedModules) { + if (!availableModules.some((m) => m.id === moduleId)) { + const customSource = await customMgr.findModuleSourceByCode(moduleId); + if (customSource) { + availableModules.push({ + id: moduleId, + name: moduleId, + isExternal: true, + fromCustom: true, + }); + } + } + } + const availableModuleIds = new Set(availableModules.map((m) => m.id)); // Only update modules that are BOTH installed AND available (we have source for) diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index f70482f43..d810ec1d3 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -818,6 +818,34 @@ class Manifest { }; } + // Check if this is a community module + const { CommunityModuleManager } = require('../modules/community-manager'); + const communityMgr = new CommunityModuleManager(); + const communityInfo = await communityMgr.getModuleByCode(moduleName); + if (communityInfo) { + const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath); + return { + version: communityVersion || communityInfo.version, + source: 'community', + npmPackage: communityInfo.npmPackage || null, + repoUrl: communityInfo.url || null, + }; + } + + // Check if this is a custom module (from user-provided URL) + const { CustomModuleManager } = require('../modules/custom-module-manager'); + const customMgr = new CustomModuleManager(); + const customSource = await customMgr.findModuleSourceByCode(moduleName); + if (customSource) { + const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath); + return { + version: customVersion, + source: 'custom', + npmPackage: null, + repoUrl: null, + }; + } + // Unknown module const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); return { diff --git a/tools/installer/modules/community-manager.js b/tools/installer/modules/community-manager.js new file mode 100644 index 000000000..0f88cffff --- /dev/null +++ b/tools/installer/modules/community-manager.js @@ -0,0 +1,377 @@ +const fs = require('fs-extra'); +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 MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main'; +const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`; +const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`; + +/** + * 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. + */ +class CommunityModuleManager { + constructor() { + this._client = new RegistryClient(); + this._cachedIndex = null; + this._cachedCategories = 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.fetchYaml(COMMUNITY_INDEX_URL); + 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.fetchYaml(CATEGORIES_URL); + 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(); + }; + + const sha = moduleInfo.approvedSha; + let needsDependencyInstall = false; + let wasNewClone = false; + + if (await fs.pathExists(moduleCacheDir)) { + // Already cloned - update to latest HEAD + 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' }, + }); + 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 { + 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}`); + } + } + + // If pinned to a specific SHA, check out that exact commit. + // Refuse to install if the approved SHA cannot be reached - security requirement. + if (sha) { + const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); + if (headSha !== sha) { + try { + execSync(`git fetch --depth 1 origin ${sha}`, { + cwd: moduleCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + execSync(`git checkout ${sha}`, { + 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 (${sha}). ` + + `Installation refused for security. The module registry entry may need updating.`, + ); + } + } + } + + // 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; + } + + // ─── 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 }; diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js new file mode 100644 index 000000000..18a631a29 --- /dev/null +++ b/tools/installer/modules/custom-module-manager.js @@ -0,0 +1,308 @@ +const fs = require('fs-extra'); +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'); + +/** + * Manages custom modules installed from user-provided GitHub URLs. + * Validates URLs, fetches .claude-plugin/marketplace.json, clones repos. + */ +class CustomModuleManager { + constructor() { + this._client = new RegistryClient(); + } + + // ─── URL Validation ─────────────────────────────────────────────────────── + + /** + * Parse and validate a GitHub repository URL. + * Supports HTTPS and SSH formats. + * @param {string} url - GitHub URL to validate + * @returns {Object} { owner, repo, isValid, error } + */ + validateGitHubUrl(url) { + if (!url || typeof url !== 'string') { + return { owner: null, repo: null, isValid: false, error: 'URL is required' }; + } + + const trimmed = url.trim(); + + // HTTPS format: https://github.com/owner/repo[.git] + const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null }; + } + + // SSH format: git@github.com:owner/repo.git + const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null }; + } + + return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' }; + } + + // ─── Discovery ──────────────────────────────────────────────────────────── + + /** + * Fetch .claude-plugin/marketplace.json from a GitHub repository. + * @param {string} repoUrl - GitHub repository URL + * @returns {Object} Parsed marketplace.json content + */ + async fetchMarketplaceJson(repoUrl) { + const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl); + if (!isValid) throw new Error(error); + + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`; + + try { + return await this._client.fetchJson(rawUrl); + } catch (error_) { + if (error_.message.includes('404')) { + throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`); + } + if (error_.message.includes('403')) { + throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`); + } + throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`); + } + } + + /** + * Discover modules from a GitHub repository's marketplace.json. + * @param {string} repoUrl - GitHub repository URL + * @returns {Array} Normalized plugin list + */ + async discoverModules(repoUrl) { + const data = await this.fetchMarketplaceJson(repoUrl); + const plugins = data?.plugins; + + if (!Array.isArray(plugins) || plugins.length === 0) { + throw new Error('marketplace.json contains no plugins'); + } + + return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data)); + } + + // ─── Clone ──────────────────────────────────────────────────────────────── + + /** + * Get the cache directory for custom modules. + * @returns {string} Path to the custom modules cache directory + */ + getCacheDir() { + return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules'); + } + + /** + * Clone a custom module repository to cache. + * @param {string} repoUrl - GitHub repository URL + * @param {Object} [options] - Clone options + * @param {boolean} [options.silent] - Suppress spinner output + * @returns {string} Path to the cloned repository + */ + async cloneRepo(repoUrl, options = {}) { + const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl); + if (!isValid) throw new Error(error); + + const cacheDir = this.getCacheDir(); + const repoCacheDir = path.join(cacheDir, owner, repo); + const silent = options.silent || false; + + await fs.ensureDir(path.join(cacheDir, owner)); + + const createSpinner = async () => { + if (silent) { + return { start() {}, stop() {}, error() {} }; + } + return await prompts.spinner(); + }; + + if (await fs.pathExists(repoCacheDir)) { + // Update existing clone + const fetchSpinner = await createSpinner(); + fetchSpinner.start(`Updating ${owner}/${repo}...`); + try { + execSync('git fetch origin --depth 1', { + cwd: repoCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + execSync('git reset --hard origin/HEAD', { + cwd: repoCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + }); + fetchSpinner.stop(`Updated ${owner}/${repo}`); + } catch { + fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`); + await fs.remove(repoCacheDir); + } + } + + if (!(await fs.pathExists(repoCacheDir))) { + const fetchSpinner = await createSpinner(); + fetchSpinner.start(`Cloning ${owner}/${repo}...`); + try { + execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + fetchSpinner.stop(`Cloned ${owner}/${repo}`); + } catch (error_) { + fetchSpinner.error(`Failed to clone ${owner}/${repo}`); + throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`); + } + } + + // Install dependencies if package.json exists + const packageJsonPath = path.join(repoCacheDir, 'package.json'); + if (await fs.pathExists(packageJsonPath)) { + const installSpinner = await createSpinner(); + installSpinner.start(`Installing dependencies for ${owner}/${repo}...`); + try { + execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { + cwd: repoCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }); + installSpinner.stop(`Installed dependencies for ${owner}/${repo}`); + } catch (error_) { + installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`); + if (!silent) await prompts.log.warn(` ${error_.message}`); + } + } + + return repoCacheDir; + } + + // ─── Source Finding ─────────────────────────────────────────────────────── + + /** + * Find the module source path within a cloned custom repo. + * @param {string} repoUrl - GitHub repository URL (for cache location) + * @param {string} [pluginSource] - Plugin source path from marketplace.json + * @returns {string|null} Path to directory containing module.yaml + */ + async findModuleSource(repoUrl, pluginSource) { + const { owner, repo } = this.validateGitHubUrl(repoUrl); + const repoCacheDir = path.join(this.getCacheDir(), owner, repo); + + if (!(await fs.pathExists(repoCacheDir))) return null; + + // Try plugin source path first (e.g., "./src/pro-skills") + if (pluginSource) { + const sourcePath = path.join(repoCacheDir, pluginSource); + const moduleYaml = path.join(sourcePath, 'module.yaml'); + if (await fs.pathExists(moduleYaml)) { + return sourcePath; + } + } + + // Fallback: search skills/ and src/ directories + for (const dir of ['skills', 'src']) { + const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml'); + if (await fs.pathExists(rootCandidate)) { + return path.dirname(rootCandidate); + } + const dirPath = path.join(repoCacheDir, 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(repoCacheDir, 'module.yaml'); + if (await fs.pathExists(rootCandidate)) { + return repoCacheDir; + } + + return null; + } + + /** + * Find module source by module code, searching the custom cache. + * @param {string} moduleCode - Module code to search for + * @param {Object} [options] - Options + * @returns {string|null} Path to the module source or null + */ + async findModuleSourceByCode(moduleCode, options = {}) { + const cacheDir = this.getCacheDir(); + if (!(await fs.pathExists(cacheDir))) return null; + + // Search through all custom repo caches + try { + const owners = await fs.readdir(cacheDir, { withFileTypes: true }); + for (const ownerEntry of owners) { + if (!ownerEntry.isDirectory()) continue; + const ownerPath = path.join(cacheDir, ownerEntry.name); + const repos = await fs.readdir(ownerPath, { withFileTypes: true }); + for (const repoEntry of repos) { + if (!repoEntry.isDirectory()) continue; + const repoPath = path.join(ownerPath, repoEntry.name); + + // Check marketplace.json for matching module code + const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json'); + if (await fs.pathExists(marketplacePath)) { + try { + const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8')); + for (const plugin of data.plugins || []) { + if (plugin.name === moduleCode) { + // Found the module - find its source + const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath; + const moduleYaml = path.join(sourcePath, 'module.yaml'); + if (await fs.pathExists(moduleYaml)) { + return sourcePath; + } + } + } + } catch { + // Skip malformed marketplace.json + } + } + } + } + } catch { + // Cache doesn't exist or is inaccessible + } + + return null; + } + + // ─── Normalization ──────────────────────────────────────────────────────── + + /** + * Normalize a plugin from marketplace.json to a consistent shape. + * @param {Object} plugin - Plugin object from marketplace.json + * @param {string} repoUrl - Source repository URL + * @param {Object} data - Full marketplace.json data + * @returns {Object} Normalized module info + */ + _normalizeCustomModule(plugin, repoUrl, data) { + return { + code: plugin.name, + name: plugin.name, + displayName: plugin.name, + description: plugin.description || '', + version: plugin.version || null, + author: plugin.author || data.owner || '', + url: repoUrl, + source: plugin.source || null, + type: 'custom', + trustTier: 'unverified', + builtIn: false, + isExternal: true, + }; + } +} + +module.exports = { CustomModuleManager }; diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index db70a6678..f9f9ff06e 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -1,10 +1,10 @@ const fs = require('fs-extra'); const os = require('node:os'); const path = require('node:path'); -const https = require('node:https'); const { execSync } = require('node:child_process'); const yaml = require('yaml'); const prompts = require('../prompts'); +const { RegistryClient } = require('./registry-client'); const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'; const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml'); @@ -17,35 +17,8 @@ const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml'); * @class ExternalModuleManager */ class ExternalModuleManager { - constructor() {} - - /** - * Fetch a URL and return the response body as a string. - * @param {string} url - URL to fetch - * @param {number} timeout - Timeout in ms (default 10s) - * @returns {Promise} Response body - */ - _fetch(url, timeout = 10_000) { - return new Promise((resolve, reject) => { - const req = https - .get(url, { timeout }, (res) => { - // Follow one redirect (GitHub sometimes 301s) - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - return this._fetch(res.headers.location, timeout).then(resolve, reject); - } - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode}`)); - } - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => resolve(data)); - }) - .on('error', reject) - .on('timeout', () => { - req.destroy(); - reject(new Error('Request timed out')); - }); - }); + constructor() { + this._client = new RegistryClient(); } /** @@ -60,7 +33,7 @@ class ExternalModuleManager { // Try remote registry first try { - const content = await this._fetch(REGISTRY_RAW_URL); + const content = await this._client.fetch(REGISTRY_RAW_URL); const config = yaml.parse(content); if (config?.modules?.length) { this.cachedModules = config; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 0effc86b8..6b9f76059 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -202,6 +202,22 @@ class OfficialModules { return externalSource; } + // Check community modules + const { CommunityModuleManager } = require('./community-manager'); + const communityMgr = new CommunityModuleManager(); + const communitySource = await communityMgr.findModuleSource(moduleCode, options); + if (communitySource) { + return communitySource; + } + + // Check custom modules (from user-provided URLs, already cloned to cache) + const { CustomModuleManager } = require('./custom-module-manager'); + const customMgr = new CustomModuleManager(); + const customSource = await customMgr.findModuleSourceByCode(moduleCode, options); + if (customSource) { + return customSource; + } + return null; } @@ -1131,7 +1147,13 @@ class OfficialModules { // Collect all answers (static + prompted) let allAnswers = { ...staticAnswers }; - if (questions.length > 0) { + if (questions.length > 0 && silentMode) { + // In silent mode (quick update), use defaults for new fields instead of prompting + for (const q of questions) { + allAnswers[q.name] = typeof q.default === 'function' ? q.default({}) : q.default; + } + await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured with defaults`); + } else if (questions.length > 0) { // Only show header if we actually have questions await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader); await prompts.log.message(''); diff --git a/tools/installer/modules/registry-client.js b/tools/installer/modules/registry-client.js new file mode 100644 index 000000000..31965e00c --- /dev/null +++ b/tools/installer/modules/registry-client.js @@ -0,0 +1,66 @@ +const https = require('node:https'); +const yaml = require('yaml'); + +/** + * Shared HTTP client for fetching registry data from GitHub. + * Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager. + */ +class RegistryClient { + constructor(options = {}) { + this.timeout = options.timeout || 10_000; + } + + /** + * Fetch a URL and return the response body as a string. + * Follows one redirect (GitHub sometimes 301s). + * @param {string} url - URL to fetch + * @param {number} [timeout] - Timeout in ms (overrides default) + * @returns {Promise} Response body + */ + fetch(url, timeout) { + const timeoutMs = timeout || this.timeout; + return new Promise((resolve, reject) => { + const req = https + .get(url, { timeout: timeoutMs }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return this.fetch(res.headers.location, timeoutMs).then(resolve, reject); + } + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode}`)); + } + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve(data)); + }) + .on('error', reject) + .on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + }); + } + + /** + * Fetch a URL and parse the response as YAML. + * @param {string} url - URL to fetch + * @param {number} [timeout] - Timeout in ms + * @returns {Promise} Parsed YAML content + */ + async fetchYaml(url, timeout) { + const content = await this.fetch(url, timeout); + return yaml.parse(content); + } + + /** + * Fetch a URL and parse the response as JSON. + * @param {string} url - URL to fetch + * @param {number} [timeout] - Timeout in ms + * @returns {Promise} Parsed JSON content + */ + async fetchJson(url, timeout) { + const content = await this.fetch(url, timeout); + return JSON.parse(content); + } +} + +module.exports = { RegistryClient }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 2c5c34479..de8783666 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -563,22 +563,58 @@ class UI { } /** - * Select all modules (official + community) using grouped multiselect. - * Core is shown as locked but filtered from the result since it's always installed separately. + * Select all modules across three tiers: official, community, and custom URL. * @param {Set} installedModuleIds - Currently installed module IDs * @returns {Array} Selected module codes (excluding core) */ async selectAllModules(installedModuleIds = new Set()) { - // Registry is the single source of truth for the module list + // Phase 1: Official modules + const officialSelected = await this._selectOfficialModules(installedModuleIds); + + // Determine which installed modules are NOT official (community or custom). + // These must be preserved even if the user declines to browse community/custom. + const officialCodes = new Set(officialSelected); + const externalManager = new ExternalModuleManager(); + const registryModules = await externalManager.listAvailable(); + const officialRegistryCodes = new Set(registryModules.map((m) => m.code)); + const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id)); + + // Phase 2: Community modules (category drill-down) + // Returns { codes, didBrowse } so we know if the user entered the flow + const communityResult = await this._browseCommunityModules(installedModuleIds); + + // Phase 3: Custom URL modules + const customSelected = await this._addCustomUrlModules(installedModuleIds); + + // Merge all selections + const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]); + + // Auto-include installed non-official modules that the user didn't get + // a chance to manage (they declined to browse). If they did browse, + // trust their selections - they could have deselected intentionally. + if (!communityResult.didBrowse) { + for (const code of installedNonOfficial) { + allSelected.add(code); + } + } + + return [...allSelected]; + } + + /** + * Select official modules using autocompleteMultiselect. + * Extracted from the original selectAllModules - unchanged behavior. + * @param {Set} installedModuleIds - Currently installed module IDs + * @returns {Array} Selected official module codes + */ + async _selectOfficialModules(installedModuleIds = new Set()) { const externalManager = new ExternalModuleManager(); const registryModules = await externalManager.listAvailable(); - // Build flat options list with group hints for autocompleteMultiselect const allOptions = []; const initialValues = []; const lockedValues = ['core']; - // Helper to build module entry with proper sorting and selection const buildModuleEntry = async (mod) => { const isInstalled = installedModuleIds.has(mod.code); const version = await getMarketplaceVersion(mod.code); @@ -591,7 +627,6 @@ class UI { }; }; - // Registry order is display order; core is always locked for (const mod of registryModules) { const entry = await buildModuleEntry(mod); allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint }); @@ -601,7 +636,7 @@ class UI { } const selected = await prompts.autocompleteMultiselect({ - message: 'Select modules to install:', + message: 'Select official modules to install:', options: allOptions, initialValues: initialValues.length > 0 ? initialValues : undefined, lockedValues, @@ -611,18 +646,261 @@ class UI { const result = selected ? [...selected] : []; - // Display selected modules as bulleted list if (result.length > 0) { const moduleLines = result.map((moduleId) => { const opt = allOptions.find((o) => o.value === moduleId); return ` \u2022 ${opt?.label || moduleId}`; }); - await prompts.log.message('Selected modules:\n' + moduleLines.join('\n')); + await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n')); } return result; } + /** + * Browse and select community modules using category drill-down. + * Featured/promoted modules appear at the top. + * @param {Set} installedModuleIds - Currently installed module IDs + * @returns {Object} { codes: string[], didBrowse: boolean } + */ + async _browseCommunityModules(installedModuleIds = new Set()) { + const browseCommunity = await prompts.confirm({ + message: 'Would you like to browse community modules?', + default: false, + }); + if (!browseCommunity) return { codes: [], didBrowse: false }; + + const { CommunityModuleManager } = require('./modules/community-manager'); + const communityMgr = new CommunityModuleManager(); + + const s = await prompts.spinner(); + s.start('Loading community module catalog...'); + + let categories, featured, allCommunity; + try { + [categories, featured, allCommunity] = await Promise.all([ + communityMgr.getCategoryList(), + communityMgr.listFeatured(), + communityMgr.listAll(), + ]); + s.stop(`Community catalog loaded (${allCommunity.length} modules)`); + } catch (error) { + s.error('Failed to load community catalog'); + await prompts.log.warn(` ${error.message}`); + return { codes: [], didBrowse: false }; + } + + if (allCommunity.length === 0) { + await prompts.log.info('No community modules are currently available.'); + return { codes: [], didBrowse: false }; + } + + const selectedCodes = new Set(); + let browsing = true; + + while (browsing) { + const categoryChoices = []; + + // Featured section at top + if (featured.length > 0) { + categoryChoices.push({ + value: '__featured__', + label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`, + }); + } + + // Categories with module counts + for (const cat of categories) { + categoryChoices.push({ + value: cat.slug, + label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`, + }); + } + + // Special actions at bottom + categoryChoices.push( + { value: '__all__', label: '\u25CE View all community modules' }, + { value: '__search__', label: '\u25CE Search by keyword' }, + { value: '__done__', label: '\u2713 Done browsing' }, + ); + + const selectedCount = selectedCodes.size; + const categoryChoice = await prompts.select({ + message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`, + choices: categoryChoices, + }); + + if (categoryChoice === '__done__') { + browsing = false; + continue; + } + + let modulesToShow; + switch (categoryChoice) { + case '__featured__': { + modulesToShow = featured; + + break; + } + case '__all__': { + modulesToShow = allCommunity; + + break; + } + case '__search__': { + const query = await prompts.text({ + message: 'Search community modules:', + placeholder: 'e.g., design, testing, game', + }); + if (!query || query.trim() === '') continue; + modulesToShow = await communityMgr.searchByKeyword(query.trim()); + if (modulesToShow.length === 0) { + await prompts.log.warn('No matching modules found.'); + continue; + } + + break; + } + default: { + modulesToShow = await communityMgr.listByCategory(categoryChoice); + } + } + + // Build options for autocompleteMultiselect + const trustBadge = (tier) => { + if (tier === 'bmad-certified') return '\u2713'; + if (tier === 'community-reviewed') return '\u25CB'; + return '\u26A0'; + }; + + const options = modulesToShow.map((mod) => { + const versionStr = mod.version ? ` (v${mod.version})` : ''; + const badge = trustBadge(mod.trustTier); + return { + label: `${mod.displayName}${versionStr} [${badge}]`, + value: mod.code, + hint: mod.description, + }; + }); + + // Pre-check modules that are already selected or installed + const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code); + + const selected = await prompts.autocompleteMultiselect({ + message: 'Select community modules:', + options, + initialValues: initialValues.length > 0 ? initialValues : undefined, + required: false, + maxItems: Math.min(options.length, 10), + }); + + // Update accumulated selections: sync with what user selected in this view + const shownCodes = new Set(modulesToShow.map((m) => m.code)); + for (const code of shownCodes) { + if (selected && selected.includes(code)) { + selectedCodes.add(code); + } else { + selectedCodes.delete(code); + } + } + } + + if (selectedCodes.size > 0) { + const moduleLines = []; + for (const code of selectedCodes) { + const mod = await communityMgr.getModuleByCode(code); + moduleLines.push(` \u2022 ${mod?.displayName || code}`); + } + await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n')); + } + + return { codes: [...selectedCodes], didBrowse: true }; + } + + /** + * Prompt user to install modules from custom GitHub URLs. + * @param {Set} installedModuleIds - Currently installed module IDs + * @returns {Array} Selected custom module code strings + */ + async _addCustomUrlModules(installedModuleIds = new Set()) { + const addCustom = await prompts.confirm({ + message: 'Would you like to install from a custom GitHub URL?', + default: false, + }); + if (!addCustom) return []; + + const { CustomModuleManager } = require('./modules/custom-module-manager'); + const customMgr = new CustomModuleManager(); + const selectedModules = []; + + let addMore = true; + while (addMore) { + const url = await prompts.text({ + message: 'GitHub repository URL:', + placeholder: 'https://github.com/owner/repo', + validate: (input) => { + if (!input || input.trim() === '') return 'URL is required'; + const result = customMgr.validateGitHubUrl(input.trim()); + return result.isValid ? undefined : result.error; + }, + }); + + const s = await prompts.spinner(); + s.start('Fetching module info...'); + + try { + const plugins = await customMgr.discoverModules(url.trim()); + s.stop('Module info loaded'); + + await prompts.log.warn( + 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', + ); + + for (const plugin of plugins) { + const versionStr = plugin.version ? ` v${plugin.version}` : ''; + await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`); + } + + const confirmInstall = await prompts.confirm({ + message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`, + default: false, + }); + + if (confirmInstall) { + // Pre-clone the repo so it's cached for the install pipeline + s.start('Cloning repository...'); + try { + await customMgr.cloneRepo(url.trim()); + s.stop('Repository cloned'); + } catch (cloneError) { + s.error('Failed to clone repository'); + await prompts.log.error(` ${cloneError.message}`); + addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + continue; + } + + for (const plugin of plugins) { + selectedModules.push(plugin.code); + } + } + } catch (error) { + s.error('Failed to load module info'); + await prompts.log.error(` ${error.message}`); + } + + addMore = await prompts.confirm({ + message: 'Add another custom module?', + default: false, + }); + } + + if (selectedModules.length > 0) { + await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n')); + } + + return selectedModules; + } + /** * Get default modules for non-interactive mode * @param {Set} installedModuleIds - Already installed module IDs @@ -946,6 +1224,7 @@ class UI { // Group modules by source const builtIn = modules.filter((m) => m.source === 'built-in'); const external = modules.filter((m) => m.source === 'external'); + const community = modules.filter((m) => m.source === 'community'); const custom = modules.filter((m) => m.source === 'custom'); const unknown = modules.filter((m) => m.source === 'unknown'); @@ -966,6 +1245,7 @@ class UI { formatGroup(builtIn, 'Built-in Modules'); formatGroup(external, 'External Modules (Official)'); + formatGroup(community, 'Community Modules'); formatGroup(custom, 'Custom Modules'); formatGroup(unknown, 'Other Modules');