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.
This commit is contained in:
Alex Verkhovsky 2026-03-21 04:09:35 -06:00
parent 89812ec846
commit ad2833caf6
3 changed files with 210 additions and 200 deletions

View File

@ -16,6 +16,15 @@ 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');
let _externalManager;
function getExternalManager() {
if (!_externalManager) {
const { ExternalModuleManager } = require('../modules/external-manager');
_externalManager = new ExternalModuleManager();
}
return _externalManager;
}
class Installer { class Installer {
constructor() { constructor() {
this.detector = new Detector(); this.detector = new Detector();
@ -204,7 +213,7 @@ class Installer {
} }
// Check if this is an external official module - skip cache for those // 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) { if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache // External modules are handled via cloneExternalModule, not from cache
continue; continue;
@ -288,7 +297,7 @@ class Installer {
} }
// Check if this is an external official module - skip cache for those // 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) { if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache // External modules are handled via cloneExternalModule, not from cache
continue; continue;
@ -1834,7 +1843,7 @@ class Installer {
} }
// Check if this is an external official module - skip cache for those // 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) { if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache // External modules are handled via cloneExternalModule, not from cache
continue; continue;
@ -2060,7 +2069,7 @@ class Installer {
} }
// Check if this is an external official module - skip cache for those // 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) { if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache // External modules are handled via cloneExternalModule, not from cache
continue; continue;

View File

@ -1,5 +1,7 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { execSync } = require('node:child_process');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
@ -131,6 +133,191 @@ class ExternalModuleManager {
const module = await this.getModuleByCode(code); const module = await this.getModuleByCode(code);
return module ? module.moduleDefinition : null; 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 }; module.exports = { ExternalModuleManager };

View File

@ -3,7 +3,15 @@ const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
let _externalManager;
function getExternalManager() {
if (!_externalManager) {
const { ExternalModuleManager } = require('./external-manager'); const { ExternalModuleManager } = require('./external-manager');
_externalManager = new ExternalModuleManager();
}
return _externalManager;
}
/** /**
* Manages the installation, updating, and removal of BMAD modules. * Manages the installation, updating, and removal of BMAD modules.
@ -22,7 +30,6 @@ const { ExternalModuleManager } = require('./external-manager');
class ModuleManager { class ModuleManager {
constructor(options = {}) { constructor(options = {}) {
this.customModulePaths = new Map(); this.customModulePaths = new Map();
this.externalModuleManager = new ExternalModuleManager();
} }
/** /**
@ -171,7 +178,7 @@ class ModuleManager {
} }
// Check external official modules // Check external official modules
const externalSource = await this.findExternalModuleSource(moduleCode, options); const externalSource = await getExternalManager().findExternalModuleSource(moduleCode, options);
if (externalSource) { if (externalSource) {
return externalSource; return externalSource;
} }
@ -179,199 +186,6 @@ class ModuleManager {
return null; 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 * Install a module
* @param {string} moduleName - Code of the module to install (from module.yaml) * @param {string} moduleName - Code of the module to install (from module.yaml)