Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions tools/installer/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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.');
Expand Down
14 changes: 14 additions & 0 deletions tools/installer/commands/uninstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
131 changes: 131 additions & 0 deletions tools/installer/core/badge.js
Original file line number Diff line number Diff line change
@@ -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,
};
9 changes: 9 additions & 0 deletions tools/installer/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class Config {
quickUpdate,
channelOptions,
setOverrides,
noBadge,
badgeOwner,
badgeRepo,
}) {
this.directory = directory;
this.modules = Object.freeze([...modules]);
Expand All @@ -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);
}

Expand All @@ -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,
});
}

Expand Down
54 changes: 54 additions & 0 deletions tools/installer/core/installer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
Comment thread
terryso marked this conversation as resolved.

// Render consolidated summary
await this.renderInstallSummary(results, {
bmadDir: paths.bmadDir,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down