This commit is contained in:
NEE 2026-05-01 14:11:18 +00:00 committed by GitHub
commit f96800d330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 244 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)'], ['--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'], ['--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'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'],
['--no-badge', 'Skip adding BMAD badge to README'],
[ [
'--channel <channel>', '--channel <channel>',
'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.', '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); 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 // Handle cancel
if (config.actionType === 'cancel') { if (config.actionType === 'cancel') {
await prompts.log.warn('Installation cancelled.'); await prompts.log.warn('Installation cancelled.');

View File

@ -139,6 +139,20 @@ module.exports = {
s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`); s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`);
await installer.uninstallModules(projectDir); await installer.uninstallModules(projectDir);
s.stop('Modules & data removed'); 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 = []; const summary = [];

View File

@ -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<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 (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,
};

View File

@ -15,6 +15,9 @@ class Config {
quickUpdate, quickUpdate,
channelOptions, channelOptions,
setOverrides, setOverrides,
noBadge,
badgeOwner,
badgeRepo,
}) { }) {
this.directory = directory; this.directory = directory;
this.modules = Object.freeze([...modules]); this.modules = Object.freeze([...modules]);
@ -32,6 +35,9 @@ class Config {
// Intentionally NOT integrated with the prompt/template/schema flow; see // Intentionally NOT integrated with the prompt/template/schema flow; see
// `tools/installer/set-overrides.js` for the rationale and tradeoffs. // `tools/installer/set-overrides.js` for the rationale and tradeoffs.
this.setOverrides = setOverrides || {}; this.setOverrides = setOverrides || {};
this.noBadge = noBadge || false;
this.badgeOwner = badgeOwner || null;
this.badgeRepo = badgeRepo || null;
Object.freeze(this); Object.freeze(this);
} }
@ -58,6 +64,9 @@ class Config {
quickUpdate: userInput._quickUpdate || false, quickUpdate: userInput._quickUpdate || false,
channelOptions: userInput.channelOptions || null, channelOptions: userInput.channelOptions || null,
setOverrides: userInput.setOverrides || {}, setOverrides: userInput.setOverrides || {},
noBadge: userInput.noBadge || false,
badgeOwner: userInput.badgeOwner || null,
badgeRepo: userInput.badgeRepo || null,
}); });
} }

View File

@ -103,6 +103,15 @@ class Installer {
const restoreResult = await this._restoreUserFiles(paths, updateState); 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 // Render consolidated summary
await this.renderInstallSummary(results, { await this.renderInstallSummary(results, {
bmadDir: paths.bmadDir, bmadDir: paths.bmadDir,
@ -1038,6 +1047,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() * Render a consolidated install summary using prompts.note()
* @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail} * @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail}
@ -1294,6 +1345,9 @@ class Installer {
directory: projectDir, directory: projectDir,
modules: modulesToUpdate, modules: modulesToUpdate,
ides: configuredIdes, ides: configuredIdes,
noBadge: config.noBadge,
badgeOwner: config.badgeOwner,
badgeRepo: config.badgeRepo,
coreConfig: quickModules.collectedConfig.core, coreConfig: quickModules.collectedConfig.core,
moduleConfigs: quickModules.collectedConfig, moduleConfigs: quickModules.collectedConfig,
// Forward `--set` overrides so the post-install patch step // Forward `--set` overrides so the post-install patch step