Compare commits
No commits in common. "a567170501b0aa56dedf2c934a5619a6e6071286" and "eade619d173d70dca36ec558ad3244f9467d0b2a" have entirely different histories.
a567170501
...
eade619d17
|
|
@ -10,19 +10,19 @@ class ConfigCollector {
|
|||
this.collectedConfig = {};
|
||||
this.existingConfig = null;
|
||||
this.currentProjectDir = null;
|
||||
this._officialModulesInstance = null;
|
||||
this._moduleManagerInstance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached OfficialModules instance (lazy initialization)
|
||||
* @returns {Object} OfficialModules instance
|
||||
* Get or create a cached ModuleManager instance (lazy initialization)
|
||||
* @returns {Object} ModuleManager instance
|
||||
*/
|
||||
_getOfficialModules() {
|
||||
if (!this._officialModulesInstance) {
|
||||
const { OfficialModules } = require('../modules/official-modules');
|
||||
this._officialModulesInstance = new OfficialModules();
|
||||
_getModuleManager() {
|
||||
if (!this._moduleManagerInstance) {
|
||||
const { ModuleManager } = require('../modules/manager');
|
||||
this._moduleManagerInstance = new ModuleManager();
|
||||
}
|
||||
return this._officialModulesInstance;
|
||||
return this._moduleManagerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -153,7 +153,7 @@ class ConfigCollector {
|
|||
const results = [];
|
||||
|
||||
for (const moduleName of modules) {
|
||||
// Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search
|
||||
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search
|
||||
let moduleConfigPath = null;
|
||||
const customPath = this.customModulePaths?.get(moduleName);
|
||||
if (customPath) {
|
||||
|
|
@ -163,7 +163,7 @@ class ConfigCollector {
|
|||
if (await fs.pathExists(standardPath)) {
|
||||
moduleConfigPath = standardPath;
|
||||
} else {
|
||||
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||
if (moduleSourcePath) {
|
||||
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 (!(await fs.pathExists(moduleConfigPath))) {
|
||||
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||
|
||||
if (moduleSourcePath) {
|
||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||
|
|
@ -378,7 +378,7 @@ class ConfigCollector {
|
|||
configPath = moduleConfigPath;
|
||||
} else {
|
||||
// Check if this is a custom module with custom.yaml
|
||||
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||
|
||||
if (moduleSourcePath) {
|
||||
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 (!(await fs.pathExists(moduleConfigPath))) {
|
||||
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||
|
||||
if (moduleSourcePath) {
|
||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,92 +0,0 @@
|
|||
const path = require('node:path');
|
||||
const { CustomHandler } = require('../custom-handler');
|
||||
|
||||
class CustomModules {
|
||||
constructor() {
|
||||
this.paths = new Map();
|
||||
}
|
||||
|
||||
has(moduleCode) {
|
||||
return this.paths.has(moduleCode);
|
||||
}
|
||||
|
||||
get(moduleCode) {
|
||||
return this.paths.get(moduleCode);
|
||||
}
|
||||
|
||||
set(moduleId, sourcePath) {
|
||||
this.paths.set(moduleId, sourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover custom module source paths from all available sources.
|
||||
* @param {Object} config - Installation configuration
|
||||
* @param {Object} paths - InstallPaths instance
|
||||
* @returns {Map<string, string>} Map of module ID to source path
|
||||
*/
|
||||
async discoverPaths(config, paths) {
|
||||
this.paths = new Map();
|
||||
|
||||
if (config._quickUpdate) {
|
||||
if (config._customModuleSources) {
|
||||
for (const [moduleId, customInfo] of config._customModuleSources) {
|
||||
this.paths.set(moduleId, customInfo.sourcePath);
|
||||
}
|
||||
}
|
||||
return this.paths;
|
||||
}
|
||||
|
||||
// From manifest (regular updates)
|
||||
if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) {
|
||||
for (const customModule of config._existingInstall.customModules) {
|
||||
let absoluteSourcePath = customModule.sourcePath;
|
||||
|
||||
if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) {
|
||||
absoluteSourcePath = path.join(paths.bmadDir, absoluteSourcePath);
|
||||
} else if (!absoluteSourcePath && customModule.relativePath) {
|
||||
absoluteSourcePath = path.resolve(paths.projectRoot, customModule.relativePath);
|
||||
} else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
|
||||
absoluteSourcePath = path.resolve(absoluteSourcePath);
|
||||
}
|
||||
|
||||
if (absoluteSourcePath) {
|
||||
this.paths.set(customModule.id, absoluteSourcePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From UI: selectedFiles
|
||||
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
|
||||
const customHandler = new CustomHandler();
|
||||
for (const customFile of config.customContent.selectedFiles) {
|
||||
const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot);
|
||||
if (customInfo && customInfo.id) {
|
||||
this.paths.set(customInfo.id, customInfo.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From UI: sources
|
||||
if (config.customContent && config.customContent.sources) {
|
||||
for (const source of config.customContent.sources) {
|
||||
this.paths.set(source.id, source.path);
|
||||
}
|
||||
}
|
||||
|
||||
// From UI: cachedModules
|
||||
if (config.customContent && config.customContent.cachedModules) {
|
||||
const selectedCachedIds = config.customContent.selectedCachedModules || [];
|
||||
const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected;
|
||||
|
||||
for (const cachedModule of config.customContent.cachedModules) {
|
||||
if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) {
|
||||
this.paths.set(cachedModule.id, cachedModule.cachePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.paths;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CustomModules };
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
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');
|
||||
|
||||
|
|
@ -133,191 +131,6 @@ 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 };
|
||||
|
|
|
|||
|
|
@ -4,10 +4,51 @@ const yaml = require('yaml');
|
|||
const prompts = require('../../../lib/prompts');
|
||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
const { ExternalModuleManager } = require('./external-manager');
|
||||
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||
|
||||
class OfficialModules {
|
||||
/**
|
||||
* Manages the installation, updating, and removal of BMAD modules.
|
||||
* Handles module discovery, dependency resolution, and configuration processing.
|
||||
*
|
||||
* @class ModuleManager
|
||||
* @requires fs-extra
|
||||
* @requires yaml
|
||||
* @requires prompts
|
||||
*
|
||||
* @example
|
||||
* const manager = new ModuleManager();
|
||||
* const modules = await manager.listAvailable();
|
||||
* await manager.install('core-module', '/path/to/bmad');
|
||||
*/
|
||||
class ModuleManager {
|
||||
constructor(options = {}) {
|
||||
this.externalModuleManager = new ExternalModuleManager();
|
||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
||||
this.customModulePaths = new Map(); // Initialize custom module paths
|
||||
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bmad folder name for placeholder replacement
|
||||
* @param {string} bmadFolderName - The bmad folder name
|
||||
*/
|
||||
setBmadFolderName(bmadFolderName) {
|
||||
this.bmadFolderName = bmadFolderName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the core configuration for access during module installation
|
||||
* @param {Object} coreConfig - Core configuration object
|
||||
*/
|
||||
setCoreConfig(coreConfig) {
|
||||
this.coreConfig = coreConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom module paths for priority lookup
|
||||
* @param {Map<string, string>} customModulePaths - Map of module ID to source path
|
||||
*/
|
||||
setCustomModulePaths(customModulePaths) {
|
||||
this.customModulePaths = customModulePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +57,7 @@ class OfficialModules {
|
|||
* @param {string} targetPath - Target file path
|
||||
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
||||
*/
|
||||
async copyFile(sourcePath, targetPath, overwrite = true) {
|
||||
async copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite = true) {
|
||||
await fs.copy(sourcePath, targetPath, { overwrite });
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +67,7 @@ class OfficialModules {
|
|||
* @param {string} targetDir - Target directory path
|
||||
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
||||
*/
|
||||
async copyDirectory(sourceDir, targetDir, overwrite = true) {
|
||||
async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir, overwrite = true) {
|
||||
await fs.ensureDir(targetDir);
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||
|
||||
|
|
@ -35,15 +76,16 @@ class OfficialModules {
|
|||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.copyDirectory(sourcePath, targetPath, overwrite);
|
||||
await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
||||
} else {
|
||||
await this.copyFile(sourcePath, targetPath, overwrite);
|
||||
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available built-in modules (core and bmm).
|
||||
* List all available modules (excluding core which is always installed)
|
||||
* bmm is the only built-in module, directly under src/bmm-skills
|
||||
* All other modules come from external-official-modules.yaml
|
||||
* @returns {Object} Object with modules array and customModules array
|
||||
*/
|
||||
|
|
@ -51,15 +93,6 @@ class OfficialModules {
|
|||
const modules = [];
|
||||
const customModules = [];
|
||||
|
||||
// Add built-in core module (directly under src/core-skills)
|
||||
const corePath = getSourcePath('core-skills');
|
||||
if (await fs.pathExists(corePath)) {
|
||||
const coreInfo = await this.getModuleInfo(corePath, 'core', 'src/core-skills');
|
||||
if (coreInfo) {
|
||||
modules.push(coreInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Add built-in bmm module (directly under src/bmm-skills)
|
||||
const bmmPath = getSourcePath('bmm-skills');
|
||||
if (await fs.pathExists(bmmPath)) {
|
||||
|
|
@ -69,6 +102,25 @@ class OfficialModules {
|
|||
}
|
||||
}
|
||||
|
||||
// Check for cached custom modules in _config/custom/
|
||||
if (this.bmadDir) {
|
||||
const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
|
||||
if (await fs.pathExists(customCacheDir)) {
|
||||
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
|
||||
for (const entry of cacheEntries) {
|
||||
if (entry.isDirectory()) {
|
||||
const cachePath = path.join(customCacheDir, entry.name);
|
||||
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
|
||||
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
|
||||
moduleInfo.isCustom = true;
|
||||
moduleInfo.fromCache = true;
|
||||
customModules.push(moduleInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { modules, customModules };
|
||||
}
|
||||
|
||||
|
|
@ -142,12 +194,9 @@ class OfficialModules {
|
|||
async findModuleSource(moduleCode, options = {}) {
|
||||
const projectRoot = getProjectRoot();
|
||||
|
||||
// Check for core module (directly under src/core-skills)
|
||||
if (moduleCode === 'core') {
|
||||
const corePath = getSourcePath('core-skills');
|
||||
if (await fs.pathExists(corePath)) {
|
||||
return corePath;
|
||||
}
|
||||
// First check custom module paths if they exist
|
||||
if (this.customModulePaths && this.customModulePaths.has(moduleCode)) {
|
||||
return this.customModulePaths.get(moduleCode);
|
||||
}
|
||||
|
||||
// Check for built-in bmm module (directly under src/bmm-skills)
|
||||
|
|
@ -159,7 +208,7 @@ class OfficialModules {
|
|||
}
|
||||
|
||||
// Check external official modules
|
||||
const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
|
||||
const externalSource = await this.findExternalModuleSource(moduleCode, options);
|
||||
if (externalSource) {
|
||||
return externalSource;
|
||||
}
|
||||
|
|
@ -167,6 +216,199 @@ class OfficialModules {
|
|||
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)
|
||||
|
|
@ -178,7 +420,7 @@ 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
|
||||
|
|
@ -399,7 +641,7 @@ class OfficialModules {
|
|||
}
|
||||
|
||||
// Copy the file with placeholder replacement
|
||||
await this.copyFile(sourceFile, targetFile);
|
||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
||||
|
||||
// Track the file if callback provided
|
||||
if (fileTrackingCallback) {
|
||||
|
|
@ -654,7 +896,7 @@ class OfficialModules {
|
|||
}
|
||||
|
||||
// Copy file with placeholder replacement
|
||||
await this.copyFile(sourceFile, targetFile);
|
||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -683,4 +925,4 @@ class OfficialModules {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { OfficialModules };
|
||||
module.exports = { ModuleManager };
|
||||
|
|
@ -423,10 +423,8 @@ class UI {
|
|||
selectedModules.push(...customModuleResult.selectedCustomModules);
|
||||
}
|
||||
|
||||
// Ensure core is in the modules list
|
||||
if (!selectedModules.includes('core')) {
|
||||
selectedModules.unshift('core');
|
||||
}
|
||||
// Filter out core - it's always installed via installCore flag
|
||||
selectedModules = selectedModules.filter((m) => m !== 'core');
|
||||
|
||||
// Get tool selection
|
||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||
|
|
@ -436,6 +434,7 @@ class UI {
|
|||
return {
|
||||
actionType: 'update',
|
||||
directory: confirmedDirectory,
|
||||
installCore: true,
|
||||
modules: selectedModules,
|
||||
ides: toolSelection.ides,
|
||||
skipIde: toolSelection.skipIde,
|
||||
|
|
@ -544,16 +543,14 @@ class UI {
|
|||
selectedModules.push(...customContentConfig.selectedModuleIds);
|
||||
}
|
||||
|
||||
// Ensure core is in the modules list
|
||||
if (!selectedModules.includes('core')) {
|
||||
selectedModules.unshift('core');
|
||||
}
|
||||
selectedModules = selectedModules.filter((m) => m !== 'core');
|
||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options);
|
||||
|
||||
return {
|
||||
actionType: 'install',
|
||||
directory: confirmedDirectory,
|
||||
installCore: true,
|
||||
modules: selectedModules,
|
||||
ides: toolSelection.ides,
|
||||
skipIde: toolSelection.skipIde,
|
||||
|
|
@ -938,9 +935,9 @@ class UI {
|
|||
}
|
||||
|
||||
// Add official modules
|
||||
const { OfficialModules } = require('../installers/lib/modules/official-modules');
|
||||
const officialModules = new OfficialModules();
|
||||
const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable();
|
||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
||||
const moduleManager = new ModuleManager();
|
||||
const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable();
|
||||
|
||||
// First, add all items to appropriate sections
|
||||
const allCustomModules = [];
|
||||
|
|
@ -995,9 +992,9 @@ class UI {
|
|||
* @returns {Array} Selected module codes (excluding core)
|
||||
*/
|
||||
async selectAllModules(installedModuleIds = new Set()) {
|
||||
const { OfficialModules } = require('../installers/lib/modules/official-modules');
|
||||
const officialModulesSource = new OfficialModules();
|
||||
const { modules: localModules } = await officialModulesSource.listAvailable();
|
||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
||||
const moduleManager = new ModuleManager();
|
||||
const { modules: localModules } = await moduleManager.listAvailable();
|
||||
|
||||
// Get external modules
|
||||
const externalManager = new ExternalModuleManager();
|
||||
|
|
@ -1072,7 +1069,7 @@ class UI {
|
|||
maxItems: allOptions.length,
|
||||
});
|
||||
|
||||
const result = selected ? [...selected] : [];
|
||||
const result = selected ? selected.filter((m) => m !== 'core') : [];
|
||||
|
||||
// Display selected modules as bulleted list
|
||||
if (result.length > 0) {
|
||||
|
|
@ -1092,9 +1089,9 @@ class UI {
|
|||
* @returns {Array} Default module codes
|
||||
*/
|
||||
async getDefaultModules(installedModuleIds = new Set()) {
|
||||
const { OfficialModules } = require('../installers/lib/modules/official-modules');
|
||||
const officialModules = new OfficialModules();
|
||||
const { modules: localModules } = await officialModules.listAvailable();
|
||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
||||
const moduleManager = new ModuleManager();
|
||||
const { modules: localModules } = await moduleManager.listAvailable();
|
||||
|
||||
const defaultModules = [];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue