refactor(installer): split ModuleManager into OfficialModules and CustomModules

OfficialModules handles source resolution (core, bmm, external),
module install/update/remove, directory creation, and file copying.
CustomModules holds the custom module paths Map. findModuleSource
now knows core explicitly. install() accepts optional sourcePath
for custom modules that already know their source.
This commit is contained in:
Alex Verkhovsky 2026-03-21 04:35:47 -06:00
parent 2a9df6377e
commit ea8c076e29
5 changed files with 83 additions and 62 deletions

View File

@ -10,19 +10,19 @@ class ConfigCollector {
this.collectedConfig = {}; this.collectedConfig = {};
this.existingConfig = null; this.existingConfig = null;
this.currentProjectDir = null; this.currentProjectDir = null;
this._moduleManagerInstance = null; this._officialModulesInstance = null;
} }
/** /**
* Get or create a cached ModuleManager instance (lazy initialization) * Get or create a cached OfficialModules instance (lazy initialization)
* @returns {Object} ModuleManager instance * @returns {Object} OfficialModules instance
*/ */
_getModuleManager() { _getOfficialModules() {
if (!this._moduleManagerInstance) { if (!this._officialModulesInstance) {
const { ModuleManager } = require('../modules/manager'); const { OfficialModules } = require('../modules/official-modules');
this._moduleManagerInstance = new ModuleManager(); this._officialModulesInstance = new OfficialModules();
} }
return this._moduleManagerInstance; return this._officialModulesInstance;
} }
/** /**
@ -153,7 +153,7 @@ class ConfigCollector {
const results = []; const results = [];
for (const moduleName of modules) { for (const moduleName of modules) {
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search // Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search
let moduleConfigPath = null; let moduleConfigPath = null;
const customPath = this.customModulePaths?.get(moduleName); const customPath = this.customModulePaths?.get(moduleName);
if (customPath) { if (customPath) {
@ -163,7 +163,7 @@ class ConfigCollector {
if (await fs.pathExists(standardPath)) { if (await fs.pathExists(standardPath)) {
moduleConfigPath = standardPath; moduleConfigPath = standardPath;
} else { } else {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
} }
@ -364,7 +364,7 @@ class ConfigCollector {
// If not found in src/modules, we need to find it by searching the project // If not found in src/modules, we need to find it by searching the project
if (!(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -378,7 +378,7 @@ class ConfigCollector {
configPath = moduleConfigPath; configPath = moduleConfigPath;
} else { } else {
// Check if this is a custom module with custom.yaml // Check if this is a custom module with custom.yaml
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
@ -674,7 +674,7 @@ class ConfigCollector {
// If not found in src/modules or custom paths, search the project // If not found in src/modules or custom paths, search the project
if (!(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');

View File

@ -2,7 +2,8 @@ const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { Detector } = require('./detector'); const { Detector } = require('./detector');
const { Manifest } = require('./manifest'); const { Manifest } = require('./manifest');
const { ModuleManager } = require('../modules/manager'); const { OfficialModules } = require('../modules/official-modules');
const { CustomModules } = require('../modules/custom-modules');
const { IdeManager } = require('../ide/manager'); const { IdeManager } = require('../ide/manager');
const { FileOps } = require('../../../lib/file-ops'); const { FileOps } = require('../../../lib/file-ops');
const { Config } = require('../../../lib/config'); const { Config } = require('../../../lib/config');
@ -22,7 +23,8 @@ class Installer {
this.externalModuleManager = new ExternalModuleManager(); this.externalModuleManager = new ExternalModuleManager();
this.detector = new Detector(); this.detector = new Detector();
this.manifest = new Manifest(); this.manifest = new Manifest();
this.moduleManager = new ModuleManager(); this.officialModules = new OfficialModules();
this.customModules = new CustomModules();
this.ideManager = new IdeManager(); this.ideManager = new IdeManager();
this.fileOps = new FileOps(); this.fileOps = new FileOps();
this.config = new Config(); this.config = new Config();
@ -60,7 +62,7 @@ class Installer {
const customModulePaths = await this._discoverCustomModulePaths(config, paths); const customModulePaths = await this._discoverCustomModulePaths(config, paths);
// Wire configs into managers // Wire configs into managers
this.moduleManager.setCustomModulePaths(customModulePaths); this.customModules.setPaths(customModulePaths);
this.ideManager.setBmadFolderName(BMAD_FOLDER_NAME); this.ideManager.setBmadFolderName(BMAD_FOLDER_NAME);
// Tool selection will be collected after we determine if it's a reinstall/update/new install // Tool selection will be collected after we determine if it's a reinstall/update/new install
@ -220,7 +222,7 @@ class Installer {
} }
// Update module manager with the new custom module paths from cache // Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths); this.customModules.setPaths(customModulePaths);
} }
// If there are custom files, back them up temporarily // If there are custom files, back them up temporarily
@ -304,7 +306,7 @@ class Installer {
} }
// Update module manager with the new custom module paths from cache // Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths); this.customModules.setPaths(customModulePaths);
} }
// Back up custom files // Back up custom files
@ -494,18 +496,14 @@ class Installer {
} }
// Update module manager with the cached paths // Update module manager with the cached paths
this.moduleManager.setCustomModulePaths(customModulePaths); this.customModules.setPaths(customModulePaths);
addResult('Custom modules cached', 'ok'); addResult('Custom modules cached', 'ok');
} }
// Custom content is already handled in UI before module selection // Custom content is already handled in UI before module selection
const finalCustomContent = config.customContent; const finalCustomContent = config.customContent;
// Official modules to install (filter out core — handled separately by installCore) // Build custom module ID set first (needed to filter official list)
const officialModules = config.installCore ? (config.modules || []).filter((m) => m !== 'core') : [...(config.modules || [])];
// Build combined list for manifest generation and IDE setup
const allModules = [...officialModules];
const customModuleIds = new Set(); const customModuleIds = new Set();
for (const id of customModulePaths.keys()) { for (const id of customModulePaths.keys()) {
customModuleIds.add(id); customModuleIds.add(id);
@ -531,6 +529,11 @@ class Installer {
} }
} }
} }
// Official modules: from config.modules, excluding core (handled separately) and custom modules
const officialModules = (config.modules || []).filter((m) => !(config.installCore && m === 'core') && !customModuleIds.has(m));
// Combined list for manifest generation and IDE setup
const allModules = [...officialModules];
for (const id of customModuleIds) { for (const id of customModuleIds) {
if (!allModules.includes(id)) { if (!allModules.includes(id)) {
allModules.push(id); allModules.push(id);
@ -608,7 +611,7 @@ class Installer {
// Core module directories // Core module directories
if (config.installCore) { if (config.installCore) {
const result = await this.moduleManager.createModuleDirectories('core', paths.bmadDir, { const result = await this.officialModules.createModuleDirectories('core', paths.bmadDir, {
installedIDEs: config.ides || [], installedIDEs: config.ides || [],
moduleConfig: moduleConfigs.core || {}, moduleConfig: moduleConfigs.core || {},
existingModuleConfig: this.configCollector.existingConfig?.core || {}, existingModuleConfig: this.configCollector.existingConfig?.core || {},
@ -627,7 +630,7 @@ class Installer {
if (config.modules && config.modules.length > 0) { if (config.modules && config.modules.length > 0) {
for (const moduleName of config.modules) { for (const moduleName of config.modules) {
message(`Setting up ${moduleName}...`); message(`Setting up ${moduleName}...`);
const result = await this.moduleManager.createModuleDirectories(moduleName, paths.bmadDir, { const result = await this.officialModules.createModuleDirectories(moduleName, paths.bmadDir, {
installedIDEs: config.ides || [], installedIDEs: config.ides || [],
moduleConfig: moduleConfigs[moduleName] || {}, moduleConfig: moduleConfigs[moduleName] || {},
existingModuleConfig: this.configCollector.existingConfig?.[moduleName] || {}, existingModuleConfig: this.configCollector.existingConfig?.[moduleName] || {},
@ -1012,7 +1015,7 @@ class Installer {
await this.installCore(paths.bmadDir); await this.installCore(paths.bmadDir);
} else { } else {
const moduleConfig = this.configCollector.collectedConfig[moduleName] || {}; const moduleConfig = this.configCollector.collectedConfig[moduleName] || {};
await this.moduleManager.install( await this.officialModules.install(
moduleName, moduleName,
paths.bmadDir, paths.bmadDir,
(filePath) => { (filePath) => {
@ -1096,11 +1099,11 @@ class Installer {
if (!customModulePaths.has(moduleName) && customInfo.path) { if (!customModulePaths.has(moduleName) && customInfo.path) {
customModulePaths.set(moduleName, customInfo.path); customModulePaths.set(moduleName, customInfo.path);
this.moduleManager.setCustomModulePaths(customModulePaths); this.customModules.setPaths(customModulePaths);
} }
const collectedModuleConfig = moduleConfigs[moduleName] || {}; const collectedModuleConfig = moduleConfigs[moduleName] || {};
await this.moduleManager.install( await this.officialModules.install(
moduleName, moduleName,
paths.bmadDir, paths.bmadDir,
(filePath) => { (filePath) => {
@ -1112,6 +1115,7 @@ class Installer {
isQuickUpdate: isQuickUpdate, isQuickUpdate: isQuickUpdate,
installer: this, installer: this,
silent: true, silent: true,
sourcePath: customInfo.path,
}, },
); );
await this.generateModuleConfigs(paths.bmadDir, { await this.generateModuleConfigs(paths.bmadDir, {
@ -1860,7 +1864,7 @@ class Installer {
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
// Get available modules (what we have source for) // Get available modules (what we have source for)
const availableModulesData = await this.moduleManager.listAvailable(); const availableModulesData = await this.officialModules.listAvailable();
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules];
// Add external official modules to available modules // Add external official modules to available modules
@ -2125,7 +2129,7 @@ class Installer {
for (const module of existingInstall.modules) { for (const module of existingInstall.modules) {
spinner.message(`Updating module: ${module.id}...`); spinner.message(`Updating module: ${module.id}...`);
await this.moduleManager.update(module.id, bmadDir, config.force, { installer: this }); await this.officialModules.update(module.id, bmadDir, config.force, { installer: this });
} }
// Update manifest // Update manifest
@ -2265,7 +2269,7 @@ class Installer {
* Get available modules * Get available modules
*/ */
async getAvailableModules() { async getAvailableModules() {
return await this.moduleManager.listAvailable(); return await this.officialModules.listAvailable();
} }
/** /**

View File

@ -0,0 +1,23 @@
class CustomModules {
constructor() {
this.paths = new Map();
}
setPaths(customModulePaths) {
this.paths = customModulePaths;
}
has(moduleCode) {
return this.paths.has(moduleCode);
}
get(moduleCode) {
return this.paths.get(moduleCode);
}
set(moduleId, sourcePath) {
this.paths.set(moduleId, sourcePath);
}
}
module.exports = { CustomModules };

View File

@ -5,18 +5,9 @@ const prompts = require('../../../lib/prompts');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { ExternalModuleManager } = require('./external-manager'); const { ExternalModuleManager } = require('./external-manager');
class ModuleManager { class OfficialModules {
constructor(options = {}) { constructor(options = {}) {
this.externalModuleManager = new ExternalModuleManager(); this.externalModuleManager = new ExternalModuleManager();
this.customModulePaths = new Map();
}
/**
* Set custom module paths for priority lookup
* @param {Map<string, string>} customModulePaths - Map of module ID to source path
*/
setCustomModulePaths(customModulePaths) {
this.customModulePaths = customModulePaths;
} }
/** /**
@ -25,7 +16,7 @@ class ModuleManager {
* @param {string} targetPath - Target file path * @param {string} targetPath - Target file path
* @param {boolean} overwrite - Whether to overwrite existing files (default: true) * @param {boolean} overwrite - Whether to overwrite existing files (default: true)
*/ */
async copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite = true) { async copyFile(sourcePath, targetPath, overwrite = true) {
await fs.copy(sourcePath, targetPath, { overwrite }); await fs.copy(sourcePath, targetPath, { overwrite });
} }
@ -35,7 +26,7 @@ class ModuleManager {
* @param {string} targetDir - Target directory path * @param {string} targetDir - Target directory path
* @param {boolean} overwrite - Whether to overwrite existing files (default: true) * @param {boolean} overwrite - Whether to overwrite existing files (default: true)
*/ */
async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir, overwrite = true) { async copyDirectory(sourceDir, targetDir, overwrite = true) {
await fs.ensureDir(targetDir); await fs.ensureDir(targetDir);
const entries = await fs.readdir(sourceDir, { withFileTypes: true }); const entries = await fs.readdir(sourceDir, { withFileTypes: true });
@ -44,9 +35,9 @@ class ModuleManager {
const targetPath = path.join(targetDir, entry.name); const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite); await this.copyDirectory(sourcePath, targetPath, overwrite);
} else { } else {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite); await this.copyFile(sourcePath, targetPath, overwrite);
} }
} }
} }
@ -143,9 +134,12 @@ class ModuleManager {
async findModuleSource(moduleCode, options = {}) { async findModuleSource(moduleCode, options = {}) {
const projectRoot = getProjectRoot(); const projectRoot = getProjectRoot();
// First check custom module paths if they exist // Check for core module (directly under src/core-skills)
if (this.customModulePaths && this.customModulePaths.has(moduleCode)) { if (moduleCode === 'core') {
return this.customModulePaths.get(moduleCode); const corePath = getSourcePath('core-skills');
if (await fs.pathExists(corePath)) {
return corePath;
}
} }
// Check for built-in bmm module (directly under src/bmm-skills) // Check for built-in bmm module (directly under src/bmm-skills)
@ -176,7 +170,7 @@ class ModuleManager {
* @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 = await this.findModuleSource(moduleName, { silent: options.silent }); const sourcePath = options.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 // Check if source module exists
@ -397,7 +391,7 @@ class ModuleManager {
} }
// Copy the file with placeholder replacement // Copy the file with placeholder replacement
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); await this.copyFile(sourceFile, targetFile);
// Track the file if callback provided // Track the file if callback provided
if (fileTrackingCallback) { if (fileTrackingCallback) {
@ -652,7 +646,7 @@ class ModuleManager {
} }
// Copy file with placeholder replacement // Copy file with placeholder replacement
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); await this.copyFile(sourceFile, targetFile);
} }
} }
@ -681,4 +675,4 @@ class ModuleManager {
} }
} }
module.exports = { ModuleManager }; module.exports = { OfficialModules };

View File

@ -935,9 +935,9 @@ class UI {
} }
// Add official modules // Add official modules
const { ModuleManager } = require('../installers/lib/modules/manager'); const { OfficialModules } = require('../installers/lib/modules/official-modules');
const moduleManager = new ModuleManager(); const officialModules = new OfficialModules();
const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable(); const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable();
// First, add all items to appropriate sections // First, add all items to appropriate sections
const allCustomModules = []; const allCustomModules = [];
@ -992,9 +992,9 @@ class UI {
* @returns {Array} Selected module codes (excluding core) * @returns {Array} Selected module codes (excluding core)
*/ */
async selectAllModules(installedModuleIds = new Set()) { async selectAllModules(installedModuleIds = new Set()) {
const { ModuleManager } = require('../installers/lib/modules/manager'); const { OfficialModules } = require('../installers/lib/modules/official-modules');
const moduleManager = new ModuleManager(); const officialModulesSource = new OfficialModules();
const { modules: localModules } = await moduleManager.listAvailable(); const { modules: localModules } = await officialModulesSource.listAvailable();
// Get external modules // Get external modules
const externalManager = new ExternalModuleManager(); const externalManager = new ExternalModuleManager();
@ -1089,9 +1089,9 @@ class UI {
* @returns {Array} Default module codes * @returns {Array} Default module codes
*/ */
async getDefaultModules(installedModuleIds = new Set()) { async getDefaultModules(installedModuleIds = new Set()) {
const { ModuleManager } = require('../installers/lib/modules/manager'); const { OfficialModules } = require('../installers/lib/modules/official-modules');
const moduleManager = new ModuleManager(); const officialModules = new OfficialModules();
const { modules: localModules } = await moduleManager.listAvailable(); const { modules: localModules } = await officialModules.listAvailable();
const defaultModules = []; const defaultModules = [];