feat(installer): auto-inject BMAD version badge into project README
- Add badge.js module for git remote resolution, README detection, and badge injection - Integrate badge prompt into install flow with --no-badge opt-out - Support badge update when owner/repo changes on re-install - Auto-create README.md with badge if missing - Pass badge config through Config.build() for both install and quick-update paths Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9debc165aa
commit
4a4a7c9079
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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 `[](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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue