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:
parent
c31e334dd8
commit
b954d2ec81
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue