Compare commits

...

5 Commits

Author SHA1 Message Date
NEE 3b8d5de9da
Merge a955c8611d into 9debc165aa 2026-05-01 11:54:42 +00:00
nick a955c8611d docs(installer): add JSDoc docstrings to badge.js
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-01 19:54:34 +08:00
nick a8666294cb fix(installer): address CodeRabbit review feedback on badge integration
- Derive BADGE_PATTERN from BADGE_URL constant (configurable)
- Fix git remote regex to support repo names with dots
- Add try/catch around badge injection so IO errors don't abort install
- Add try/catch around badge cleanup in uninstall (best-effort)
- Pass noBadge through quickUpdate flow

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-01 15:54:05 +08:00
nick 4d0b0fdcd1 feat(installer): add badge prompt and auto-create README if missing
- Interactive prompt asks "Add BMAD badge to your README?" (default yes)
- --no-badge skips the prompt and disables badge
- --yes accepts badge by default
- If user wants badge but no README exists, creates README.md with
  project name and badge

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-01 15:36:50 +08:00
nick 707de90238 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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-05-01 13:45:25 +08:00
4 changed files with 208 additions and 0 deletions

View File

@ -35,6 +35,7 @@ module.exports = {
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
['--custom-source <sources>', '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 <channel>',
'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.',
@ -96,6 +97,18 @@ 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,
}));
}
// Handle cancel
if (config.actionType === 'cancel') {
await prompts.log.warn('Installation cancelled.');

View File

@ -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 = [];

View File

@ -0,0 +1,133 @@
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.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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<string | null>} 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 (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++;
}
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,
};

View File

@ -103,6 +103,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);
} catch (error) {
addResult('Badge', 'warn', `skipped: ${error.message}`);
}
}
// Render consolidated summary
await this.renderInstallSummary(results, {
bmadDir: paths.bmadDir,
@ -1038,6 +1047,44 @@ 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) {
// No README — create one with the badge
const projectName = path.basename(projectDir);
const content = badge.createReadmeWithBadge(remote.owner, remote.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)) {
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}
@ -1294,6 +1341,7 @@ class Installer {
directory: projectDir,
modules: modulesToUpdate,
ides: configuredIdes,
noBadge: config.noBadge,
coreConfig: quickModules.collectedConfig.core,
moduleConfigs: quickModules.collectedConfig,
// Forward `--set` overrides so the post-install patch step