From 707de9023873ada97ac6bd36ad23f604195c3f60 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 1 May 2026 13:45:25 +0800 Subject: [PATCH] feat(installer): auto-inject BMAD version badge into project README Lightweight installer integration that adds a dynamic BMAD version badge to the project README during `bmad install`. The badge is powered by the existing bmad-badge.vercel.app service (from terryso/bmad-badge, issue #2174). - New badge.js module: resolves git remote, finds README, injects/removes badge - Badge is added after install, shown in the install summary - `--no-badge` flag to opt out - Badge is removed during `bmad uninstall` when modules are removed - Idempotent: re-running install skips if badge already present Closes #2174 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- tools/installer/commands/install.js | 2 + tools/installer/commands/uninstall.js | 10 ++++ tools/installer/core/badge.js | 83 +++++++++++++++++++++++++++ tools/installer/core/installer.js | 38 ++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 tools/installer/core/badge.js diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 1dfe6fb70..61598a2cf 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.', @@ -95,6 +96,7 @@ module.exports = { } const config = await ui.promptInstall(options); + config.noBadge = options.badge === false; // Handle cancel if (config.actionType === 'cancel') { diff --git a/tools/installer/commands/uninstall.js b/tools/installer/commands/uninstall.js index 727b7b0ef..19b2447ad 100644 --- a/tools/installer/commands/uninstall.js +++ b/tools/installer/commands/uninstall.js @@ -139,6 +139,16 @@ module.exports = { s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`); await installer.uninstallModules(projectDir); s.stop('Modules & data removed'); + + // Remove BMAD badge from README + 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'); + } + } } const summary = []; diff --git a/tools/installer/core/badge.js b/tools/installer/core/badge.js new file mode 100644 index 000000000..7623c256b --- /dev/null +++ b/tools/installer/core/badge.js @@ -0,0 +1,83 @@ +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 BADGE_PATTERN = /\[!\[BMAD\]\(https:\/\/bmad-badge\.vercel\.app\/[^\)]+\)\]\(https:\/\/github\.com\/bmad-code-org\/BMAD-METHOD\)/; +const README_NAMES = ['README.md', 'readme.md', 'README', 'readme']; + +function resolveGitRemote(projectDir) { + try { + const raw = execSync('git remote get-url origin', { + cwd: projectDir, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + // https://github.com/owner/repo.git + const httpsMatch = raw.match(/github\.com[:/]([^/]+)\/([^/.]+)/); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + } catch { + // no git remote + } + return 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; +} + +function hasBadge(content) { + return BADGE_PATTERN.test(content); +} + +function generateBadgeMarkdown(owner, repo) { + return `[![BMAD](${BADGE_URL}/${owner}/${repo}.svg)](https://github.com/bmad-code-org/BMAD-METHOD)`; +} + +function injectBadge(content, owner, repo) { + const badgeLine = generateBadgeMarkdown(owner, repo); + + const lines = content.split('\n'); + + // Find the first heading (# title) + let headingEnd = 0; + for (let i = 0; i < lines.length; i++) { + headingEnd = i + 1; + if (lines[i].startsWith('#')) break; + } + + // Check if there are existing badges right after the heading + let insertAt = headingEnd; + while (insertAt < lines.length && /^\[!\[.*?\]\(.*?\)\]\(.*?\)/.test(lines[insertAt].trim())) { + insertAt++; + } + + // Insert badge line + lines.splice(insertAt, 0, badgeLine); + return lines.join('\n'); +} + +function removeBadge(content) { + return content + .split('\n') + .filter((line) => !BADGE_PATTERN.test(line.trim())) + .join('\n'); +} + +module.exports = { + resolveGitRemote, + findReadme, + hasBadge, + generateBadgeMarkdown, + injectBadge, + removeBadge, +}; diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 4952c89e1..b6c2d8e52 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -103,6 +103,11 @@ class Installer { const restoreResult = await this._restoreUserFiles(paths, updateState); + // Inject BMAD badge into README if applicable + if (!config.noBadge) { + await this._injectBadgeIfNeeded(paths.projectRoot, addResult); + } + // Render consolidated summary await this.renderInstallSummary(results, { bmadDir: paths.bmadDir, @@ -1038,6 +1043,39 @@ class Installer { } } + /** + * Inject BMAD version badge into project README if applicable. + * Skipped when --no-badge is set, when no git remote is found, + * or when no README exists. + * @param {string} projectDir - Project root directory + * @param {Function} addResult - Callback to record results + */ + async _injectBadgeIfNeeded(projectDir, addResult) { + const badge = require('../core/badge'); + + const remote = badge.resolveGitRemote(projectDir); + if (!remote) { + addResult('Badge', 'warn', 'no git remote found'); + return; + } + + const readmePath = await badge.findReadme(projectDir); + if (!readmePath) { + addResult('Badge', 'warn', 'no README found'); + return; + } + + const content = await fs.readFile(readmePath, 'utf8'); + if (badge.hasBadge(content)) { + addResult('Badge', 'ok', 'already present'); + return; + } + + const updated = badge.injectBadge(content, remote.owner, remote.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}