const fs = require('fs-extra'); const os = require('node:os'); const path = require('node:path'); const { execSync } = require('node:child_process'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); /** * Manages external official modules defined in external-official-modules.yaml * These are modules hosted in external repositories that can be installed * * @class ExternalModuleManager */ class ExternalModuleManager { constructor() { this.externalModulesConfigPath = path.join(__dirname, '../../../external-official-modules.yaml'); this.cachedModules = null; } /** * Load and parse the external-official-modules.yaml file * @returns {Object} Parsed YAML content with modules object */ async loadExternalModulesConfig() { if (this.cachedModules) { return this.cachedModules; } try { const content = await fs.readFile(this.externalModulesConfigPath, 'utf8'); const config = yaml.parse(content); this.cachedModules = config; return config; } catch (error) { await prompts.log.warn(`Failed to load external modules config: ${error.message}`); return { modules: {} }; } } /** * Get list of available external modules * @returns {Array} Array of module info objects */ async listAvailable() { const config = await this.loadExternalModulesConfig(); const modules = []; for (const [key, moduleConfig] of Object.entries(config.modules || {})) { modules.push({ key, url: moduleConfig.url, moduleDefinition: moduleConfig['module-definition'], code: moduleConfig.code, name: moduleConfig.name, header: moduleConfig.header, subheader: moduleConfig.subheader, description: moduleConfig.description || '', defaultSelected: moduleConfig.defaultSelected === true, type: moduleConfig.type || 'community', // bmad-org or community npmPackage: moduleConfig.npmPackage || null, // Include npm package name isExternal: true, }); } return modules; } /** * Get module info by code * @param {string} code - The module code (e.g., 'cis') * @returns {Object|null} Module info or null if not found */ async getModuleByCode(code) { const modules = await this.listAvailable(); return modules.find((m) => m.code === code) || null; } /** * Get module info by key * @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite') * @returns {Object|null} Module info or null if not found */ async getModuleByKey(key) { const config = await this.loadExternalModulesConfig(); const moduleConfig = config.modules?.[key]; if (!moduleConfig) { return null; } return { key, url: moduleConfig.url, moduleDefinition: moduleConfig['module-definition'], code: moduleConfig.code, name: moduleConfig.name, header: moduleConfig.header, subheader: moduleConfig.subheader, description: moduleConfig.description || '', defaultSelected: moduleConfig.defaultSelected === true, type: moduleConfig.type || 'community', // bmad-org or community npmPackage: moduleConfig.npmPackage || null, // Include npm package name isExternal: true, }; } /** * Check if a module code exists in external modules * @param {string} code - The module code to check * @returns {boolean} True if the module exists */ async hasModule(code) { const module = await this.getModuleByCode(code); return module !== null; } /** * Get the URL for a module by code * @param {string} code - The module code * @returns {string|null} The URL or null if not found */ async getModuleUrl(code) { const module = await this.getModuleByCode(code); return module ? module.url : null; } /** * Get the module definition path for a module by code * @param {string} code - The module code * @returns {string|null} The module definition path or null if not found */ async getModuleDefinition(code) { const module = await this.getModuleByCode(code); return module ? module.moduleDefinition : null; } /** * Get the cache directory for external modules * @returns {string} Path to the external modules cache directory */ getExternalCacheDir() { const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules'); return cacheDir; } /** * Clone an external module repository to cache * @param {string} moduleCode - Code of the external module * @param {Object} options - Clone options * @param {boolean} options.silent - Suppress spinner output * @returns {string} Path to the cloned repository */ async cloneExternalModule(moduleCode, options = {}) { const moduleInfo = await this.getModuleByCode(moduleCode); if (!moduleInfo) { throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`); } const cacheDir = this.getExternalCacheDir(); const moduleCacheDir = path.join(cacheDir, moduleCode); const silent = options.silent || false; // Create cache directory if it doesn't exist await fs.ensureDir(cacheDir); // Helper to create a spinner or a no-op when silent const createSpinner = async () => { if (silent) { return { start() {}, stop() {}, error() {}, message() {}, cancel() {}, clear() {}, get isSpinning() { return false; }, get isCancelled() { return false; }, }; } return await prompts.spinner(); }; // Track if we need to install dependencies let needsDependencyInstall = false; let wasNewClone = false; // Check if already cloned if (await fs.pathExists(moduleCacheDir)) { // Try to update if it's a git repo const fetchSpinner = await createSpinner(); fetchSpinner.start(`Fetching ${moduleInfo.name}...`); try { const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); // Fetch and reset to remote - works better with shallow clones than pull 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'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); fetchSpinner.stop(`Fetched ${moduleInfo.name}`); // Force dependency install if we got new code if (currentRef !== newRef) { needsDependencyInstall = true; } } catch { fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`); // If update fails, remove and re-clone await fs.remove(moduleCacheDir); wasNewClone = true; } } else { wasNewClone = true; } // Clone if not exists or was removed if (wasNewClone) { const fetchSpinner = await createSpinner(); fetchSpinner.start(`Fetching ${moduleInfo.name}...`); try { execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); fetchSpinner.stop(`Fetched ${moduleInfo.name}`); } catch (error) { fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`); throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`); } } // Install dependencies if package.json exists const packageJsonPath = path.join(moduleCacheDir, 'package.json'); const nodeModulesPath = path.join(moduleCacheDir, 'node_modules'); if (await fs.pathExists(packageJsonPath)) { // Install if node_modules doesn't exist, or if package.json is newer (dependencies changed) const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath)); // Force install if we updated or cloned new if (needsDependencyInstall || wasNewClone || nodeModulesMissing) { const installSpinner = await createSpinner(); installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); try { execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, // 2 minute timeout }); installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); } catch (error) { installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); if (!silent) await prompts.log.warn(` ${error.message}`); } } else { // Check if package.json is newer than node_modules let packageJsonNewer = false; try { const packageStats = await fs.stat(packageJsonPath); const nodeModulesStats = await fs.stat(nodeModulesPath); packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime; } catch { // If stat fails, assume we need to install packageJsonNewer = true; } if (packageJsonNewer) { const installSpinner = await createSpinner(); installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); try { execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { cwd: moduleCacheDir, stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, // 2 minute timeout }); installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); } catch (error) { installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); if (!silent) await prompts.log.warn(` ${error.message}`); } } } } return moduleCacheDir; } /** * Find the source path for an external module * @param {string} moduleCode - Code of the external module * @param {Object} options - Options passed to cloneExternalModule * @returns {string|null} Path to the module source or null if not found */ async findExternalModuleSource(moduleCode, options = {}) { const moduleInfo = await this.getModuleByCode(moduleCode); if (!moduleInfo) { return null; } // Clone the external module repo const cloneDir = await this.cloneExternalModule(moduleCode, options); // The module-definition specifies the path to module.yaml relative to repo root // We need to return the directory containing module.yaml const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml' const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath)); return moduleDir; } } module.exports = { ExternalModuleManager };