From ad2833caf641a30e69d93f800e3108369f49f444 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sat, 21 Mar 2026 04:09:35 -0600 Subject: [PATCH] refactor(installer): move external module ops into ExternalModuleManager Move cloneExternalModule, findExternalModuleSource, and getExternalCacheDir from ModuleManager into ExternalModuleManager where they belong. Replace this.moduleManager.isExternalModule() calls with direct ExternalModuleManager.hasModule(). Remove externalModuleManager instance from ModuleManager constructor. --- tools/cli/installers/lib/core/installer.js | 17 +- .../lib/modules/external-manager.js | 187 ++++++++++++++++ tools/cli/installers/lib/modules/manager.js | 206 +----------------- 3 files changed, 210 insertions(+), 200 deletions(-) diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 0bb25f6c3..b84e2dd65 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -16,6 +16,15 @@ const prompts = require('../../../lib/prompts'); const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { InstallPaths } = require('./install-paths'); +let _externalManager; +function getExternalManager() { + if (!_externalManager) { + const { ExternalModuleManager } = require('../modules/external-manager'); + _externalManager = new ExternalModuleManager(); + } + return _externalManager; +} + class Installer { constructor() { this.detector = new Detector(); @@ -204,7 +213,7 @@ class Installer { } // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); + const isExternal = await getExternalManager().hasModule(moduleId); if (isExternal) { // External modules are handled via cloneExternalModule, not from cache continue; @@ -288,7 +297,7 @@ class Installer { } // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); + const isExternal = await getExternalManager().hasModule(moduleId); if (isExternal) { // External modules are handled via cloneExternalModule, not from cache continue; @@ -1834,7 +1843,7 @@ class Installer { } // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); + const isExternal = await getExternalManager().hasModule(moduleId); if (isExternal) { // External modules are handled via cloneExternalModule, not from cache continue; @@ -2060,7 +2069,7 @@ class Installer { } // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); + const isExternal = await getExternalManager().hasModule(moduleId); if (isExternal) { // External modules are handled via cloneExternalModule, not from cache continue; diff --git a/tools/cli/installers/lib/modules/external-manager.js b/tools/cli/installers/lib/modules/external-manager.js index f1ea2206e..8fe0d5ae8 100644 --- a/tools/cli/installers/lib/modules/external-manager.js +++ b/tools/cli/installers/lib/modules/external-manager.js @@ -1,5 +1,7 @@ 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'); @@ -131,6 +133,191 @@ class ExternalModuleManager { 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 }; diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index a0e02e5b1..4bbc1e947 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -3,7 +3,15 @@ const fs = require('fs-extra'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); -const { ExternalModuleManager } = require('./external-manager'); + +let _externalManager; +function getExternalManager() { + if (!_externalManager) { + const { ExternalModuleManager } = require('./external-manager'); + _externalManager = new ExternalModuleManager(); + } + return _externalManager; +} /** * Manages the installation, updating, and removal of BMAD modules. @@ -22,7 +30,6 @@ const { ExternalModuleManager } = require('./external-manager'); class ModuleManager { constructor(options = {}) { this.customModulePaths = new Map(); - this.externalModuleManager = new ExternalModuleManager(); } /** @@ -171,7 +178,7 @@ class ModuleManager { } // Check external official modules - const externalSource = await this.findExternalModuleSource(moduleCode, options); + const externalSource = await getExternalManager().findExternalModuleSource(moduleCode, options); if (externalSource) { return externalSource; } @@ -179,199 +186,6 @@ class ModuleManager { return null; } - /** - * Check if a module is an external official module - * @param {string} moduleCode - Code of the module to check - * @returns {boolean} True if the module is external - */ - async isExternalModule(moduleCode) { - return await this.externalModuleManager.hasModule(moduleCode); - } - - /** - * Get the cache directory for external modules - * @returns {string} Path to the external modules cache directory - */ - getExternalCacheDir() { - const os = require('node:os'); - 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 - * @returns {string} Path to the cloned repository - */ - async cloneExternalModule(moduleCode, options = {}) { - const { execSync } = require('node:child_process'); - const moduleInfo = await this.externalModuleManager.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 - * @returns {string|null} Path to the module source or null if not found - */ - async findExternalModuleSource(moduleCode, options = {}) { - const moduleInfo = await this.externalModuleManager.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; - } - /** * Install a module * @param {string} moduleName - Code of the module to install (from module.yaml)