From b954d2ec816365c4725fb66b971fb64db611256c Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sun, 22 Mar 2026 02:36:56 -0600 Subject: [PATCH] refactor(installer): separate custom and official module install paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give CustomModules its own install() method with independent file-copy-with-filtering logic. Route _installCustomModules through CustomModules.install() instead of OfficialModules. Remove custom.yaml reading and sourcePath/isCustom options from OfficialModules.install(). Eliminate _buildModuleLists — compute official vs custom module IDs inline from discoverPaths results. --- tools/cli/installers/lib/core/installer.js | 138 +++--------------- .../installers/lib/modules/custom-modules.js | 124 ++++++++++++++++ .../lib/modules/official-modules.js | 36 +---- 3 files changed, 143 insertions(+), 155 deletions(-) diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 2848ca011..1724f7004 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -8,7 +8,6 @@ const { FileOps } = require('../../../lib/file-ops'); const { Config } = require('./config'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { ManifestGenerator } = require('./manifest-generator'); -const { CustomHandler } = require('../custom-handler'); const prompts = require('../../../lib/prompts'); const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { InstallPaths } = require('./install-paths'); @@ -41,7 +40,6 @@ class Installer { const config = Config.build(originalConfig); const paths = await InstallPaths.create(config); const officialModules = await OfficialModules.build(config, paths); - const existingInstall = await ExistingInstall.detect(paths.bmadDir); await this.customModules.discoverPaths(config, paths); @@ -63,7 +61,11 @@ class Installer { await this._cacheCustomModules(paths, addResult); - const { officialModuleIds, allModules } = await this._buildModuleLists(config, customConfig, paths); + // Compute module lists: official = selected minus custom, all = both + const customModuleIds = new Set(this.customModules.paths.keys()); + const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m)); + const allModules = [...officialModuleIds, ...[...customModuleIds].filter((id) => !officialModuleIds.includes(id))]; + await this._installAndConfigure(config, customConfig, paths, officialModuleIds, allModules, addResult, officialModules); await this._setupIdes(config, allModules, paths, addResult); @@ -195,51 +197,6 @@ class Installer { addResult('Custom modules cached', 'ok'); } - /** - * Build the official and combined module lists from config and custom sources. - * @returns {{ officialModuleIds: string[], allModules: string[] }} - */ - async _buildModuleLists(config, customConfig, paths) { - const finalCustomContent = customConfig.customContent; - - const customModuleIds = new Set(); - for (const id of this.customModules.paths.keys()) { - customModuleIds.add(id); - } - if (customConfig._customModuleSources) { - for (const [moduleId, customInfo] of customConfig._customModuleSources) { - if (!customModuleIds.has(moduleId) && (await fs.pathExists(customInfo.sourcePath))) { - customModuleIds.add(moduleId); - } - } - } - if (finalCustomContent && finalCustomContent.cachedModules) { - for (const cachedModule of finalCustomContent.cachedModules) { - customModuleIds.add(cachedModule.id); - } - } - if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { - const customHandler = new CustomHandler(); - for (const customFile of finalCustomContent.selectedFiles) { - const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot); - if (customInfo && customInfo.id) { - customModuleIds.add(customInfo.id); - } - } - } - - const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m)); - - const allModules = [...officialModuleIds]; - for (const id of customModuleIds) { - if (!allModules.includes(id)) { - allModules.push(id); - } - } - - return { officialModuleIds, allModules }; - } - /** * Install modules, create directories, generate configs and manifests. */ @@ -263,7 +220,7 @@ class Installer { installedModuleNames, }); - await this._installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, { + await this._installCustomModules(config, paths, addResult, officialModules, { message, installedModuleNames, }); @@ -626,88 +583,27 @@ class Installer { } /** - * Install custom modules from all custom module sources. - * @param {Object} config - Installation configuration - * @param {Object} paths - InstallPaths instance - * @param {Object|undefined} finalCustomContent - Custom content from config - * @param {Function} addResult - Callback to record installation results - * @param {boolean} isQuickUpdate - Whether this is a quick update - * @param {Object} ctx - Shared context: { message, installedModuleNames } + * Install custom modules using CustomModules.install(). + * Source paths come from this.customModules.paths (populated by discoverPaths). */ - async _installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, ctx) { + async _installCustomModules(config, paths, addResult, officialModules, ctx) { const { message, installedModuleNames } = ctx; + const isQuickUpdate = config.isQuickUpdate(); - // Collect all custom module IDs with their info from all sources - const customModules = new Map(); - - // First: cached modules from finalCustomContent - if (finalCustomContent && finalCustomContent.cachedModules) { - for (const cachedModule of finalCustomContent.cachedModules) { - if (!customModules.has(cachedModule.id)) { - customModules.set(cachedModule.id, { id: cachedModule.id, path: cachedModule.cachePath, config: {} }); - } - } - } - - // Second: custom module sources from manifest (for quick update) - if (customConfig._customModuleSources) { - for (const [moduleId, customInfo] of customConfig._customModuleSources) { - if (!customModules.has(moduleId)) { - const info = { ...customInfo }; - if (info.sourcePath && !info.path) { - info.path = path.isAbsolute(info.sourcePath) ? info.sourcePath : path.join(paths.bmadDir, info.sourcePath); - } - customModules.set(moduleId, info); - } - } - } - - // Third: regular custom content from user input (non-cached) - if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { - const customHandler = new CustomHandler(); - for (const customFile of finalCustomContent.selectedFiles) { - const info = await customHandler.getCustomInfo(customFile, paths.projectRoot); - if (info && info.id && !customModules.has(info.id)) { - customModules.set(info.id, info); - } - } - } - - // Fourth: any remaining custom modules not yet covered - for (const [moduleId, modulePath] of this.customModules.paths) { - if (!customModules.has(moduleId)) { - customModules.set(moduleId, { id: moduleId, path: modulePath, config: {} }); - } - } - - for (const [moduleName, customInfo] of customModules) { + for (const [moduleName, sourcePath] of this.customModules.paths) { if (installedModuleNames.has(moduleName)) continue; installedModuleNames.add(moduleName); message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); - if (!this.customModules.paths.has(moduleName) && customInfo.path) { - this.customModules.paths.set(moduleName, customInfo.path); - } - const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {}; - await officialModules.install( - moduleName, - paths.bmadDir, - (filePath) => { - this.installedFiles.add(filePath); - }, - { - isCustom: true, - moduleConfig: collectedModuleConfig, - isQuickUpdate: isQuickUpdate, - installer: this, - silent: true, - sourcePath: customInfo.path, - }, - ); + const result = await this.customModules.install(moduleName, paths.bmadDir, (filePath) => this.installedFiles.add(filePath), { + moduleConfig: collectedModuleConfig, + }); + + // Generate runtime config.yaml with merged values await this.generateModuleConfigs(paths.bmadDir, { - [moduleName]: { ...customConfig.coreConfig, ...customInfo.config, ...collectedModuleConfig }, + [moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig }, }); addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); diff --git a/tools/cli/installers/lib/modules/custom-modules.js b/tools/cli/installers/lib/modules/custom-modules.js index 8e9b2e876..68144aa7e 100644 --- a/tools/cli/installers/lib/modules/custom-modules.js +++ b/tools/cli/installers/lib/modules/custom-modules.js @@ -1,5 +1,9 @@ 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('../../../lib/prompts'); class CustomModules { constructor() { @@ -18,6 +22,126 @@ class CustomModules { 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 diff --git a/tools/cli/installers/lib/modules/official-modules.js b/tools/cli/installers/lib/modules/official-modules.js index cdc97aa2d..cda2f6149 100644 --- a/tools/cli/installers/lib/modules/official-modules.js +++ b/tools/cli/installers/lib/modules/official-modules.js @@ -231,52 +231,25 @@ class OfficialModules { * @param {Object} options.logger - Logger instance for output */ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { - const sourcePath = options.sourcePath || (await this.findModuleSource(moduleName, { silent: options.silent })); + const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); const targetPath = path.join(bmadDir, moduleName); - // Check if source module exists if (!sourcePath) { - // Provide a more user-friendly error message throw new Error( `Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`, ); } - // Check if this is a custom module and read its custom.yaml values - let customConfig = null; - const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml'); - - if (await fs.pathExists(rootCustomConfigPath)) { - try { - const customContent = await fs.readFile(rootCustomConfigPath, 'utf8'); - customConfig = yaml.parse(customContent); - } catch (error) { - await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); - } - } - - // If this is a custom module, merge its values into the module config - if (customConfig) { - options.moduleConfig = { ...options.moduleConfig, ...customConfig }; - if (options.logger) { - await options.logger.log(` Merged custom configuration for ${moduleName}`); - } - } - - // Check if already installed if (await fs.pathExists(targetPath)) { await fs.remove(targetPath); } - // Copy module files with filtering await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); - // Create directories declared in module.yaml (unless explicitly skipped) if (!options.skipModuleInstaller) { await this.createModuleDirectories(moduleName, bmadDir, options); } - // Capture version info for manifest const { Manifest } = require('../core/manifest'); const manifestObj = new Manifest(); const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath); @@ -288,12 +261,7 @@ class OfficialModules { repoUrl: versionInfo.repoUrl, }); - return { - success: true, - module: moduleName, - path: targetPath, - versionInfo, - }; + return { success: true, module: moduleName, path: targetPath, versionInfo }; } /**