refactor(installer): separate custom and official module install paths

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.
This commit is contained in:
Alex Verkhovsky 2026-03-22 02:36:56 -06:00
parent c31e334dd8
commit b954d2ec81
3 changed files with 143 additions and 155 deletions

View File

@ -8,7 +8,6 @@ const { FileOps } = require('../../../lib/file-ops');
const { Config } = require('./config'); const { Config } = require('./config');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { ManifestGenerator } = require('./manifest-generator'); const { ManifestGenerator } = require('./manifest-generator');
const { CustomHandler } = require('../custom-handler');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths'); const { InstallPaths } = require('./install-paths');
@ -41,7 +40,6 @@ class Installer {
const config = Config.build(originalConfig); const config = Config.build(originalConfig);
const paths = await InstallPaths.create(config); const paths = await InstallPaths.create(config);
const officialModules = await OfficialModules.build(config, paths); const officialModules = await OfficialModules.build(config, paths);
const existingInstall = await ExistingInstall.detect(paths.bmadDir); const existingInstall = await ExistingInstall.detect(paths.bmadDir);
await this.customModules.discoverPaths(config, paths); await this.customModules.discoverPaths(config, paths);
@ -63,7 +61,11 @@ class Installer {
await this._cacheCustomModules(paths, addResult); 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._installAndConfigure(config, customConfig, paths, officialModuleIds, allModules, addResult, officialModules);
await this._setupIdes(config, allModules, paths, addResult); await this._setupIdes(config, allModules, paths, addResult);
@ -195,51 +197,6 @@ class Installer {
addResult('Custom modules cached', 'ok'); 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. * Install modules, create directories, generate configs and manifests.
*/ */
@ -263,7 +220,7 @@ class Installer {
installedModuleNames, installedModuleNames,
}); });
await this._installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, { await this._installCustomModules(config, paths, addResult, officialModules, {
message, message,
installedModuleNames, installedModuleNames,
}); });
@ -626,88 +583,27 @@ class Installer {
} }
/** /**
* Install custom modules from all custom module sources. * Install custom modules using CustomModules.install().
* @param {Object} config - Installation configuration * Source paths come from this.customModules.paths (populated by discoverPaths).
* @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 }
*/ */
async _installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, ctx) { async _installCustomModules(config, paths, addResult, officialModules, ctx) {
const { message, installedModuleNames } = ctx; const { message, installedModuleNames } = ctx;
const isQuickUpdate = config.isQuickUpdate();
// Collect all custom module IDs with their info from all sources for (const [moduleName, sourcePath] of this.customModules.paths) {
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) {
if (installedModuleNames.has(moduleName)) continue; if (installedModuleNames.has(moduleName)) continue;
installedModuleNames.add(moduleName); installedModuleNames.add(moduleName);
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${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] || {}; const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {};
await officialModules.install( const result = await this.customModules.install(moduleName, paths.bmadDir, (filePath) => this.installedFiles.add(filePath), {
moduleName, moduleConfig: collectedModuleConfig,
paths.bmadDir, });
(filePath) => {
this.installedFiles.add(filePath); // Generate runtime config.yaml with merged values
},
{
isCustom: true,
moduleConfig: collectedModuleConfig,
isQuickUpdate: isQuickUpdate,
installer: this,
silent: true,
sourcePath: customInfo.path,
},
);
await this.generateModuleConfigs(paths.bmadDir, { await this.generateModuleConfigs(paths.bmadDir, {
[moduleName]: { ...customConfig.coreConfig, ...customInfo.config, ...collectedModuleConfig }, [moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig },
}); });
addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed');

View File

@ -1,5 +1,9 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { CustomHandler } = require('../custom-handler'); const { CustomHandler } = require('../custom-handler');
const { Manifest } = require('../core/manifest');
const prompts = require('../../../lib/prompts');
class CustomModules { class CustomModules {
constructor() { constructor() {
@ -18,6 +22,126 @@ class CustomModules {
this.paths.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 (/<agent[^>]*\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. * Discover custom module source paths from all available sources.
* @param {Object} config - Installation configuration * @param {Object} config - Installation configuration

View File

@ -231,52 +231,25 @@ class OfficialModules {
* @param {Object} options.logger - Logger instance for output * @param {Object} options.logger - Logger instance for output
*/ */
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { 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); const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!sourcePath) { if (!sourcePath) {
// Provide a more user-friendly error message
throw new Error( throw new Error(
`Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`, `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)) { if (await fs.pathExists(targetPath)) {
await fs.remove(targetPath); await fs.remove(targetPath);
} }
// Copy module files with filtering
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
// Create directories declared in module.yaml (unless explicitly skipped)
if (!options.skipModuleInstaller) { if (!options.skipModuleInstaller) {
await this.createModuleDirectories(moduleName, bmadDir, options); await this.createModuleDirectories(moduleName, bmadDir, options);
} }
// Capture version info for manifest
const { Manifest } = require('../core/manifest'); const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest(); const manifestObj = new Manifest();
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath); const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
@ -288,12 +261,7 @@ class OfficialModules {
repoUrl: versionInfo.repoUrl, repoUrl: versionInfo.repoUrl,
}); });
return { return { success: true, module: moduleName, path: targetPath, versionInfo };
success: true,
module: moduleName,
path: targetPath,
versionInfo,
};
} }
/** /**