From fd2521ec69434dabc0f34db4cda6568b90dc69c3 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Sat, 8 Nov 2025 15:19:19 -0600 Subject: [PATCH] customize installation folder for the bmad content --- tools/cli/installers/lib/core/detector.js | 69 +++++++++++-- tools/cli/installers/lib/core/installer.js | 53 +++++----- tools/cli/installers/lib/ide/_base-ide.js | 44 +++++++- tools/cli/installers/lib/ide/manager.js | 15 +++ tools/cli/installers/lib/modules/manager.js | 106 ++++++++++++++++---- tools/cli/lib/ui.js | 13 ++- 6 files changed, 242 insertions(+), 58 deletions(-) diff --git a/tools/cli/installers/lib/core/detector.js b/tools/cli/installers/lib/core/detector.js index ccff80d5..5f6edd81 100644 --- a/tools/cli/installers/lib/core/detector.js +++ b/tools/cli/installers/lib/core/detector.js @@ -199,6 +199,8 @@ class Detector { /** * Detect legacy BMAD v4 footprints (case-sensitive path checks) + * V4 used .bmad-method as default folder name + * V6+ uses configurable folder names and ALWAYS has _cfg/manifest.yaml with installation.version * @param {string} projectDir - Project directory to check * @returns {{ hasLegacyV4: boolean, offenders: string[] }} */ @@ -223,18 +225,62 @@ class Detector { return true; }; + // Helper: check if a directory is a V6+ installation + const isV6Installation = async (dirPath) => { + const manifestPath = path.join(dirPath, '_cfg', 'manifest.yaml'); + if (!(await fs.pathExists(manifestPath))) { + return false; + } + try { + const yaml = require('js-yaml'); + const manifestContent = await fs.readFile(manifestPath, 'utf8'); + const manifest = yaml.load(manifestContent); + // V6+ manifest has installation.version + return manifest && manifest.installation && manifest.installation.version; + } catch { + return false; + } + }; + const offenders = []; - // Find all directories starting with .bmad, bmad, or Bmad + // Strategy: + // 1. First scan for ANY V6+ installation (_cfg/manifest.yaml) + // 2. If V6+ found → don't flag anything (user is already on V6+) + // 3. If NO V6+ found → flag folders with "bmad" in name as potential V4 legacy + + let hasV6Installation = false; + const potentialV4Folders = []; + try { const entries = await fs.readdir(projectDir, { withFileTypes: true }); + for (const entry of entries) { if (entry.isDirectory()) { const name = entry.name; - // Match .bmad*, bmad* (lowercase), or Bmad* (capital B) - // BUT exclude 'bmad' exactly (that's the new v6 installation directory) - if ((name.startsWith('.bmad') || name.startsWith('bmad') || name.startsWith('Bmad')) && name !== 'bmad') { - offenders.push(path.join(projectDir, entry.name)); + const fullPath = path.join(projectDir, entry.name); + + // Check if directory is empty (skip empty leftover folders) + const dirContents = await fs.readdir(fullPath); + if (dirContents.length === 0) { + continue; // Skip empty folders + } + + // Check if it's a V6+ installation by looking for _cfg/manifest.yaml + // This works for ANY folder name (not just bmad-prefixed) + const isV6 = await isV6Installation(fullPath); + + if (isV6) { + // Found a V6+ installation - user is already on V6+ + hasV6Installation = true; + // Don't break - continue scanning to be thorough + } else { + // Not V6+, check if folder name contains "bmad" (case insensitive) + const nameLower = name.toLowerCase(); + if (nameLower.includes('bmad')) { + // Potential V4 legacy folder + potentialV4Folders.push(fullPath); + } } } } @@ -242,8 +288,15 @@ class Detector { // Ignore errors reading directory } + // Only flag V4 folders if NO V6+ installation was found + if (!hasV6Installation && potentialV4Folders.length > 0) { + offenders.push(...potentialV4Folders); + } + // Check inside various IDE command folders for legacy bmad folders - // List of IDE config folders that might have commands directories + // V4 used folders like 'bmad-method' or custom names in IDE commands + // V6+ uses 'bmad' in IDE commands (hardcoded in IDE handlers) + // Legacy V4 IDE command folders won't have a corresponding V6+ installation const ideConfigFolders = ['.opencode', '.claude', '.crush', '.continue', '.cursor', '.windsurf', '.cline', '.roo-cline']; for (const ideFolder of ideConfigFolders) { @@ -255,7 +308,9 @@ class Detector { for (const entry of commandEntries) { if (entry.isDirectory()) { const name = entry.name; - // Find bmad-related folders (excluding exact 'bmad' if it exists) + // V4 used 'bmad-method' or similar in IDE commands folders + // V6+ uses 'bmad' (hardcoded) + // So anything that's NOT 'bmad' but starts with bmad/Bmad is likely V4 if ((name.startsWith('bmad') || name.startsWith('Bmad') || name === 'BMad') && name !== 'bmad') { offenders.push(path.join(commandsPath, entry.name)); } diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 5b55c775..97eb8c19 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -35,7 +35,7 @@ class Installer { /** * Find the bmad installation directory in a project - * Checks for custom bmad_folder names (.bmad, .bmad-custom, etc.) and falls back to 'bmad' + * V6+ installations can use ANY folder name but ALWAYS have _cfg/manifest.yaml * @param {string} projectDir - Project directory * @returns {Promise} Path to bmad directory */ @@ -46,34 +46,25 @@ class Installer { return path.join(projectDir, 'bmad'); } - // First, try to read from existing core config to get the bmad_folder value - const possibleDirs = ['.bmad', 'bmad']; // Common defaults - - // Check if any of these exist - for (const dir of possibleDirs) { - const fullPath = path.join(projectDir, dir); - if (await fs.pathExists(fullPath)) { - // Try to read the config to confirm this is a bmad installation - const configPath = path.join(fullPath, 'core', 'config.yaml'); - if (await fs.pathExists(configPath)) { - return fullPath; + // V6+ strategy: Look for ANY directory with _cfg/manifest.yaml + // This is the definitive marker of a V6+ installation + try { + const entries = await fs.readdir(projectDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const manifestPath = path.join(projectDir, entry.name, '_cfg', 'manifest.yaml'); + if (await fs.pathExists(manifestPath)) { + // Found a V6+ installation + return path.join(projectDir, entry.name); + } } } + } catch { + // Ignore errors, fall through to default } - // If nothing found, check for any directory that contains core/config.yaml - // This handles custom bmad_folder names - const entries = await fs.readdir(projectDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const configPath = path.join(projectDir, entry.name, 'core', 'config.yaml'); - if (await fs.pathExists(configPath)) { - return path.join(projectDir, entry.name); - } - } - } - - // Default fallback + // No V6+ installation found, return default + // This will be used for new installations return path.join(projectDir, 'bmad'); } @@ -249,12 +240,10 @@ class Installer { // Display welcome message CLIUtils.displaySection('BMAD™ Installation', 'Version ' + require(path.join(getProjectRoot(), 'package.json')).version); - // Preflight: Handle legacy BMAD v4 footprints before any prompts/writes + // Note: Legacy V4 detection now happens earlier in UI.promptInstall() + // before any config collection, so we don't need to check again here + const projectDir = path.resolve(config.directory); - const legacyV4 = await this.detector.detectLegacyV4(projectDir); - if (legacyV4.hasLegacyV4) { - await this.handleLegacyV4Migration(projectDir, legacyV4); - } // If core config was pre-collected (from interactive mode), use it if (config.coreConfig) { @@ -280,6 +269,10 @@ class Installer { const bmadFolderName = moduleConfigs.core && moduleConfigs.core.bmad_folder ? moduleConfigs.core.bmad_folder : 'bmad'; this.bmadFolderName = bmadFolderName; // Store for use in other methods + // Set bmad folder name on module manager and IDE manager for placeholder replacement + this.moduleManager.setBmadFolderName(bmadFolderName); + this.ideManager.setBmadFolderName(bmadFolderName); + // Tool selection will be collected after we determine if it's a reinstall/update/new install const spinner = ora('Preparing installation...').start(); diff --git a/tools/cli/installers/lib/ide/_base-ide.js b/tools/cli/installers/lib/ide/_base-ide.js index 269bf9fd..86f8948e 100644 --- a/tools/cli/installers/lib/ide/_base-ide.js +++ b/tools/cli/installers/lib/ide/_base-ide.js @@ -18,6 +18,15 @@ class BaseIdeSetup { this.configFile = null; // Override in subclasses when detection is file-based this.detectionPaths = []; // Additional paths that indicate the IDE is configured this.xmlHandler = new XmlHandler(); + this.bmadFolderName = 'bmad'; // Default, can be overridden + } + + /** + * Set the bmad folder name for placeholder replacement + * @param {string} bmadFolderName - The bmad folder name + */ + setBmadFolderName(bmadFolderName) { + this.bmadFolderName = bmadFolderName; } /** @@ -489,23 +498,52 @@ class BaseIdeSetup { } /** - * Write file with content + * Write file with content (replaces {bmad_folder} placeholder) * @param {string} filePath - File path * @param {string} content - File content */ async writeFile(filePath, content) { + // Replace {bmad_folder} placeholder if present + if (typeof content === 'string' && content.includes('{bmad_folder}')) { + content = content.replaceAll('{bmad_folder}', this.bmadFolderName); + } await this.ensureDir(path.dirname(filePath)); await fs.writeFile(filePath, content, 'utf8'); } /** - * Copy file from source to destination + * Copy file from source to destination (replaces {bmad_folder} placeholder in text files) * @param {string} source - Source file path * @param {string} dest - Destination file path */ async copyFile(source, dest) { + // List of text file extensions that should have placeholder replacement + const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv']; + const ext = path.extname(source).toLowerCase(); + await this.ensureDir(path.dirname(dest)); - await fs.copy(source, dest, { overwrite: true }); + + // Check if this is a text file that might contain placeholders + if (textExtensions.includes(ext)) { + try { + // Read the file content + let content = await fs.readFile(source, 'utf8'); + + // Replace {bmad_folder} placeholder with actual folder name + if (content.includes('{bmad_folder}')) { + content = content.replaceAll('{bmad_folder}', this.bmadFolderName); + } + + // Write to dest with replaced content + await fs.writeFile(dest, content, 'utf8'); + } catch { + // If reading as text fails, fall back to regular copy + await fs.copy(source, dest, { overwrite: true }); + } + } else { + // Binary file or other file type - just copy directly + await fs.copy(source, dest, { overwrite: true }); + } } /** diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/cli/installers/lib/ide/manager.js index d58ca6b0..434cabf2 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/cli/installers/lib/ide/manager.js @@ -10,6 +10,21 @@ class IdeManager { constructor() { this.handlers = new Map(); this.loadHandlers(); + this.bmadFolderName = 'bmad'; // Default, can be overridden + } + + /** + * Set the bmad folder name for all IDE handlers + * @param {string} bmadFolderName - The bmad folder name + */ + setBmadFolderName(bmadFolderName) { + this.bmadFolderName = bmadFolderName; + // Update all loaded handlers + for (const handler of this.handlers.values()) { + if (typeof handler.setBmadFolderName === 'function') { + handler.setBmadFolderName(bmadFolderName); + } + } } /** diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 4f9fb472..e4e1eded 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -26,6 +26,70 @@ class ModuleManager { // Path to source modules directory this.modulesSourcePath = getSourcePath('modules'); this.xmlHandler = new XmlHandler(); + this.bmadFolderName = 'bmad'; // Default, can be overridden + } + + /** + * Set the bmad folder name for placeholder replacement + * @param {string} bmadFolderName - The bmad folder name + */ + setBmadFolderName(bmadFolderName) { + this.bmadFolderName = bmadFolderName; + } + + /** + * Copy a file and replace {bmad_folder} placeholder with actual folder name + * @param {string} sourcePath - Source file path + * @param {string} targetPath - Target file path + */ + async copyFileWithPlaceholderReplacement(sourcePath, targetPath) { + // List of text file extensions that should have placeholder replacement + const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv']; + const ext = path.extname(sourcePath).toLowerCase(); + + // Check if this is a text file that might contain placeholders + if (textExtensions.includes(ext)) { + try { + // Read the file content + let content = await fs.readFile(sourcePath, 'utf8'); + + // Replace {bmad_folder} placeholder with actual folder name + if (content.includes('{bmad_folder}')) { + content = content.replaceAll('{bmad_folder}', this.bmadFolderName); + } + + // Write to target with replaced content + await fs.ensureDir(path.dirname(targetPath)); + await fs.writeFile(targetPath, content, 'utf8'); + } catch { + // If reading as text fails (might be binary despite extension), fall back to regular copy + await fs.copy(sourcePath, targetPath, { overwrite: true }); + } + } else { + // Binary file or other file type - just copy directly + await fs.copy(sourcePath, targetPath, { overwrite: true }); + } + } + + /** + * Copy a directory recursively with placeholder replacement + * @param {string} sourceDir - Source directory path + * @param {string} targetDir - Target directory path + */ + async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir) { + await fs.ensureDir(targetDir); + const entries = await fs.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath); + } else { + await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath); + } + } } /** @@ -311,9 +375,8 @@ class ModuleManager { await fs.ensureDir(path.dirname(targetFile)); await this.copyWorkflowYamlStripped(sourceFile, targetFile); } else { - // Copy the file normally - await fs.ensureDir(path.dirname(targetFile)); - await fs.copy(sourceFile, targetFile, { overwrite: true }); + // Copy the file with placeholder replacement + await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); } // Track the file if callback provided @@ -333,12 +396,16 @@ class ModuleManager { // Read the source YAML file let yamlContent = await fs.readFile(sourceFile, 'utf8'); + // IMPORTANT: Replace {bmad_folder} BEFORE parsing YAML + // Otherwise parsing will fail on the placeholder + yamlContent = yamlContent.replaceAll('{bmad_folder}', this.bmadFolderName); + try { // First check if web_bundle exists by parsing const workflowConfig = yaml.load(yamlContent); if (workflowConfig.web_bundle === undefined) { - // No web_bundle section, just copy as-is + // No web_bundle section, just write (placeholders already replaced above) await fs.writeFile(targetFile, yamlContent, 'utf8'); return; } @@ -400,6 +467,7 @@ class ModuleManager { // Clean up any double blank lines that might result const strippedYaml = newLines.join('\n').replaceAll(/\n\n\n+/g, '\n\n'); + // Placeholders already replaced at the beginning of this function await fs.writeFile(targetFile, strippedYaml, 'utf8'); } catch { // If anything fails, just copy the file as-is @@ -488,8 +556,10 @@ class ModuleManager { const installWorkflowPath = item['workflow-install']; // Where to copy TO // Parse SOURCE workflow path - // Example: {project-root}/bmad/bmm/workflows/4-implementation/create-story/workflow.yaml - const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/bmad\/([^/]+)\/workflows\/(.+)/); + // Handle both {bmad_folder} placeholder and hardcoded 'bmad' + // Example: {project-root}/{bmad_folder}/bmm/workflows/4-implementation/create-story/workflow.yaml + // Or: {project-root}/bmad/bmm/workflows/4-implementation/create-story/workflow.yaml + const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/(?:\{bmad_folder\}|bmad)\/([^/]+)\/workflows\/(.+)/); if (!sourceMatch) { console.warn(chalk.yellow(` Could not parse workflow path: ${sourceWorkflowPath}`)); continue; @@ -498,8 +568,9 @@ class ModuleManager { const [, sourceModule, sourceWorkflowSubPath] = sourceMatch; // Parse INSTALL workflow path - // Example: {project-root}/bmad/bmgd/workflows/4-production/create-story/workflow.yaml - const installMatch = installWorkflowPath.match(/\{project-root\}\/bmad\/([^/]+)\/workflows\/(.+)/); + // Handle both {bmad_folder} placeholder and hardcoded 'bmad' + // Example: {project-root}/{bmad_folder}/bmgd/workflows/4-production/create-story/workflow.yaml + const installMatch = installWorkflowPath.match(/\{project-root\}\/(?:\{bmad_folder\}|bmad)\/([^/]+)\/workflows\/(.+)/); if (!installMatch) { console.warn(chalk.yellow(` Could not parse workflow-install path: ${installWorkflowPath}`)); continue; @@ -527,7 +598,8 @@ class ModuleManager { ); await fs.ensureDir(path.dirname(actualDestWorkflowPath)); - await fs.copy(actualSourceWorkflowPath, actualDestWorkflowPath, { overwrite: true }); + // Copy the workflow directory recursively with placeholder replacement + await this.copyDirectoryWithPlaceholderReplacement(actualSourceWorkflowPath, actualDestWorkflowPath); // Update the workflow.yaml config_source reference const workflowYamlPath = path.join(actualDestWorkflowPath, 'workflow.yaml'); @@ -550,16 +622,17 @@ class ModuleManager { async updateWorkflowConfigSource(workflowYamlPath, newModuleName) { let yamlContent = await fs.readFile(workflowYamlPath, 'utf8'); - // Replace config_source: "{project-root}/bmad/OLD_MODULE/config.yaml" - // with config_source: "{project-root}/bmad/NEW_MODULE/config.yaml" - const configSourcePattern = /config_source:\s*["']?\{project-root\}\/bmad\/[^/]+\/config\.yaml["']?/g; - const newConfigSource = `config_source: "{project-root}/bmad/${newModuleName}/config.yaml"`; + // Replace config_source: "{project-root}/{bmad_folder}/OLD_MODULE/config.yaml" + // with config_source: "{project-root}/{bmad_folder}/NEW_MODULE/config.yaml" + // Note: At this point {bmad_folder} has already been replaced with actual folder name + const configSourcePattern = /config_source:\s*["']?\{project-root\}\/[^/]+\/[^/]+\/config\.yaml["']?/g; + const newConfigSource = `config_source: "{project-root}/${this.bmadFolderName}/${newModuleName}/config.yaml"`; const updatedYaml = yamlContent.replaceAll(configSourcePattern, newConfigSource); if (updatedYaml !== yamlContent) { await fs.writeFile(workflowYamlPath, updatedYaml, 'utf8'); - console.log(chalk.dim(` Updated config_source to: bmad/${newModuleName}/config.yaml`)); + console.log(chalk.dim(` Updated config_source to: ${this.bmadFolderName}/${newModuleName}/config.yaml`)); } } @@ -664,9 +737,8 @@ class ModuleManager { } } - // Copy file - await fs.ensureDir(path.dirname(targetFile)); - await fs.copy(sourceFile, targetFile, { overwrite: true }); + // Copy file with placeholder replacement + await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); } } diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 5eff4469..93fc7ac3 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -21,10 +21,21 @@ class UI { const confirmedDirectory = await this.getConfirmedDirectory(); + // Preflight: Check for legacy BMAD v4 footprints immediately after getting directory + const { Detector } = require('../installers/lib/core/detector'); + const { Installer } = require('../installers/lib/core/installer'); + const detector = new Detector(); + const installer = new Installer(); + const legacyV4 = await detector.detectLegacyV4(confirmedDirectory); + if (legacyV4.hasLegacyV4) { + await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4); + } + // Check if there's an existing BMAD installation const fs = require('fs-extra'); const path = require('node:path'); - const bmadDir = path.join(confirmedDirectory, 'bmad'); + // Use findBmadDir to detect any custom folder names (V6+) + const bmadDir = await installer.findBmadDir(confirmedDirectory); const hasExistingInstall = await fs.pathExists(bmadDir); // Track action type (only set if there's an existing installation)