diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 1dfe6fb70..4125295a8 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -35,6 +35,7 @@ module.exports = { ['--output-folder ', 'Output folder path relative to project root (default: _bmad-output)'], ['--custom-source ', 'Comma-separated Git URLs or local paths to install custom modules from'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'], + ['--no-badge', 'Skip adding BMAD badge to README'], [ '--channel ', 'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.', @@ -96,6 +97,41 @@ module.exports = { const config = await ui.promptInstall(options); + // Ask about badge unless --no-badge or --yes + if (options.badge === false) { + config.noBadge = true; + } else if (options.yes) { + config.noBadge = false; + } else { + config.noBadge = !(await prompts.confirm({ + message: 'Add BMAD badge to your README?', + default: true, + })); + } + + // Resolve owner/repo for badge (git remote → prompt fallback) + if (!config.noBadge) { + const badge = require('../core/badge'); + let remote = badge.resolveGitRemote(config.directory); + if (!remote) { + const input = await prompts.text({ + message: 'Enter your GitHub owner/repo for the badge (e.g., nick/my-project):', + placeholder: 'owner/repo', + validate: (v) => (!v || !v.includes('/') ? 'Format: owner/repo' : undefined), + }); + if (input) { + const [owner, repo] = input.split('/'); + remote = { owner, repo }; + } + } + if (remote) { + config.badgeOwner = remote.owner; + config.badgeRepo = remote.repo; + } else { + config.noBadge = true; + } + } + // Handle cancel if (config.actionType === 'cancel') { await prompts.log.warn('Installation cancelled.'); diff --git a/tools/installer/commands/uninstall.js b/tools/installer/commands/uninstall.js index 727b7b0ef..dc2055027 100644 --- a/tools/installer/commands/uninstall.js +++ b/tools/installer/commands/uninstall.js @@ -139,6 +139,20 @@ module.exports = { s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`); await installer.uninstallModules(projectDir); s.stop('Modules & data removed'); + + // Remove BMAD badge from README (best-effort) + try { + const badge = require('../core/badge'); + const readmePath = await badge.findReadme(projectDir); + if (readmePath) { + const content = await fs.readFile(readmePath, 'utf8'); + if (badge.hasBadge(content)) { + await fs.writeFile(readmePath, badge.removeBadge(content), 'utf8'); + } + } + } catch (error) { + await prompts.log.warn(`Badge cleanup skipped: ${error.message}`); + } } const summary = []; diff --git a/tools/installer/core/badge.js b/tools/installer/core/badge.js new file mode 100644 index 000000000..c7503c0e9 --- /dev/null +++ b/tools/installer/core/badge.js @@ -0,0 +1,131 @@ +const path = require('node:path'); +const { execSync } = require('node:child_process'); +const fs = require('../fs-native'); + +const BADGE_URL = 'https://bmad-badge.vercel.app'; +const escapedBadgeUrl = BADGE_URL.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); +const BADGE_PATTERN = new RegExp(`\\[!\\[BMAD\\]\\(${escapedBadgeUrl}/[^)]+\\)\\]\\(https://github\\.com/bmad-code-org/BMAD-METHOD\\)`); +const README_NAMES = ['README.md', 'readme.md', 'README', 'readme']; + +/** + * Resolve owner and repo from the project's git remote origin URL. + * Supports HTTPS and SSH formats. + * @param {string} projectDir - Project root directory + * @returns {{ owner: string, repo: string } | null} Parsed owner/repo or null + */ +function resolveGitRemote(projectDir) { + try { + const raw = execSync('git remote get-url origin', { + cwd: projectDir, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + const httpsMatch = raw.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + } catch { + // no git remote + } + return null; +} + +/** + * Find the first README file in the project directory. + * Checks common README naming variants (case-insensitive). + * @param {string} projectDir - Project root directory + * @returns {Promise} Absolute path to README or null + */ +async function findReadme(projectDir) { + for (const name of README_NAMES) { + const fullPath = path.join(projectDir, name); + if (await fs.pathExists(fullPath)) { + return fullPath; + } + } + return null; +} + +/** + * Check whether the content already contains a BMAD badge. + * @param {string} content - README file content + * @returns {boolean} True if badge is present + */ +function hasBadge(content) { + return BADGE_PATTERN.test(content); +} + +/** + * Generate the BMAD badge markdown line. + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {string} Badge markdown string + */ +function generateBadgeMarkdown(owner, repo) { + return `[![BMAD](${BADGE_URL}/${owner}/${repo}.svg)](https://github.com/bmad-code-org/BMAD-METHOD)`; +} + +/** + * Inject the BMAD badge into README content. + * Places the badge after the first heading, alongside any existing badges. + * @param {string} content - Original README content + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {string} Updated README content with badge + */ +function injectBadge(content, owner, repo) { + const badgeLine = generateBadgeMarkdown(owner, repo); + + const lines = content.split('\n'); + + // Find the first heading (# title) + let headingEnd = 0; + for (const [i, line] of lines.entries()) { + headingEnd = i + 1; + if (line.startsWith('#')) break; + } + + // Check if there are existing badges right after the heading + let insertAt = headingEnd; + while (insertAt < lines.length && /^\[!\[.*?\]\(.*?\)\]\(.*?\)/.test(lines[insertAt].trim())) { + insertAt++; + } + + lines.splice(insertAt, 0, badgeLine); + return lines.join('\n'); +} + +/** + * Remove the BMAD badge from README content. + * @param {string} content - README file content + * @returns {string} Cleaned README content without the badge line + */ +function removeBadge(content) { + return content + .split('\n') + .filter((line) => !BADGE_PATTERN.test(line.trim())) + .join('\n'); +} + +/** + * Create a minimal README.md content with project heading and BMAD badge. + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} projectName - Project name for the heading + * @returns {string} New README content + */ +function createReadmeWithBadge(owner, repo, projectName) { + const badgeLine = generateBadgeMarkdown(owner, repo); + return `# ${projectName}\n\n${badgeLine}\n`; +} + +module.exports = { + resolveGitRemote, + findReadme, + hasBadge, + generateBadgeMarkdown, + injectBadge, + removeBadge, + createReadmeWithBadge, +}; diff --git a/tools/installer/core/config.js b/tools/installer/core/config.js index 39617de4c..1148b386a 100644 --- a/tools/installer/core/config.js +++ b/tools/installer/core/config.js @@ -15,6 +15,9 @@ class Config { quickUpdate, channelOptions, setOverrides, + noBadge, + badgeOwner, + badgeRepo, }) { this.directory = directory; this.modules = Object.freeze([...modules]); @@ -32,6 +35,9 @@ class Config { // Intentionally NOT integrated with the prompt/template/schema flow; see // `tools/installer/set-overrides.js` for the rationale and tradeoffs. this.setOverrides = setOverrides || {}; + this.noBadge = noBadge || false; + this.badgeOwner = badgeOwner || null; + this.badgeRepo = badgeRepo || null; Object.freeze(this); } @@ -58,6 +64,9 @@ class Config { quickUpdate: userInput._quickUpdate || false, channelOptions: userInput.channelOptions || null, setOverrides: userInput.setOverrides || {}, + noBadge: userInput.noBadge || false, + badgeOwner: userInput.badgeOwner || null, + badgeRepo: userInput.badgeRepo || null, }); } diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index ed04b07d1..90bdf0fbb 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -104,6 +104,15 @@ class Installer { const restoreResult = await this._restoreUserFiles(paths, updateState); + // Inject BMAD badge into README if applicable + if (!config.noBadge) { + try { + await this._injectBadgeIfNeeded(paths.projectRoot, addResult, config); + } catch (error) { + addResult('Badge', 'warn', `skipped: ${error.message}`); + } + } + // Render consolidated summary await this.renderInstallSummary(results, { bmadDir: paths.bmadDir, @@ -1049,6 +1058,48 @@ class Installer { } } + /** + * Inject BMAD version badge into project README. + * Uses owner/repo from config (resolved in UI layer). + * @param {string} projectDir - Project root directory + * @param {Function} addResult - Callback to record results + * @param {Object} config - Installation config with badgeOwner/badgeRepo + */ + async _injectBadgeIfNeeded(projectDir, addResult, config) { + const badge = require('../core/badge'); + + const owner = config.badgeOwner; + const repo = config.badgeRepo; + if (!owner || !repo) { + addResult('Badge', 'warn', 'no owner/repo provided'); + return; + } + + const readmePath = await badge.findReadme(projectDir); + if (!readmePath) { + const projectName = path.basename(projectDir); + const content = badge.createReadmeWithBadge(owner, repo, projectName); + const newReadmePath = path.join(projectDir, 'README.md'); + await fs.writeFile(newReadmePath, content, 'utf8'); + addResult('Badge', 'ok', 'created README.md with badge'); + return; + } + + const content = await fs.readFile(readmePath, 'utf8'); + if (badge.hasBadge(content)) { + // Update badge if owner/repo changed + const updated = badge.removeBadge(content); + const injected = badge.injectBadge(updated, owner, repo); + await fs.writeFile(readmePath, injected, 'utf8'); + addResult('Badge', 'ok', `updated in ${path.basename(readmePath)}`); + return; + } + + const updated = badge.injectBadge(content, owner, repo); + await fs.writeFile(readmePath, updated, 'utf8'); + addResult('Badge', 'ok', `added to ${path.basename(readmePath)}`); + } + /** * Render a consolidated install summary using prompts.note() * @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail} @@ -1305,6 +1356,9 @@ class Installer { directory: projectDir, modules: modulesToUpdate, ides: configuredIdes, + noBadge: config.noBadge, + badgeOwner: config.badgeOwner, + badgeRepo: config.badgeRepo, coreConfig: quickModules.collectedConfig.core, moduleConfigs: quickModules.collectedConfig, // Forward `--set` overrides so the post-install patch step