custom install module cached
This commit is contained in:
parent
6430173738
commit
738237b4ae
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Custom Module Source Cache
|
||||
* Caches custom module sources under _cfg/custom/ to ensure they're never lost
|
||||
* and can be checked into source control
|
||||
*/
|
||||
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
class CustomModuleCache {
|
||||
constructor(bmadDir) {
|
||||
this.bmadDir = bmadDir;
|
||||
this.customCacheDir = path.join(bmadDir, '_cfg', 'custom');
|
||||
this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the custom cache directory exists
|
||||
*/
|
||||
async ensureCacheDir() {
|
||||
await fs.ensureDir(this.customCacheDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache manifest
|
||||
*/
|
||||
async getCacheManifest() {
|
||||
if (!(await fs.pathExists(this.manifestPath))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = await fs.readFile(this.manifestPath, 'utf8');
|
||||
const yaml = require('js-yaml');
|
||||
return yaml.load(content) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cache manifest
|
||||
*/
|
||||
async updateCacheManifest(manifest) {
|
||||
const yaml = require('js-yaml');
|
||||
const content = yaml.dump(manifest, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
});
|
||||
|
||||
await fs.writeFile(this.manifestPath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hash of a file or directory
|
||||
*/
|
||||
async calculateHash(sourcePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
|
||||
const isDir = (await fs.stat(sourcePath)).isDirectory();
|
||||
|
||||
if (isDir) {
|
||||
// For directories, hash all files
|
||||
const files = [];
|
||||
async function collectFiles(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
files.push(path.join(dir, entry.name));
|
||||
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
await collectFiles(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await collectFiles(sourcePath);
|
||||
files.sort(); // Ensure consistent order
|
||||
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file);
|
||||
const relativePath = path.relative(sourcePath, file);
|
||||
hash.update(relativePath + '|' + content.toString('base64'));
|
||||
}
|
||||
} else {
|
||||
// For single files
|
||||
const content = await fs.readFile(sourcePath);
|
||||
hash.update(content);
|
||||
}
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a custom module source
|
||||
* @param {string} moduleId - Module ID
|
||||
* @param {string} sourcePath - Original source path
|
||||
* @param {Object} metadata - Additional metadata to store
|
||||
* @returns {Object} Cached module info
|
||||
*/
|
||||
async cacheModule(moduleId, sourcePath, metadata = {}) {
|
||||
await this.ensureCacheDir();
|
||||
|
||||
const cacheDir = path.join(this.customCacheDir, moduleId);
|
||||
const cacheManifest = await this.getCacheManifest();
|
||||
|
||||
// Check if already cached and unchanged
|
||||
if (cacheManifest[moduleId]) {
|
||||
const cached = cacheManifest[moduleId];
|
||||
if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) {
|
||||
// Source unchanged, return existing cache info
|
||||
return {
|
||||
moduleId,
|
||||
cachePath: cacheDir,
|
||||
...cached,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing cache if it exists
|
||||
if (await fs.pathExists(cacheDir)) {
|
||||
await fs.remove(cacheDir);
|
||||
}
|
||||
|
||||
// Copy module to cache
|
||||
await fs.copy(sourcePath, cacheDir, {
|
||||
filter: (src) => {
|
||||
const relative = path.relative(sourcePath, src);
|
||||
// Skip node_modules, .git, and other common ignore patterns
|
||||
return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store');
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate hash of the source
|
||||
const sourceHash = await this.calculateHash(sourcePath);
|
||||
const cacheHash = await this.calculateHash(cacheDir);
|
||||
|
||||
// Update manifest - don't store originalPath for source control friendliness
|
||||
cacheManifest[moduleId] = {
|
||||
originalHash: sourceHash,
|
||||
cacheHash: cacheHash,
|
||||
cachedAt: new Date().toISOString(),
|
||||
...metadata,
|
||||
};
|
||||
|
||||
await this.updateCacheManifest(cacheManifest);
|
||||
|
||||
return {
|
||||
moduleId,
|
||||
cachePath: cacheDir,
|
||||
...cacheManifest[moduleId],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached module info
|
||||
* @param {string} moduleId - Module ID
|
||||
* @returns {Object|null} Cached module info or null
|
||||
*/
|
||||
async getCachedModule(moduleId) {
|
||||
const cacheManifest = await this.getCacheManifest();
|
||||
const cached = cacheManifest[moduleId];
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheDir = path.join(this.customCacheDir, moduleId);
|
||||
|
||||
if (!(await fs.pathExists(cacheDir))) {
|
||||
// Cache dir missing, remove from manifest
|
||||
delete cacheManifest[moduleId];
|
||||
await this.updateCacheManifest(cacheManifest);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify cache integrity
|
||||
const currentCacheHash = await this.calculateHash(cacheDir);
|
||||
if (currentCacheHash !== cached.cacheHash) {
|
||||
console.warn(`Warning: Cache integrity check failed for ${moduleId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
moduleId,
|
||||
cachePath: cacheDir,
|
||||
...cached,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached modules
|
||||
* @returns {Array} Array of cached module info
|
||||
*/
|
||||
async getAllCachedModules() {
|
||||
const cacheManifest = await this.getCacheManifest();
|
||||
const cached = [];
|
||||
|
||||
for (const [moduleId, info] of Object.entries(cacheManifest)) {
|
||||
const cachedModule = await this.getCachedModule(moduleId);
|
||||
if (cachedModule) {
|
||||
cached.push(cachedModule);
|
||||
}
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a cached module
|
||||
* @param {string} moduleId - Module ID to remove
|
||||
*/
|
||||
async removeCachedModule(moduleId) {
|
||||
const cacheManifest = await this.getCacheManifest();
|
||||
const cacheDir = path.join(this.customCacheDir, moduleId);
|
||||
|
||||
// Remove cache directory
|
||||
if (await fs.pathExists(cacheDir)) {
|
||||
await fs.remove(cacheDir);
|
||||
}
|
||||
|
||||
// Remove from manifest
|
||||
delete cacheManifest[moduleId];
|
||||
await this.updateCacheManifest(cacheManifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync cached modules with a list of module IDs
|
||||
* @param {Array<string>} moduleIds - Module IDs to keep
|
||||
*/
|
||||
async syncCache(moduleIds) {
|
||||
const cached = await this.getAllCachedModules();
|
||||
|
||||
for (const cachedModule of cached) {
|
||||
if (!moduleIds.includes(cachedModule.moduleId)) {
|
||||
await this.removeCachedModule(cachedModule.moduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CustomModuleCache };
|
||||
|
|
@ -17,6 +17,7 @@ class Detector {
|
|||
hasCore: false,
|
||||
modules: [],
|
||||
ides: [],
|
||||
customModules: [],
|
||||
manifest: null,
|
||||
};
|
||||
|
||||
|
|
@ -32,6 +33,10 @@ class Detector {
|
|||
result.manifest = manifestData;
|
||||
result.version = manifestData.version;
|
||||
result.installed = true;
|
||||
// Copy custom modules if they exist
|
||||
if (manifestData.customModules) {
|
||||
result.customModules = manifestData.customModules;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for core
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const path = require('node:path');
|
|||
const fs = require('fs-extra');
|
||||
const chalk = require('chalk');
|
||||
const ora = require('ora');
|
||||
const inquirer = require('inquirer');
|
||||
const { Detector } = require('./detector');
|
||||
const { Manifest } = require('./manifest');
|
||||
const { ModuleManager } = require('../modules/manager');
|
||||
|
|
@ -750,15 +751,48 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
spinner.text = 'Creating directory structure...';
|
||||
await this.createDirectoryStructure(bmadDir);
|
||||
|
||||
// Resolve dependencies for selected modules
|
||||
spinner.text = 'Resolving dependencies...';
|
||||
// Get project root
|
||||
const projectRoot = getProjectRoot();
|
||||
|
||||
// Add custom content modules to the modules list for installation
|
||||
// Step 1: Install core module first (if requested)
|
||||
if (config.installCore) {
|
||||
spinner.start('Installing BMAD core...');
|
||||
await this.installCoreWithDependencies(bmadDir, { core: {} });
|
||||
spinner.succeed('Core installed');
|
||||
|
||||
// Generate core config file
|
||||
await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} });
|
||||
}
|
||||
|
||||
// Custom content is already handled in UI before module selection
|
||||
let finalCustomContent = config.customContent;
|
||||
|
||||
// Step 3: Prepare modules list including cached custom modules
|
||||
let allModules = [...(config.modules || [])];
|
||||
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
|
||||
|
||||
// During quick update, we might have custom module sources from the manifest
|
||||
if (config._customModuleSources) {
|
||||
// Add custom modules from stored sources
|
||||
for (const [moduleId, customInfo] of config._customModuleSources) {
|
||||
if (!allModules.includes(moduleId) && (await fs.pathExists(customInfo.sourcePath))) {
|
||||
allModules.push(moduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add cached custom modules
|
||||
if (finalCustomContent && finalCustomContent.cachedModules) {
|
||||
for (const cachedModule of finalCustomContent.cachedModules) {
|
||||
if (!allModules.includes(cachedModule.id)) {
|
||||
allModules.push(cachedModule.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular custom content from user input (non-cached)
|
||||
if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
|
||||
// Add custom modules to the installation list
|
||||
for (const customFile of config.customContent.selectedFiles) {
|
||||
for (const customFile of finalCustomContent.selectedFiles) {
|
||||
const { CustomHandler } = require('../custom/handler');
|
||||
const customHandler = new CustomHandler();
|
||||
const customInfo = await customHandler.getCustomInfo(customFile, projectDir);
|
||||
|
|
@ -768,12 +802,18 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
}
|
||||
}
|
||||
|
||||
const modulesToInstall = config.installCore ? ['core', ...allModules] : allModules;
|
||||
// Don't include core again if already installed
|
||||
if (config.installCore) {
|
||||
allModules = allModules.filter((m) => m !== 'core');
|
||||
}
|
||||
|
||||
const modulesToInstall = allModules;
|
||||
|
||||
// For dependency resolution, we need to pass the project root
|
||||
// Create a temporary module manager that knows about custom content locations
|
||||
const tempModuleManager = new ModuleManager({
|
||||
scanProjectForModules: true,
|
||||
bmadDir: bmadDir, // Pass bmadDir so we can check cache
|
||||
});
|
||||
|
||||
// Make sure custom modules are discoverable
|
||||
|
|
@ -793,12 +833,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
spinner.succeed('Dependencies resolved');
|
||||
}
|
||||
|
||||
// Install core if requested or if dependencies require it
|
||||
if (config.installCore || resolution.byModule.core) {
|
||||
spinner.start('Installing BMAD core...');
|
||||
await this.installCoreWithDependencies(bmadDir, resolution.byModule.core);
|
||||
spinner.succeed('Core installed');
|
||||
}
|
||||
// Core is already installed above, skip if included in resolution
|
||||
|
||||
// Install modules with their dependencies
|
||||
if (allModules && allModules.length > 0) {
|
||||
|
|
@ -816,10 +851,42 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
// Check if this is a custom module
|
||||
let isCustomModule = false;
|
||||
let customInfo = null;
|
||||
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
|
||||
let useCache = false;
|
||||
|
||||
// First check if we have a cached version
|
||||
if (finalCustomContent && finalCustomContent.cachedModules) {
|
||||
const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName);
|
||||
if (cachedModule) {
|
||||
isCustomModule = true;
|
||||
customInfo = {
|
||||
id: moduleName,
|
||||
path: cachedModule.cachePath,
|
||||
config: {},
|
||||
};
|
||||
useCache = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check if we have custom module sources from the manifest (for quick update)
|
||||
if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) {
|
||||
customInfo = config._customModuleSources.get(moduleName);
|
||||
isCustomModule = true;
|
||||
|
||||
// Check if this is a cached module (source path starts with _cfg)
|
||||
if (customInfo.sourcePath && (customInfo.sourcePath.startsWith('_cfg') || customInfo.sourcePath.includes('_cfg/custom'))) {
|
||||
useCache = true;
|
||||
// Make sure we have the right path structure
|
||||
if (!customInfo.path) {
|
||||
customInfo.path = customInfo.sourcePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally check regular custom content
|
||||
if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
|
||||
const { CustomHandler } = require('../custom/handler');
|
||||
const customHandler = new CustomHandler();
|
||||
for (const customFile of config.customContent.selectedFiles) {
|
||||
for (const customFile of finalCustomContent.selectedFiles) {
|
||||
const info = await customHandler.getCustomInfo(customFile, projectDir);
|
||||
if (info && info.id === moduleName) {
|
||||
isCustomModule = true;
|
||||
|
|
@ -874,10 +941,44 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
|
||||
// Create module config
|
||||
await this.generateModuleConfigs(bmadDir, { [moduleName]: { ...config.coreConfig, ...customInfo.config } });
|
||||
|
||||
// Store custom module info for later manifest update
|
||||
if (!config._customModulesToTrack) {
|
||||
config._customModulesToTrack = [];
|
||||
}
|
||||
|
||||
// For cached modules, use appropriate path handling
|
||||
let sourcePath;
|
||||
if (useCache) {
|
||||
// Check if we have cached modules info (from initial install)
|
||||
if (finalCustomContent && finalCustomContent.cachedModules) {
|
||||
sourcePath = finalCustomContent.cachedModules.find((m) => m.id === moduleName)?.relativePath;
|
||||
} else {
|
||||
// During update, the sourcePath is already cache-relative if it starts with _cfg
|
||||
sourcePath =
|
||||
customInfo.sourcePath && customInfo.sourcePath.startsWith('_cfg')
|
||||
? customInfo.sourcePath
|
||||
: path.relative(bmadDir, customInfo.path || customInfo.sourcePath);
|
||||
}
|
||||
} else {
|
||||
sourcePath = path.resolve(customInfo.path || customInfo.sourcePath);
|
||||
}
|
||||
|
||||
config._customModulesToTrack.push({
|
||||
id: customInfo.id,
|
||||
name: customInfo.name,
|
||||
sourcePath: sourcePath,
|
||||
installDate: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Regular module installation
|
||||
// Special case for core module
|
||||
if (moduleName === 'core') {
|
||||
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
|
||||
} else {
|
||||
await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed(`Module installed: ${moduleName}`);
|
||||
}
|
||||
|
|
@ -989,14 +1090,37 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
spinner.start('Generating workflow and agent manifests...');
|
||||
const manifestGen = new ManifestGenerator();
|
||||
|
||||
// Include preserved modules (from quick update) and custom modules in the manifest
|
||||
const allModulesToList = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules || [];
|
||||
// For quick update, we need ALL installed modules in the manifest
|
||||
// Not just the ones being updated
|
||||
const allModulesForManifest = config._quickUpdate
|
||||
? config._existingModules || allModules || []
|
||||
: config._preserveModules
|
||||
? [...allModules, ...config._preserveModules]
|
||||
: allModules || [];
|
||||
|
||||
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesToList, this.installedFiles, {
|
||||
// For regular installs (including when called from quick update), use what we have
|
||||
let modulesForCsvPreserve;
|
||||
if (config._quickUpdate) {
|
||||
// Quick update - use existing modules or fall back to modules being updated
|
||||
modulesForCsvPreserve = config._existingModules || allModules || [];
|
||||
} else {
|
||||
// Regular install - use the modules we're installing plus any preserved ones
|
||||
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
|
||||
}
|
||||
|
||||
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, this.installedFiles, {
|
||||
ides: config.ides || [],
|
||||
preservedModules: config._preserveModules || [], // Scan these from installed bmad/ dir
|
||||
preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir
|
||||
});
|
||||
|
||||
// Add custom modules to manifest (now that it exists)
|
||||
if (config._customModulesToTrack && config._customModulesToTrack.length > 0) {
|
||||
spinner.text = 'Storing custom module sources...';
|
||||
for (const customModule of config._customModulesToTrack) {
|
||||
await this.manifest.addCustomModule(bmadDir, customModule);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed(
|
||||
`Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`,
|
||||
);
|
||||
|
|
@ -1259,6 +1383,30 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
const currentVersion = existingInstall.version;
|
||||
const newVersion = require(path.join(getProjectRoot(), 'package.json')).version;
|
||||
|
||||
// Check for custom modules with missing sources before update
|
||||
const customModuleSources = new Map();
|
||||
if (existingInstall.customModules) {
|
||||
for (const customModule of existingInstall.customModules) {
|
||||
customModuleSources.set(customModule.id, customModule);
|
||||
}
|
||||
}
|
||||
|
||||
if (customModuleSources.size > 0) {
|
||||
spinner.stop();
|
||||
console.log(chalk.yellow('\nChecking custom module sources before update...'));
|
||||
|
||||
const projectRoot = getProjectRoot();
|
||||
await this.handleMissingCustomSources(
|
||||
customModuleSources,
|
||||
bmadDir,
|
||||
projectRoot,
|
||||
'update',
|
||||
existingInstall.modules.map((m) => m.id),
|
||||
);
|
||||
|
||||
spinner.start('Preparing update...');
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
spinner.stop();
|
||||
console.log(chalk.cyan('\n🔍 Update Preview (Dry Run)\n'));
|
||||
|
|
@ -2027,6 +2175,24 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
throw new Error(`BMAD not installed at ${bmadDir}`);
|
||||
}
|
||||
|
||||
// Check for custom modules with missing sources
|
||||
const manifest = await this.manifest.read(bmadDir);
|
||||
if (manifest && manifest.customModules && manifest.customModules.length > 0) {
|
||||
spinner.stop();
|
||||
console.log(chalk.yellow('\nChecking custom module sources before compilation...'));
|
||||
|
||||
const customModuleSources = new Map();
|
||||
for (const customModule of manifest.customModules) {
|
||||
customModuleSources.set(customModule.id, customModule);
|
||||
}
|
||||
|
||||
const projectRoot = getProjectRoot();
|
||||
const installedModules = manifest.modules || [];
|
||||
await this.handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, 'compile-agents', installedModules);
|
||||
|
||||
spinner.start('Rebuilding agent files...');
|
||||
}
|
||||
|
||||
let agentCount = 0;
|
||||
let taskCount = 0;
|
||||
|
||||
|
|
@ -2171,17 +2337,245 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
const existingInstall = await this.detector.detect(bmadDir);
|
||||
const installedModules = existingInstall.modules.map((m) => m.id);
|
||||
const configuredIdes = existingInstall.ides || [];
|
||||
const projectRoot = path.dirname(bmadDir);
|
||||
|
||||
// Get custom module sources from manifest
|
||||
const customModuleSources = new Map();
|
||||
if (existingInstall.customModules) {
|
||||
for (const customModule of existingInstall.customModules) {
|
||||
// Ensure we have an absolute sourcePath
|
||||
let absoluteSourcePath = customModule.sourcePath;
|
||||
|
||||
// Check if sourcePath is a cache-relative path (starts with _cfg/)
|
||||
if (absoluteSourcePath && absoluteSourcePath.startsWith('_cfg')) {
|
||||
// Convert cache-relative path to absolute path
|
||||
absoluteSourcePath = path.join(bmadDir, absoluteSourcePath);
|
||||
}
|
||||
// If no sourcePath but we have relativePath, convert it
|
||||
else if (!absoluteSourcePath && customModule.relativePath) {
|
||||
// relativePath is relative to the project root (parent of bmad dir)
|
||||
absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath);
|
||||
}
|
||||
// Ensure sourcePath is absolute for anything else
|
||||
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
|
||||
absoluteSourcePath = path.resolve(absoluteSourcePath);
|
||||
}
|
||||
|
||||
// Update the custom module object with the absolute path
|
||||
const updatedModule = {
|
||||
...customModule,
|
||||
sourcePath: absoluteSourcePath,
|
||||
};
|
||||
|
||||
customModuleSources.set(customModule.id, updatedModule);
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved IDE configurations
|
||||
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
|
||||
|
||||
// Get available modules (what we have source for)
|
||||
const availableModules = await this.moduleManager.listAvailable();
|
||||
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
||||
const availableModulesData = await this.moduleManager.listAvailable();
|
||||
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules];
|
||||
|
||||
// Add custom modules from manifest if their sources exist
|
||||
for (const [moduleId, customModule] of customModuleSources) {
|
||||
// Use the absolute sourcePath
|
||||
const sourcePath = customModule.sourcePath;
|
||||
|
||||
// Check if source exists at the recorded path
|
||||
if (
|
||||
sourcePath &&
|
||||
(await fs.pathExists(sourcePath)) && // Add to available modules if not already there
|
||||
!availableModules.some((m) => m.id === moduleId)
|
||||
) {
|
||||
availableModules.push({
|
||||
id: moduleId,
|
||||
name: customModule.name || moduleId,
|
||||
path: sourcePath,
|
||||
isCustom: true,
|
||||
fromManifest: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for untracked custom modules (installed but not in manifest)
|
||||
const untrackedCustomModules = [];
|
||||
for (const installedModule of installedModules) {
|
||||
// Skip standard modules and core
|
||||
const standardModuleIds = ['bmb', 'bmgd', 'bmm', 'cis', 'core'];
|
||||
if (standardModuleIds.includes(installedModule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this installed module is not tracked in customModules
|
||||
if (!customModuleSources.has(installedModule)) {
|
||||
const modulePath = path.join(bmadDir, installedModule);
|
||||
if (await fs.pathExists(modulePath)) {
|
||||
untrackedCustomModules.push({
|
||||
id: installedModule,
|
||||
name: installedModule, // We don't have the original name
|
||||
path: modulePath,
|
||||
untracked: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found untracked custom modules, offer to track them
|
||||
if (untrackedCustomModules.length > 0) {
|
||||
spinner.stop();
|
||||
console.log(chalk.yellow(`\n⚠️ Found ${untrackedCustomModules.length} custom module(s) not tracked in manifest:`));
|
||||
|
||||
for (const untracked of untrackedCustomModules) {
|
||||
console.log(chalk.dim(` • ${untracked.id} (installed at ${path.relative(projectRoot, untracked.path)})`));
|
||||
}
|
||||
|
||||
const { trackModules } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'trackModules',
|
||||
message: chalk.cyan('Would you like to scan for their source locations?'),
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (trackModules) {
|
||||
const { scanDirectory } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'scanDirectory',
|
||||
message: 'Enter directory to scan for custom module sources (or leave blank to skip):',
|
||||
default: projectRoot,
|
||||
validate: async (input) => {
|
||||
if (input && input.trim() !== '') {
|
||||
const expandedPath = path.resolve(input.trim());
|
||||
if (!(await fs.pathExists(expandedPath))) {
|
||||
return 'Directory does not exist';
|
||||
}
|
||||
const stats = await fs.stat(expandedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return 'Path must be a directory';
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (scanDirectory && scanDirectory.trim() !== '') {
|
||||
console.log(chalk.dim('\nScanning for custom module sources...'));
|
||||
|
||||
// Scan for all module.yaml files
|
||||
const allModulePaths = await this.moduleManager.findModulesInProject(scanDirectory);
|
||||
const { ModuleManager } = require('../modules/manager');
|
||||
const mm = new ModuleManager({ scanProjectForModules: true });
|
||||
|
||||
for (const untracked of untrackedCustomModules) {
|
||||
let foundSource = null;
|
||||
|
||||
// Try to find by module ID
|
||||
for (const modulePath of allModulePaths) {
|
||||
try {
|
||||
const moduleInfo = await mm.getModuleInfo(modulePath);
|
||||
if (moduleInfo && moduleInfo.id === untracked.id) {
|
||||
foundSource = {
|
||||
path: modulePath,
|
||||
info: moduleInfo,
|
||||
};
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Continue searching
|
||||
}
|
||||
}
|
||||
|
||||
if (foundSource) {
|
||||
console.log(chalk.green(` ✓ Found source for ${untracked.id}: ${path.relative(projectRoot, foundSource.path)}`));
|
||||
|
||||
// Add to manifest
|
||||
await this.manifest.addCustomModule(bmadDir, {
|
||||
id: untracked.id,
|
||||
name: foundSource.info.name || untracked.name,
|
||||
sourcePath: path.resolve(foundSource.path),
|
||||
installDate: new Date().toISOString(),
|
||||
tracked: true,
|
||||
});
|
||||
|
||||
// Add to customModuleSources for processing
|
||||
customModuleSources.set(untracked.id, {
|
||||
id: untracked.id,
|
||||
name: foundSource.info.name || untracked.name,
|
||||
sourcePath: path.resolve(foundSource.path),
|
||||
});
|
||||
} else {
|
||||
console.log(chalk.yellow(` ⚠ Could not find source for ${untracked.id}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim('\nUntracked custom modules will remain installed but cannot be updated without their source.'));
|
||||
spinner.start('Preparing update...');
|
||||
}
|
||||
|
||||
// Handle missing custom module sources using shared method
|
||||
const customModuleResult = await this.handleMissingCustomSources(
|
||||
customModuleSources,
|
||||
bmadDir,
|
||||
projectRoot,
|
||||
'update',
|
||||
installedModules,
|
||||
);
|
||||
|
||||
// Handle both old return format (array) and new format (object)
|
||||
let validCustomModules = [];
|
||||
let keptModulesWithoutSources = [];
|
||||
|
||||
if (Array.isArray(customModuleResult)) {
|
||||
// Old format - just an array
|
||||
validCustomModules = customModuleResult;
|
||||
} else if (customModuleResult && typeof customModuleResult === 'object') {
|
||||
// New format - object with two arrays
|
||||
validCustomModules = customModuleResult.validCustomModules || [];
|
||||
keptModulesWithoutSources = customModuleResult.keptModulesWithoutSources || [];
|
||||
}
|
||||
|
||||
const customModulesFromManifest = validCustomModules.map((m) => ({
|
||||
...m,
|
||||
isCustom: true,
|
||||
hasUpdate: true,
|
||||
}));
|
||||
|
||||
// Add untracked modules to the update list but mark them as untrackable
|
||||
for (const untracked of untrackedCustomModules) {
|
||||
if (!customModuleSources.has(untracked.id)) {
|
||||
customModulesFromManifest.push({
|
||||
...untracked,
|
||||
isCustom: true,
|
||||
hasUpdate: false, // Can't update without source
|
||||
untracked: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allAvailableModules = [...availableModules, ...customModulesFromManifest];
|
||||
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));
|
||||
|
||||
// Core module is special - never include it in update flow
|
||||
const nonCoreInstalledModules = installedModules.filter((id) => id !== 'core');
|
||||
|
||||
// Only update modules that are BOTH installed AND available (we have source for)
|
||||
const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id));
|
||||
const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id));
|
||||
const modulesToUpdate = nonCoreInstalledModules.filter((id) => availableModuleIds.has(id));
|
||||
const skippedModules = nonCoreInstalledModules.filter((id) => !availableModuleIds.has(id));
|
||||
|
||||
// Add custom modules that were kept without sources to the skipped modules
|
||||
// This ensures their agents are preserved in the manifest
|
||||
for (const keptModule of keptModulesWithoutSources) {
|
||||
if (!skippedModules.includes(keptModule)) {
|
||||
skippedModules.push(keptModule);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`);
|
||||
|
||||
|
|
@ -2246,6 +2640,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
_quickUpdate: true, // Flag to skip certain prompts
|
||||
_preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them
|
||||
_savedIdeConfigs: savedIdeConfigs, // Pass saved IDE configs to installer
|
||||
_customModuleSources: customModuleSources, // Pass custom module sources for updates
|
||||
_existingModules: installedModules, // Pass all installed modules for manifest generation
|
||||
};
|
||||
|
||||
// Call the standard install method
|
||||
|
|
@ -2885,6 +3281,230 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle missing custom module sources interactively
|
||||
* @param {Map} customModuleSources - Map of custom module ID to info
|
||||
* @param {string} bmadDir - BMAD directory
|
||||
* @param {string} projectRoot - Project root directory
|
||||
* @param {string} operation - Current operation ('update', 'compile', etc.)
|
||||
* @param {Array} installedModules - Array of installed module IDs (will be modified)
|
||||
* @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array
|
||||
*/
|
||||
async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules) {
|
||||
const validCustomModules = [];
|
||||
const keptModulesWithoutSources = []; // Track modules kept without sources
|
||||
const customModulesWithMissingSources = [];
|
||||
|
||||
// Check which sources exist
|
||||
for (const [moduleId, customInfo] of customModuleSources) {
|
||||
if (await fs.pathExists(customInfo.sourcePath)) {
|
||||
validCustomModules.push({
|
||||
id: moduleId,
|
||||
name: customInfo.name,
|
||||
path: customInfo.sourcePath,
|
||||
info: customInfo,
|
||||
});
|
||||
} else {
|
||||
customModulesWithMissingSources.push({
|
||||
id: moduleId,
|
||||
name: customInfo.name,
|
||||
sourcePath: customInfo.sourcePath,
|
||||
relativePath: customInfo.relativePath,
|
||||
info: customInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no missing sources, return immediately
|
||||
if (customModulesWithMissingSources.length === 0) {
|
||||
return validCustomModules;
|
||||
}
|
||||
|
||||
// Stop any spinner for interactive prompts
|
||||
const currentSpinner = ora();
|
||||
if (currentSpinner.isSpinning) {
|
||||
currentSpinner.stop();
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`\n⚠️ Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`));
|
||||
|
||||
const inquirer = require('inquirer');
|
||||
let keptCount = 0;
|
||||
let updatedCount = 0;
|
||||
let removedCount = 0;
|
||||
|
||||
for (const missing of customModulesWithMissingSources) {
|
||||
console.log(chalk.dim(` • ${missing.name} (${missing.id})`));
|
||||
console.log(chalk.dim(` Original source: ${missing.relativePath}`));
|
||||
console.log(chalk.dim(` Full path: ${missing.sourcePath}`));
|
||||
|
||||
const choices = [
|
||||
{
|
||||
name: 'Keep installed (will not be processed)',
|
||||
value: 'keep',
|
||||
short: 'Keep',
|
||||
},
|
||||
{
|
||||
name: 'Specify new source location',
|
||||
value: 'update',
|
||||
short: 'Update',
|
||||
},
|
||||
];
|
||||
|
||||
// Only add remove option if not just compiling agents
|
||||
if (operation !== 'compile-agents') {
|
||||
choices.push({
|
||||
name: '⚠️ REMOVE module completely (destructive!)',
|
||||
value: 'remove',
|
||||
short: 'Remove',
|
||||
});
|
||||
}
|
||||
|
||||
const { action } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'action',
|
||||
message: `How would you like to handle "${missing.name}"?`,
|
||||
choices,
|
||||
},
|
||||
]);
|
||||
|
||||
switch (action) {
|
||||
case 'update': {
|
||||
const { newSourcePath } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'newSourcePath',
|
||||
message: 'Enter the new path to the custom module:',
|
||||
default: missing.sourcePath,
|
||||
validate: async (input) => {
|
||||
if (!input || input.trim() === '') {
|
||||
return 'Please enter a path';
|
||||
}
|
||||
const expandedPath = path.resolve(input.trim());
|
||||
if (!(await fs.pathExists(expandedPath))) {
|
||||
return 'Path does not exist';
|
||||
}
|
||||
// Check if it looks like a valid module
|
||||
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
|
||||
const agentsPath = path.join(expandedPath, 'agents');
|
||||
const workflowsPath = path.join(expandedPath, 'workflows');
|
||||
|
||||
if (!(await fs.pathExists(moduleYamlPath)) && !(await fs.pathExists(agentsPath)) && !(await fs.pathExists(workflowsPath))) {
|
||||
return 'Path does not appear to contain a valid custom module';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Update the source in manifest
|
||||
const resolvedPath = path.resolve(newSourcePath.trim());
|
||||
missing.info.sourcePath = resolvedPath;
|
||||
// Remove relativePath - we only store absolute sourcePath now
|
||||
delete missing.info.relativePath;
|
||||
await this.manifest.addCustomModule(bmadDir, missing.info);
|
||||
|
||||
validCustomModules.push({
|
||||
id: moduleId,
|
||||
name: missing.name,
|
||||
path: resolvedPath,
|
||||
info: missing.info,
|
||||
});
|
||||
|
||||
updatedCount++;
|
||||
console.log(chalk.green(`✓ Updated source location`));
|
||||
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
// Extra confirmation for destructive remove
|
||||
console.log(chalk.red.bold(`\n⚠️ WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!`));
|
||||
console.log(chalk.red(` Module location: ${path.join(bmadDir, moduleId)}`));
|
||||
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: chalk.red.bold('Are you absolutely sure you want to delete this module?'),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirm) {
|
||||
const { typedConfirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'typedConfirm',
|
||||
message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'),
|
||||
validate: (input) => {
|
||||
if (input !== 'DELETE') {
|
||||
return chalk.red('You must type "DELETE" exactly to proceed');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (typedConfirm === 'DELETE') {
|
||||
// Remove the module from filesystem and manifest
|
||||
const modulePath = path.join(bmadDir, moduleId);
|
||||
if (await fs.pathExists(modulePath)) {
|
||||
const fsExtra = require('fs-extra');
|
||||
await fsExtra.remove(modulePath);
|
||||
console.log(chalk.yellow(` ✓ Deleted module directory: ${path.relative(projectRoot, modulePath)}`));
|
||||
}
|
||||
|
||||
await this.manifest.removeModule(bmadDir, moduleId);
|
||||
await this.manifest.removeCustomModule(bmadDir, moduleId);
|
||||
console.log(chalk.yellow(` ✓ Removed from manifest`));
|
||||
|
||||
// Also remove from installedModules list
|
||||
if (installedModules && installedModules.includes(moduleId)) {
|
||||
const index = installedModules.indexOf(moduleId);
|
||||
if (index !== -1) {
|
||||
installedModules.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
removedCount++;
|
||||
console.log(chalk.red.bold(`✓ "${missing.name}" has been permanently removed`));
|
||||
} else {
|
||||
console.log(chalk.dim(' Removal cancelled - module will be kept'));
|
||||
keptCount++;
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.dim(' Removal cancelled - module will be kept'));
|
||||
keptCount++;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'keep': {
|
||||
keptCount++;
|
||||
keptModulesWithoutSources.push(moduleId);
|
||||
console.log(chalk.dim(` Module will be kept as-is`));
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
|
||||
// Show summary
|
||||
if (keptCount > 0 || updatedCount > 0 || removedCount > 0) {
|
||||
console.log(chalk.dim(`\nSummary for custom modules with missing sources:`));
|
||||
if (keptCount > 0) console.log(chalk.dim(` • ${keptCount} module(s) kept as-is`));
|
||||
if (updatedCount > 0) console.log(chalk.dim(` • ${updatedCount} module(s) updated with new sources`));
|
||||
if (removedCount > 0) console.log(chalk.red(` • ${removedCount} module(s) permanently deleted`));
|
||||
}
|
||||
|
||||
return {
|
||||
validCustomModules,
|
||||
keptModulesWithoutSources,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Installer };
|
||||
|
|
|
|||
|
|
@ -41,7 +41,11 @@ class ManifestGenerator {
|
|||
// Deduplicate modules list to prevent duplicates
|
||||
this.modules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])];
|
||||
this.updatedModules = [...new Set(['core', ...selectedModules, ...installedModules])]; // All installed modules get rescanned
|
||||
this.preservedModules = preservedModules; // These stay as-is in CSVs
|
||||
|
||||
// For CSV manifests, we need to include ALL modules that are installed
|
||||
// preservedModules controls which modules stay as-is in the CSV (don't get rescanned)
|
||||
// But all modules should be included in the final manifest
|
||||
this.preservedModules = [...new Set([...preservedModules, ...selectedModules, ...installedModules])]; // Include all installed modules
|
||||
this.bmadDir = bmadDir;
|
||||
this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '.bmad' or 'bmad')
|
||||
this.allInstalledFiles = installedFiles;
|
||||
|
|
@ -61,14 +65,14 @@ class ManifestGenerator {
|
|||
// Collect workflow data
|
||||
await this.collectWorkflows(selectedModules);
|
||||
|
||||
// Collect agent data
|
||||
await this.collectAgents(selectedModules);
|
||||
// Collect agent data - use updatedModules which includes all installed modules
|
||||
await this.collectAgents(this.updatedModules);
|
||||
|
||||
// Collect task data
|
||||
await this.collectTasks(selectedModules);
|
||||
await this.collectTasks(this.updatedModules);
|
||||
|
||||
// Collect tool data
|
||||
await this.collectTools(selectedModules);
|
||||
await this.collectTools(this.updatedModules);
|
||||
|
||||
// Write manifest files and collect their paths
|
||||
const manifestFiles = [
|
||||
|
|
@ -450,6 +454,21 @@ class ManifestGenerator {
|
|||
async writeMainManifest(cfgDir) {
|
||||
const manifestPath = path.join(cfgDir, 'manifest.yaml');
|
||||
|
||||
// Read existing manifest to preserve custom modules
|
||||
let existingCustomModules = [];
|
||||
if (await fs.pathExists(manifestPath)) {
|
||||
try {
|
||||
const existingContent = await fs.readFile(manifestPath, 'utf8');
|
||||
const existingManifest = yaml.load(existingContent);
|
||||
if (existingManifest && existingManifest.customModules) {
|
||||
existingCustomModules = existingManifest.customModules;
|
||||
}
|
||||
} catch {
|
||||
// If we can't read the existing manifest, continue without preserving custom modules
|
||||
console.warn('Warning: Could not read existing manifest to preserve custom modules');
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
installation: {
|
||||
version: packageJson.version,
|
||||
|
|
@ -457,6 +476,7 @@ class ManifestGenerator {
|
|||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
modules: this.modules,
|
||||
customModules: existingCustomModules, // Preserve custom modules
|
||||
ides: this.selectedIdes,
|
||||
};
|
||||
|
||||
|
|
@ -562,12 +582,47 @@ class ManifestGenerator {
|
|||
async writeWorkflowManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
|
||||
|
||||
// Read existing manifest to preserve entries
|
||||
const existingEntries = new Map();
|
||||
if (await fs.pathExists(csvPath)) {
|
||||
const content = await fs.readFile(csvPath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
|
||||
// Skip header
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line) {
|
||||
// Parse CSV (simple parsing assuming no commas in quoted fields)
|
||||
const parts = line.split('","');
|
||||
if (parts.length >= 4) {
|
||||
const name = parts[0].replace(/^"/, '');
|
||||
const module = parts[2];
|
||||
existingEntries.set(`${module}:${name}`, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create CSV header - removed standalone column as ALL workflows now generate commands
|
||||
let csv = 'name,description,module,path\n';
|
||||
|
||||
// Add all workflows - no standalone property needed anymore
|
||||
// Combine existing and new workflows
|
||||
const allWorkflows = new Map();
|
||||
|
||||
// Add existing entries
|
||||
for (const [key, value] of existingEntries) {
|
||||
allWorkflows.set(key, value);
|
||||
}
|
||||
|
||||
// Add/update new workflows
|
||||
for (const workflow of this.workflows) {
|
||||
csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`;
|
||||
const key = `${workflow.module}:${workflow.name}`;
|
||||
allWorkflows.set(key, `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"`);
|
||||
}
|
||||
|
||||
// Write all workflows
|
||||
for (const [, value] of allWorkflows) {
|
||||
csv += value + '\n';
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
|
|
@ -581,12 +636,50 @@ class ManifestGenerator {
|
|||
async writeAgentManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
|
||||
|
||||
// Read existing manifest to preserve entries
|
||||
const existingEntries = new Map();
|
||||
if (await fs.pathExists(csvPath)) {
|
||||
const content = await fs.readFile(csvPath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
|
||||
// Skip header
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line) {
|
||||
// Parse CSV (simple parsing assuming no commas in quoted fields)
|
||||
const parts = line.split('","');
|
||||
if (parts.length >= 11) {
|
||||
const name = parts[0].replace(/^"/, '');
|
||||
const module = parts[8];
|
||||
existingEntries.set(`${module}:${name}`, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create CSV header with persona fields
|
||||
let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n';
|
||||
|
||||
// Add all agents
|
||||
// Combine existing and new agents, preferring new data for duplicates
|
||||
const allAgents = new Map();
|
||||
|
||||
// Add existing entries
|
||||
for (const [key, value] of existingEntries) {
|
||||
allAgents.set(key, value);
|
||||
}
|
||||
|
||||
// Add/update new agents
|
||||
for (const agent of this.agents) {
|
||||
csv += `"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"\n`;
|
||||
const key = `${agent.module}:${agent.name}`;
|
||||
allAgents.set(
|
||||
key,
|
||||
`"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Write all agents
|
||||
for (const [, value] of allAgents) {
|
||||
csv += value + '\n';
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
|
|
@ -600,12 +693,47 @@ class ManifestGenerator {
|
|||
async writeTaskManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'task-manifest.csv');
|
||||
|
||||
// Read existing manifest to preserve entries
|
||||
const existingEntries = new Map();
|
||||
if (await fs.pathExists(csvPath)) {
|
||||
const content = await fs.readFile(csvPath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
|
||||
// Skip header
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line) {
|
||||
// Parse CSV (simple parsing assuming no commas in quoted fields)
|
||||
const parts = line.split('","');
|
||||
if (parts.length >= 6) {
|
||||
const name = parts[0].replace(/^"/, '');
|
||||
const module = parts[3];
|
||||
existingEntries.set(`${module}:${name}`, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create CSV header with standalone column
|
||||
let csv = 'name,displayName,description,module,path,standalone\n';
|
||||
|
||||
// Add all tasks
|
||||
// Combine existing and new tasks
|
||||
const allTasks = new Map();
|
||||
|
||||
// Add existing entries
|
||||
for (const [key, value] of existingEntries) {
|
||||
allTasks.set(key, value);
|
||||
}
|
||||
|
||||
// Add/update new tasks
|
||||
for (const task of this.tasks) {
|
||||
csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"\n`;
|
||||
const key = `${task.module}:${task.name}`;
|
||||
allTasks.set(key, `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"`);
|
||||
}
|
||||
|
||||
// Write all tasks
|
||||
for (const [, value] of allTasks) {
|
||||
csv += value + '\n';
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
|
|
@ -619,12 +747,47 @@ class ManifestGenerator {
|
|||
async writeToolManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'tool-manifest.csv');
|
||||
|
||||
// Read existing manifest to preserve entries
|
||||
const existingEntries = new Map();
|
||||
if (await fs.pathExists(csvPath)) {
|
||||
const content = await fs.readFile(csvPath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
|
||||
// Skip header
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line) {
|
||||
// Parse CSV (simple parsing assuming no commas in quoted fields)
|
||||
const parts = line.split('","');
|
||||
if (parts.length >= 6) {
|
||||
const name = parts[0].replace(/^"/, '');
|
||||
const module = parts[3];
|
||||
existingEntries.set(`${module}:${name}`, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create CSV header with standalone column
|
||||
let csv = 'name,displayName,description,module,path,standalone\n';
|
||||
|
||||
// Add all tools
|
||||
// Combine existing and new tools
|
||||
const allTools = new Map();
|
||||
|
||||
// Add existing entries
|
||||
for (const [key, value] of existingEntries) {
|
||||
allTools.set(key, value);
|
||||
}
|
||||
|
||||
// Add/update new tools
|
||||
for (const tool of this.tools) {
|
||||
csv += `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"\n`;
|
||||
const key = `${tool.module}:${tool.name}`;
|
||||
allTools.set(key, `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"`);
|
||||
}
|
||||
|
||||
// Write all tools
|
||||
for (const [, value] of allTools) {
|
||||
csv += value + '\n';
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class Manifest {
|
|||
installDate: manifestData.installation?.installDate,
|
||||
lastUpdated: manifestData.installation?.lastUpdated,
|
||||
modules: manifestData.modules || [],
|
||||
customModules: manifestData.customModules || [],
|
||||
ides: manifestData.ides || [],
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -93,6 +94,7 @@ class Manifest {
|
|||
lastUpdated: manifest.lastUpdated,
|
||||
},
|
||||
modules: manifest.modules || [],
|
||||
customModules: manifest.customModules || [],
|
||||
ides: manifest.ides || [],
|
||||
};
|
||||
|
||||
|
|
@ -535,6 +537,51 @@ class Manifest {
|
|||
|
||||
return configs;
|
||||
}
|
||||
/**
|
||||
* Add a custom module to the manifest with its source path
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @param {Object} customModule - Custom module info
|
||||
*/
|
||||
async addCustomModule(bmadDir, customModule) {
|
||||
const manifest = await this.read(bmadDir);
|
||||
if (!manifest) {
|
||||
throw new Error('No manifest found');
|
||||
}
|
||||
|
||||
if (!manifest.customModules) {
|
||||
manifest.customModules = [];
|
||||
}
|
||||
|
||||
// Check if custom module already exists
|
||||
const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id);
|
||||
if (existingIndex === -1) {
|
||||
// Add new entry
|
||||
manifest.customModules.push(customModule);
|
||||
} else {
|
||||
// Update existing entry
|
||||
manifest.customModules[existingIndex] = customModule;
|
||||
}
|
||||
|
||||
await this.update(bmadDir, { customModules: manifest.customModules });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom module from the manifest
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @param {string} moduleId - Module ID to remove
|
||||
*/
|
||||
async removeCustomModule(bmadDir, moduleId) {
|
||||
const manifest = await this.read(bmadDir);
|
||||
if (!manifest || !manifest.customModules) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = manifest.customModules.findIndex((m) => m.id === moduleId);
|
||||
if (index !== -1) {
|
||||
manifest.customModules.splice(index, 1);
|
||||
await this.update(bmadDir, { customModules: manifest.customModules });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Manifest };
|
||||
|
|
|
|||
|
|
@ -240,6 +240,25 @@ class ModuleManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for cached custom modules in _cfg/custom/
|
||||
if (this.bmadDir) {
|
||||
const customCacheDir = path.join(this.bmadDir, '_cfg', '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, '_cfg/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 };
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const boxen = require('boxen');
|
|||
const wrapAnsi = require('wrap-ansi');
|
||||
const figlet = require('figlet');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const CLIUtils = {
|
||||
/**
|
||||
|
|
@ -205,6 +206,22 @@ const CLIUtils = {
|
|||
// No longer clear screen or show boxes - just a simple completion message
|
||||
// This is deprecated but kept for backwards compatibility
|
||||
},
|
||||
|
||||
/**
|
||||
* Expand path with ~ expansion
|
||||
* @param {string} inputPath - Path to expand
|
||||
* @returns {string} Expanded path
|
||||
*/
|
||||
expandPath(inputPath) {
|
||||
if (!inputPath) return inputPath;
|
||||
|
||||
// Expand ~ to home directory
|
||||
if (inputPath.startsWith('~')) {
|
||||
return path.join(os.homedir(), inputPath.slice(1));
|
||||
}
|
||||
|
||||
return inputPath;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { CLIUtils };
|
||||
|
|
|
|||
|
|
@ -59,11 +59,15 @@ class UI {
|
|||
const bmadDir = await installer.findBmadDir(confirmedDirectory);
|
||||
const hasExistingInstall = await fs.pathExists(bmadDir);
|
||||
|
||||
// Only ask for custom content if it's a NEW installation
|
||||
// Always ask for custom content, but we'll handle it differently for new installs
|
||||
let customContentConfig = { hasCustomContent: false };
|
||||
if (!hasExistingInstall) {
|
||||
// Prompt for custom content location (separate from installation directory)
|
||||
customContentConfig = await this.promptCustomContentLocation();
|
||||
if (hasExistingInstall) {
|
||||
// Existing installation - prompt to add/update custom content
|
||||
customContentConfig = await this.promptCustomContentForExisting();
|
||||
} else {
|
||||
// New installation - we'll prompt after creating the directory structure
|
||||
// For now, set a flag to indicate we should ask later
|
||||
customContentConfig._shouldAsk = true;
|
||||
}
|
||||
|
||||
// Track action type (only set if there's an existing installation)
|
||||
|
|
@ -126,6 +130,64 @@ class UI {
|
|||
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
|
||||
const coreConfig = await this.collectCoreConfig(confirmedDirectory);
|
||||
|
||||
// For new installations, create the directory structure first so we can cache custom content
|
||||
if (!hasExistingInstall && customContentConfig._shouldAsk) {
|
||||
// Create the bmad directory based on core config
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const bmadFolderName = coreConfig.bmad_folder || 'bmad';
|
||||
const bmadDir = path.join(confirmedDirectory, bmadFolderName);
|
||||
|
||||
await fs.ensureDir(bmadDir);
|
||||
await fs.ensureDir(path.join(bmadDir, '_cfg'));
|
||||
await fs.ensureDir(path.join(bmadDir, '_cfg', 'custom'));
|
||||
|
||||
// Now prompt for custom content
|
||||
customContentConfig = await this.promptCustomContentLocation();
|
||||
|
||||
// If custom content found, cache it
|
||||
if (customContentConfig.hasCustomContent) {
|
||||
const { CustomModuleCache } = require('../installers/lib/core/custom-module-cache');
|
||||
const cache = new CustomModuleCache(bmadDir);
|
||||
|
||||
const { CustomHandler } = require('../installers/lib/custom/handler');
|
||||
const customHandler = new CustomHandler();
|
||||
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
|
||||
|
||||
for (const customFile of customFiles) {
|
||||
const customInfo = await customHandler.getCustomInfo(customFile);
|
||||
if (customInfo && customInfo.id) {
|
||||
// Cache the module source
|
||||
await cache.cacheModule(customInfo.id, customInfo.path, {
|
||||
name: customInfo.name,
|
||||
type: 'custom',
|
||||
});
|
||||
|
||||
console.log(chalk.dim(` Cached ${customInfo.name} to _cfg/custom/${customInfo.id}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Update config to use cached modules
|
||||
customContentConfig.cachedModules = [];
|
||||
for (const customFile of customFiles) {
|
||||
const customInfo = await customHandler.getCustomInfo(customFile);
|
||||
if (customInfo && customInfo.id) {
|
||||
customContentConfig.cachedModules.push({
|
||||
id: customInfo.id,
|
||||
cachePath: path.join(bmadDir, '_cfg', 'custom', customInfo.id),
|
||||
// Store relative path from cache for the manifest
|
||||
relativePath: path.join('_cfg', 'custom', customInfo.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ Cached ${customFiles.length} custom module(s)`));
|
||||
}
|
||||
|
||||
// Clear the flag
|
||||
delete customContentConfig._shouldAsk;
|
||||
}
|
||||
|
||||
// Skip module selection during update/reinstall - keep existing modules
|
||||
let selectedModules;
|
||||
if (actionType === 'update' || actionType === 'reinstall') {
|
||||
|
|
@ -139,8 +201,20 @@ class UI {
|
|||
|
||||
// Check which custom content items were selected
|
||||
const selectedCustomContent = selectedModules.filter((mod) => mod.startsWith('__CUSTOM_CONTENT__'));
|
||||
if (selectedCustomContent.length > 0) {
|
||||
|
||||
// For cached modules (new installs), check if any cached modules were selected
|
||||
let selectedCachedModules = [];
|
||||
if (customContentConfig.cachedModules) {
|
||||
selectedCachedModules = selectedModules.filter(
|
||||
(mod) => !mod.startsWith('__CUSTOM_CONTENT__') && customContentConfig.cachedModules.some((cm) => cm.id === mod),
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedCustomContent.length > 0 || selectedCachedModules.length > 0) {
|
||||
customContentConfig.selected = true;
|
||||
|
||||
// Handle directory-based custom content (existing installs)
|
||||
if (selectedCustomContent.length > 0) {
|
||||
customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', ''));
|
||||
// Convert custom content to module IDs for installation
|
||||
const customContentModuleIds = [];
|
||||
|
|
@ -155,10 +229,18 @@ class UI {
|
|||
}
|
||||
// Filter out custom content markers and add module IDs
|
||||
selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds];
|
||||
}
|
||||
|
||||
// For cached modules, they're already module IDs, just mark as selected
|
||||
if (selectedCachedModules.length > 0) {
|
||||
customContentConfig.selectedCachedModules = selectedCachedModules;
|
||||
// No need to filter since they're already proper module IDs
|
||||
}
|
||||
} else if (customContentConfig.hasCustomContent) {
|
||||
// User provided custom content but didn't select any
|
||||
customContentConfig.selected = false;
|
||||
customContentConfig.selectedFiles = [];
|
||||
customContentConfig.selectedCachedModules = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,9 +610,30 @@ class UI {
|
|||
const customContentItems = [];
|
||||
const hasCustomContentItems = false;
|
||||
|
||||
// Add custom content items from directory
|
||||
if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) {
|
||||
// Get the custom content info to display proper names
|
||||
// Add custom content items
|
||||
if (customContentConfig && customContentConfig.hasCustomContent) {
|
||||
if (customContentConfig.cachedModules) {
|
||||
// New installation - show cached modules
|
||||
for (const cachedModule of customContentConfig.cachedModules) {
|
||||
// Get the module info from cache
|
||||
const yaml = require('js-yaml');
|
||||
const fs = require('fs-extra');
|
||||
const moduleYamlPath = path.join(cachedModule.cachePath, 'module.yaml');
|
||||
|
||||
if (await fs.pathExists(moduleYamlPath)) {
|
||||
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
||||
const moduleData = yaml.load(yamlContent);
|
||||
|
||||
customContentItems.push({
|
||||
name: `${chalk.cyan('✓')} ${moduleData.name || cachedModule.id} ${chalk.gray('(cached)')}`,
|
||||
value: cachedModule.id, // Use module ID directly
|
||||
checked: true, // Default to selected
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (customContentConfig.customPath) {
|
||||
// Existing installation - show from directory
|
||||
const { CustomHandler } = require('../installers/lib/custom/handler');
|
||||
const customHandler = new CustomHandler();
|
||||
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
|
||||
|
|
@ -547,12 +650,16 @@ class UI {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add official modules
|
||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
||||
// Only scan project for modules if user selected custom content
|
||||
// For new installations, don't scan project yet (will do after custom content is discovered)
|
||||
// For existing installations, scan if user selected custom content
|
||||
const shouldScanProject =
|
||||
!isNewInstallation && customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected;
|
||||
const moduleManager = new ModuleManager({
|
||||
scanProjectForModules: customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected,
|
||||
scanProjectForModules: shouldScanProject,
|
||||
});
|
||||
const { modules: availableModules, customModules: customModulesFromProject } = await moduleManager.listAvailable();
|
||||
|
||||
|
|
@ -1069,6 +1176,144 @@ class UI {
|
|||
|
||||
return (await fs.pathExists(hookPath)) && (await fs.pathExists(playTtsPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for custom content for existing installations
|
||||
* @returns {Object} Custom content configuration
|
||||
*/
|
||||
async promptCustomContentForExisting() {
|
||||
try {
|
||||
CLIUtils.displaySection('Custom Content', 'Add new custom agents, workflows, or modules to your installation');
|
||||
|
||||
const { hasCustomContent } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'hasCustomContent',
|
||||
message: 'Do you want to add or update custom content?',
|
||||
choices: [
|
||||
{
|
||||
name: 'No, continue with current installation only',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
name: 'Yes, I have custom content to add or update',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!hasCustomContent) {
|
||||
return { hasCustomContent: false };
|
||||
}
|
||||
|
||||
// Get directory path
|
||||
const { customPath } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customPath',
|
||||
message: 'Enter directory to search for custom content (will scan subfolders):',
|
||||
default: process.cwd(),
|
||||
validate: async (input) => {
|
||||
if (!input || input.trim() === '') {
|
||||
return 'Please enter a directory path';
|
||||
}
|
||||
|
||||
// Normalize and check if path exists
|
||||
const expandedPath = CLIUtils.expandPath(input.trim());
|
||||
const pathExists = await fs.pathExists(expandedPath);
|
||||
if (!pathExists) {
|
||||
return 'Directory does not exist';
|
||||
}
|
||||
|
||||
// Check if it's actually a directory
|
||||
const stats = await fs.stat(expandedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return 'Path must be a directory';
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
transformer: (input) => {
|
||||
return CLIUtils.expandPath(input);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const resolvedPath = CLIUtils.expandPath(customPath);
|
||||
|
||||
// Find custom content
|
||||
const { CustomHandler } = require('../installers/lib/custom/handler');
|
||||
const customHandler = new CustomHandler();
|
||||
const customFiles = await customHandler.findCustomContent(resolvedPath);
|
||||
|
||||
if (customFiles.length === 0) {
|
||||
console.log(chalk.yellow(`\nNo custom content found in ${resolvedPath}`));
|
||||
|
||||
const { tryDifferent } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'tryDifferent',
|
||||
message: 'Try a different directory?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (tryDifferent) {
|
||||
return await this.promptCustomContentForExisting();
|
||||
}
|
||||
|
||||
return { hasCustomContent: false };
|
||||
}
|
||||
|
||||
// Display found items
|
||||
console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`));
|
||||
const { CustomHandler: CustomHandler2 } = require('../installers/lib/custom/handler');
|
||||
const customHandler2 = new CustomHandler2();
|
||||
const customContentItems = [];
|
||||
|
||||
for (const customFile of customFiles) {
|
||||
const customInfo = await customHandler2.getCustomInfo(customFile);
|
||||
if (customInfo) {
|
||||
customContentItems.push({
|
||||
name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`,
|
||||
value: `__CUSTOM_CONTENT__${customFile}`,
|
||||
checked: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add option to keep existing custom content
|
||||
console.log(chalk.yellow('\nExisting custom modules will be preserved unless you remove them'));
|
||||
|
||||
const { selectedFiles } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedFiles',
|
||||
message: 'Select custom content to add:',
|
||||
choices: customContentItems,
|
||||
pageSize: 15,
|
||||
validate: (answer) => {
|
||||
if (answer.length === 0) {
|
||||
return 'You must select at least one item';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
hasCustomContent: true,
|
||||
customPath: resolvedPath,
|
||||
selected: true,
|
||||
selectedFiles: selectedFiles,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(chalk.red('Error configuring custom content:'), error);
|
||||
return { hasCustomContent: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UI };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Migration script to convert relative paths to absolute paths in custom module manifests
|
||||
* This should be run once to update existing installations
|
||||
*/
|
||||
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const yaml = require('yaml');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Find BMAD directory in project
|
||||
*/
|
||||
function findBmadDir(projectDir = process.cwd()) {
|
||||
const possibleNames = ['bmad', '.bmad'];
|
||||
|
||||
for (const name of possibleNames) {
|
||||
const bmadDir = path.join(projectDir, name);
|
||||
if (fs.existsSync(bmadDir)) {
|
||||
return bmadDir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update manifest to use absolute paths
|
||||
*/
|
||||
async function updateManifest(manifestPath, projectRoot) {
|
||||
console.log(chalk.cyan(`\nUpdating manifest: ${manifestPath}`));
|
||||
|
||||
const content = await fs.readFile(manifestPath, 'utf8');
|
||||
const manifest = yaml.parse(content);
|
||||
|
||||
if (!manifest.customModules || manifest.customModules.length === 0) {
|
||||
console.log(chalk.dim(' No custom modules found'));
|
||||
return false;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
|
||||
for (const customModule of manifest.customModules) {
|
||||
if (customModule.relativePath && !customModule.sourcePath) {
|
||||
// Convert relative path to absolute
|
||||
const absolutePath = path.resolve(projectRoot, customModule.relativePath);
|
||||
customModule.sourcePath = absolutePath;
|
||||
|
||||
// Remove the old relativePath
|
||||
delete customModule.relativePath;
|
||||
|
||||
console.log(chalk.green(` ✓ Updated ${customModule.id}: ${customModule.relativePath} → ${absolutePath}`));
|
||||
updated = true;
|
||||
} else if (customModule.sourcePath && !path.isAbsolute(customModule.sourcePath)) {
|
||||
// Source path exists but is not absolute
|
||||
const absolutePath = path.resolve(customModule.sourcePath);
|
||||
customModule.sourcePath = absolutePath;
|
||||
|
||||
console.log(chalk.green(` ✓ Updated ${customModule.id}: ${customModule.sourcePath} → ${absolutePath}`));
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
// Write back the updated manifest
|
||||
const yamlStr = yaml.dump(manifest, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
});
|
||||
|
||||
await fs.writeFile(manifestPath, yamlStr);
|
||||
console.log(chalk.green(' Manifest updated successfully'));
|
||||
} else {
|
||||
console.log(chalk.dim(' All paths already absolute'));
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main migration function
|
||||
*/
|
||||
async function migrate(directory) {
|
||||
const projectRoot = path.resolve(directory || process.cwd());
|
||||
const bmadDir = findBmadDir(projectRoot);
|
||||
|
||||
if (!bmadDir) {
|
||||
console.error(chalk.red('✗ No BMAD installation found in directory'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.blue.bold('🔄 BMAD Custom Module Path Migration'));
|
||||
console.log(chalk.dim(`Project: ${projectRoot}`));
|
||||
console.log(chalk.dim(`BMAD Directory: ${bmadDir}`));
|
||||
|
||||
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error(chalk.red('✗ No manifest.yaml found'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updated = await updateManifest(manifestPath, projectRoot);
|
||||
|
||||
if (updated) {
|
||||
console.log(chalk.green.bold('\n✨ Migration completed successfully!'));
|
||||
console.log(chalk.dim('Custom modules now use absolute source paths.'));
|
||||
} else {
|
||||
console.log(chalk.yellow('\n⚠ No migration needed - paths already absolute'));
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
const directory = process.argv[2];
|
||||
migrate(directory).catch((error) => {
|
||||
console.error(chalk.red('\n✗ Migration failed:'), error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { migrate };
|
||||
Loading…
Reference in New Issue