const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); const { CustomHandler } = require('../custom-handler'); const { Manifest } = require('../core/manifest'); const prompts = require('../prompts'); class CustomModules { constructor() { this.paths = new Map(); } has(moduleCode) { return this.paths.has(moduleCode); } get(moduleCode) { return this.paths.get(moduleCode); } set(moduleId, sourcePath) { this.paths.set(moduleId, sourcePath); } /** * Install a custom module from its source path. * @param {string} moduleName - Module identifier * @param {string} bmadDir - Target bmad directory * @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Object} options - Install options * @param {Object} options.moduleConfig - Pre-collected module configuration * @returns {Object} Install result */ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { const sourcePath = this.paths.get(moduleName); if (!sourcePath) { throw new Error(`No source path for custom module '${moduleName}'`); } if (!(await fs.pathExists(sourcePath))) { throw new Error(`Source for custom module '${moduleName}' not found at: ${sourcePath}`); } const targetPath = path.join(bmadDir, moduleName); // Read custom.yaml and merge into module config let moduleConfig = options.moduleConfig ? { ...options.moduleConfig } : {}; const customConfigPath = path.join(sourcePath, 'custom.yaml'); if (await fs.pathExists(customConfigPath)) { try { const content = await fs.readFile(customConfigPath, 'utf8'); const customConfig = yaml.parse(content); if (customConfig) { moduleConfig = { ...moduleConfig, ...customConfig }; } } catch (error) { await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); } } // Remove existing installation if (await fs.pathExists(targetPath)) { await fs.remove(targetPath); } // Copy files with filtering await this._copyWithFiltering(sourcePath, targetPath, fileTrackingCallback); // Add to manifest const manifest = new Manifest(); const versionInfo = await manifest.getModuleVersionInfo(moduleName, bmadDir, sourcePath); await manifest.addModule(bmadDir, moduleName, { version: versionInfo.version, source: versionInfo.source, npmPackage: versionInfo.npmPackage, repoUrl: versionInfo.repoUrl, }); return { success: true, module: moduleName, path: targetPath, moduleConfig }; } /** * Copy module files, filtering out install-time-only artifacts. * @param {string} sourcePath - Source module directory * @param {string} targetPath - Target module directory * @param {Function} fileTrackingCallback - Optional callback to track installed files */ async _copyWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) { const files = await this._getFileList(sourcePath); for (const file of files) { if (file.startsWith('sub-modules/')) continue; const isInSidecar = path .dirname(file) .split('/') .some((dir) => dir.toLowerCase().endsWith('-sidecar')); if (isInSidecar) continue; if (file === 'module.yaml') continue; if (file === 'config.yaml') continue; const sourceFile = path.join(sourcePath, file); const targetFile = path.join(targetPath, file); // Skip web-only agents if (file.startsWith('agents/') && file.endsWith('.md')) { const content = await fs.readFile(sourceFile, 'utf8'); if (/]*\slocalskip="true"[^>]*>/.test(content)) { continue; } } await fs.ensureDir(path.dirname(targetFile)); await fs.copy(sourceFile, targetFile, { overwrite: true }); if (fileTrackingCallback) { fileTrackingCallback(targetFile); } } } /** * Recursively list all files in a directory. * @param {string} dir - Directory to scan * @param {string} baseDir - Base directory for relative paths * @returns {string[]} Relative file paths */ async _getFileList(dir, baseDir = dir) { const files = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await this._getFileList(fullPath, baseDir))); } else { files.push(path.relative(baseDir, fullPath)); } } return files; } /** * Discover custom module source paths from all available sources. * @param {Object} config - Installation configuration * @param {Object} paths - InstallPaths instance * @returns {Map} Map of module ID to source path */ async discoverPaths(config, paths) { this.paths = new Map(); if (config._quickUpdate) { if (config._customModuleSources) { for (const [moduleId, customInfo] of config._customModuleSources) { this.paths.set(moduleId, customInfo.sourcePath); } } return this.paths; } // From manifest (regular updates) if (config._isUpdate && config._existingInstall) { for (const customModule of config._existingInstall.customModules) { let absoluteSourcePath = customModule.sourcePath; if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) { absoluteSourcePath = path.join(paths.bmadDir, absoluteSourcePath); } else if (!absoluteSourcePath && customModule.relativePath) { absoluteSourcePath = path.resolve(paths.projectRoot, customModule.relativePath); } else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) { absoluteSourcePath = path.resolve(absoluteSourcePath); } if (absoluteSourcePath) { this.paths.set(customModule.id, absoluteSourcePath); } } } // From UI: selectedFiles if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { const customHandler = new CustomHandler(); for (const customFile of config.customContent.selectedFiles) { const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot); if (customInfo && customInfo.id) { this.paths.set(customInfo.id, customInfo.path); } } } // From UI: sources if (config.customContent && config.customContent.sources) { for (const source of config.customContent.sources) { this.paths.set(source.id, source.path); } } // From UI: cachedModules if (config.customContent && config.customContent.cachedModules) { const selectedCachedIds = config.customContent.selectedCachedModules || []; const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected; for (const cachedModule of config.customContent.cachedModules) { if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) { this.paths.set(cachedModule.id, cachedModule.cachePath); } } } return this.paths; } } module.exports = { CustomModules };