chore: add code formatting config and pre-commit hooks (#450)

This commit is contained in:
manjaroblack
2025-08-16 19:08:39 -05:00
committed by GitHub
parent 51284d6ecf
commit ed539432fb
130 changed files with 11886 additions and 10939 deletions

View File

@@ -5,30 +5,30 @@
* This file ensures proper execution when run via npx from GitHub
*/
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const { execSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
// Check if we're running in an npx temporary directory
const isNpxExecution = __dirname.includes('_npx') || __dirname.includes('.npm');
// If running via npx, we need to handle things differently
if (isNpxExecution) {
const args = process.argv.slice(2);
const arguments_ = process.argv.slice(2);
// Use the installer for all commands
const bmadScriptPath = path.join(__dirname, 'installer', 'bin', 'bmad.js');
if (!fs.existsSync(bmadScriptPath)) {
console.error('Error: Could not find bmad.js at', bmadScriptPath);
console.error('Current directory:', __dirname);
process.exit(1);
}
try {
execSync(`node "${bmadScriptPath}" ${args.join(' ')}`, {
execSync(`node "${bmadScriptPath}" ${arguments_.join(' ')}`, {
stdio: 'inherit',
cwd: path.dirname(__dirname)
cwd: path.dirname(__dirname),
});
} catch (error) {
process.exit(error.status || 1);
@@ -36,4 +36,4 @@ if (isNpxExecution) {
} else {
// Local execution - use installer for all commands
require('./installer/bin/bmad.js');
}
}

View File

@@ -1,23 +1,23 @@
const fs = require("node:fs").promises;
const path = require("node:path");
const DependencyResolver = require("../lib/dependency-resolver");
const yamlUtils = require("../lib/yaml-utils");
const fs = require('node:fs').promises;
const path = require('node:path');
const DependencyResolver = require('../lib/dependency-resolver');
const yamlUtilities = require('../lib/yaml-utils');
class WebBuilder {
constructor(options = {}) {
this.rootDir = options.rootDir || process.cwd();
this.outputDirs = options.outputDirs || [path.join(this.rootDir, "dist")];
this.outputDirs = options.outputDirs || [path.join(this.rootDir, 'dist')];
this.resolver = new DependencyResolver(this.rootDir);
this.templatePath = path.join(
this.rootDir,
"tools",
"md-assets",
"web-agent-startup-instructions.md"
'tools',
'md-assets',
'web-agent-startup-instructions.md',
);
}
parseYaml(content) {
const yaml = require("js-yaml");
const yaml = require('js-yaml');
return yaml.load(content);
}
@@ -26,7 +26,7 @@ class WebBuilder {
// All resources get installed under the bundle root, so use that path
const relativePath = path.relative(this.rootDir, filePath);
const pathParts = relativePath.split(path.sep);
let resourcePath;
if (pathParts[0] === 'expansion-packs') {
// For expansion packs, remove 'expansion-packs/packname' and use the rest
@@ -35,18 +35,28 @@ class WebBuilder {
// For bmad-core, common, etc., remove the first part
resourcePath = pathParts.slice(1).join('/');
}
return `.${bundleRoot}/${resourcePath}`;
}
generateWebInstructions(bundleType, packName = null) {
// Generate dynamic web instructions based on bundle type
const rootExample = packName ? `.${packName}` : '.bmad-core';
const examplePath = packName ? `.${packName}/folder/filename.md` : '.bmad-core/folder/filename.md';
const personasExample = packName ? `.${packName}/personas/analyst.md` : '.bmad-core/personas/analyst.md';
const tasksExample = packName ? `.${packName}/tasks/create-story.md` : '.bmad-core/tasks/create-story.md';
const utilsExample = packName ? `.${packName}/utils/template-format.md` : '.bmad-core/utils/template-format.md';
const tasksRef = packName ? `.${packName}/tasks/create-story.md` : '.bmad-core/tasks/create-story.md';
const examplePath = packName
? `.${packName}/folder/filename.md`
: '.bmad-core/folder/filename.md';
const personasExample = packName
? `.${packName}/personas/analyst.md`
: '.bmad-core/personas/analyst.md';
const tasksExample = packName
? `.${packName}/tasks/create-story.md`
: '.bmad-core/tasks/create-story.md';
const utilitiesExample = packName
? `.${packName}/utils/template-format.md`
: '.bmad-core/utils/template-format.md';
const tasksReference = packName
? `.${packName}/tasks/create-story.md`
: '.bmad-core/tasks/create-story.md';
return `# Web Agent Bundle Instructions
@@ -79,8 +89,8 @@ dependencies:
These references map directly to bundle sections:
- \`utils: template-format\` → Look for \`==================== START: ${utilsExample} ====================\`
- \`tasks: create-story\` → Look for \`==================== START: ${tasksRef} ====================\`
- \`utils: template-format\` → Look for \`==================== START: ${utilitiesExample} ====================\`
- \`tasks: create-story\` → Look for \`==================== START: ${tasksReference} ====================\`
3. **Execution Context**: You are operating in a web environment. All your capabilities and knowledge are contained within this bundle. Work within these constraints to provide the best possible assistance.
@@ -112,10 +122,10 @@ These references map directly to bundle sections:
// Write to all output directories
for (const outputDir of this.outputDirs) {
const outputPath = path.join(outputDir, "agents");
const outputPath = path.join(outputDir, 'agents');
await fs.mkdir(outputPath, { recursive: true });
const outputFile = path.join(outputPath, `${agentId}.txt`);
await fs.writeFile(outputFile, bundle, "utf8");
await fs.writeFile(outputFile, bundle, 'utf8');
}
}
@@ -131,10 +141,10 @@ These references map directly to bundle sections:
// Write to all output directories
for (const outputDir of this.outputDirs) {
const outputPath = path.join(outputDir, "teams");
const outputPath = path.join(outputDir, 'teams');
await fs.mkdir(outputPath, { recursive: true });
const outputFile = path.join(outputPath, `${teamId}.txt`);
await fs.writeFile(outputFile, bundle, "utf8");
await fs.writeFile(outputFile, bundle, 'utf8');
}
}
@@ -157,7 +167,7 @@ These references map directly to bundle sections:
sections.push(this.formatSection(resourcePath, resource.content, 'bmad-core'));
}
return sections.join("\n");
return sections.join('\n');
}
async buildTeamBundle(teamId) {
@@ -182,40 +192,40 @@ These references map directly to bundle sections:
sections.push(this.formatSection(resourcePath, resource.content, 'bmad-core'));
}
return sections.join("\n");
return sections.join('\n');
}
processAgentContent(content) {
// First, replace content before YAML with the template
const yamlContent = yamlUtils.extractYamlFromAgent(content);
const yamlContent = yamlUtilities.extractYamlFromAgent(content);
if (!yamlContent) return content;
const yamlMatch = content.match(/```ya?ml\n([\s\S]*?)\n```/);
if (!yamlMatch) return content;
const yamlStartIndex = content.indexOf(yamlMatch[0]);
const yamlEndIndex = yamlStartIndex + yamlMatch[0].length;
// Parse YAML and remove root and IDE-FILE-RESOLUTION properties
try {
const yaml = require("js-yaml");
const yaml = require('js-yaml');
const parsed = yaml.load(yamlContent);
// Remove the properties if they exist at root level
delete parsed.root;
delete parsed["IDE-FILE-RESOLUTION"];
delete parsed["REQUEST-RESOLUTION"];
delete parsed['IDE-FILE-RESOLUTION'];
delete parsed['REQUEST-RESOLUTION'];
// Also remove from activation-instructions if they exist
if (parsed["activation-instructions"] && Array.isArray(parsed["activation-instructions"])) {
parsed["activation-instructions"] = parsed["activation-instructions"].filter(
if (parsed['activation-instructions'] && Array.isArray(parsed['activation-instructions'])) {
parsed['activation-instructions'] = parsed['activation-instructions'].filter(
(instruction) => {
return (
typeof instruction === 'string' &&
!instruction.startsWith("IDE-FILE-RESOLUTION:") &&
!instruction.startsWith("REQUEST-RESOLUTION:")
!instruction.startsWith('IDE-FILE-RESOLUTION:') &&
!instruction.startsWith('REQUEST-RESOLUTION:')
);
}
},
);
}
@@ -223,25 +233,25 @@ These references map directly to bundle sections:
const cleanedYaml = yaml.dump(parsed, { lineWidth: -1 });
// Get the agent name from the YAML for the header
const agentName = parsed.agent?.id || "agent";
const agentName = parsed.agent?.id || 'agent';
// Build the new content with just the agent header and YAML
const newHeader = `# ${agentName}\n\nCRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:\n\n`;
const afterYaml = content.substring(yamlEndIndex);
const afterYaml = content.slice(Math.max(0, yamlEndIndex));
return newHeader + "```yaml\n" + cleanedYaml.trim() + "\n```" + afterYaml;
return newHeader + '```yaml\n' + cleanedYaml.trim() + '\n```' + afterYaml;
} catch (error) {
console.warn("Failed to process agent YAML:", error.message);
console.warn('Failed to process agent YAML:', error.message);
// If parsing fails, return original content
return content;
}
}
formatSection(path, content, bundleRoot = 'bmad-core') {
const separator = "====================";
const separator = '====================';
// Process agent content if this is an agent file
if (path.includes("/agents/")) {
if (path.includes('/agents/')) {
content = this.processAgentContent(content);
}
@@ -252,17 +262,17 @@ These references map directly to bundle sections:
`${separator} START: ${path} ${separator}`,
content.trim(),
`${separator} END: ${path} ${separator}`,
"",
].join("\n");
'',
].join('\n');
}
replaceRootReferences(content, bundleRoot) {
// Replace {root} with the appropriate bundle root path
return content.replace(/\{root\}/g, `.${bundleRoot}`);
return content.replaceAll('{root}', `.${bundleRoot}`);
}
async validate() {
console.log("Validating agent configurations...");
console.log('Validating agent configurations...');
const agents = await this.resolver.listAgents();
for (const agentId of agents) {
try {
@@ -274,7 +284,7 @@ These references map directly to bundle sections:
}
}
console.log("\nValidating team configurations...");
console.log('\nValidating team configurations...');
const teams = await this.resolver.listTeams();
for (const teamId of teams) {
try {
@@ -299,54 +309,54 @@ These references map directly to bundle sections:
}
async buildExpansionPack(packName, options = {}) {
const packDir = path.join(this.rootDir, "expansion-packs", packName);
const outputDirs = [path.join(this.rootDir, "dist", "expansion-packs", packName)];
const packDir = path.join(this.rootDir, 'expansion-packs', packName);
const outputDirectories = [path.join(this.rootDir, 'dist', 'expansion-packs', packName)];
// Clean output directories if requested
if (options.clean !== false) {
for (const outputDir of outputDirs) {
for (const outputDir of outputDirectories) {
try {
await fs.rm(outputDir, { recursive: true, force: true });
} catch (error) {
} catch {
// Directory might not exist, that's fine
}
}
}
// Build individual agents first
const agentsDir = path.join(packDir, "agents");
const agentsDir = path.join(packDir, 'agents');
try {
const agentFiles = await fs.readdir(agentsDir);
const agentMarkdownFiles = agentFiles.filter((f) => f.endsWith(".md"));
const agentMarkdownFiles = agentFiles.filter((f) => f.endsWith('.md'));
if (agentMarkdownFiles.length > 0) {
console.log(` Building individual agents for ${packName}:`);
for (const agentFile of agentMarkdownFiles) {
const agentName = agentFile.replace(".md", "");
const agentName = agentFile.replace('.md', '');
console.log(` - ${agentName}`);
// Build individual agent bundle
const bundle = await this.buildExpansionAgentBundle(packName, packDir, agentName);
// Write to all output directories
for (const outputDir of outputDirs) {
const agentsOutputDir = path.join(outputDir, "agents");
for (const outputDir of outputDirectories) {
const agentsOutputDir = path.join(outputDir, 'agents');
await fs.mkdir(agentsOutputDir, { recursive: true });
const outputFile = path.join(agentsOutputDir, `${agentName}.txt`);
await fs.writeFile(outputFile, bundle, "utf8");
await fs.writeFile(outputFile, bundle, 'utf8');
}
}
}
} catch (error) {
} catch {
console.debug(` No agents directory found for ${packName}`);
}
// Build team bundle
const agentTeamsDir = path.join(packDir, "agent-teams");
const agentTeamsDir = path.join(packDir, 'agent-teams');
try {
const teamFiles = await fs.readdir(agentTeamsDir);
const teamFile = teamFiles.find((f) => f.endsWith(".yaml"));
const teamFile = teamFiles.find((f) => f.endsWith('.yaml'));
if (teamFile) {
console.log(` Building team bundle for ${packName}`);
@@ -356,17 +366,17 @@ These references map directly to bundle sections:
const bundle = await this.buildExpansionTeamBundle(packName, packDir, teamConfigPath);
// Write to all output directories
for (const outputDir of outputDirs) {
const teamsOutputDir = path.join(outputDir, "teams");
for (const outputDir of outputDirectories) {
const teamsOutputDir = path.join(outputDir, 'teams');
await fs.mkdir(teamsOutputDir, { recursive: true });
const outputFile = path.join(teamsOutputDir, teamFile.replace(".yaml", ".txt"));
await fs.writeFile(outputFile, bundle, "utf8");
const outputFile = path.join(teamsOutputDir, teamFile.replace('.yaml', '.txt'));
await fs.writeFile(outputFile, bundle, 'utf8');
console.log(` ✓ Created bundle: ${path.relative(this.rootDir, outputFile)}`);
}
} else {
console.warn(` ⚠ No team configuration found in ${packName}/agent-teams/`);
}
} catch (error) {
} catch {
console.warn(` ⚠ No agent-teams directory found for ${packName}`);
}
}
@@ -376,16 +386,16 @@ These references map directly to bundle sections:
const sections = [template];
// Add agent configuration
const agentPath = path.join(packDir, "agents", `${agentName}.md`);
const agentContent = await fs.readFile(agentPath, "utf8");
const agentPath = path.join(packDir, 'agents', `${agentName}.md`);
const agentContent = await fs.readFile(agentPath, 'utf8');
const agentWebPath = this.convertToWebPath(agentPath, packName);
sections.push(this.formatSection(agentWebPath, agentContent, packName));
// Resolve and add agent dependencies
const yamlContent = yamlUtils.extractYamlFromAgent(agentContent);
const yamlContent = yamlUtilities.extractYamlFromAgent(agentContent);
if (yamlContent) {
try {
const yaml = require("js-yaml");
const yaml = require('js-yaml');
const agentConfig = yaml.load(yamlContent);
if (agentConfig.dependencies) {
@@ -398,59 +408,43 @@ These references map directly to bundle sections:
// Try expansion pack first
const resourcePath = path.join(packDir, resourceType, resourceName);
try {
const resourceContent = await fs.readFile(resourcePath, "utf8");
const resourceContent = await fs.readFile(resourcePath, 'utf8');
const resourceWebPath = this.convertToWebPath(resourcePath, packName);
sections.push(
this.formatSection(resourceWebPath, resourceContent, packName)
);
sections.push(this.formatSection(resourceWebPath, resourceContent, packName));
found = true;
} catch (error) {
} catch {
// Not in expansion pack, continue
}
// If not found in expansion pack, try core
if (!found) {
const corePath = path.join(
this.rootDir,
"bmad-core",
resourceType,
resourceName
);
const corePath = path.join(this.rootDir, 'bmad-core', resourceType, resourceName);
try {
const coreContent = await fs.readFile(corePath, "utf8");
const coreContent = await fs.readFile(corePath, 'utf8');
const coreWebPath = this.convertToWebPath(corePath, packName);
sections.push(
this.formatSection(coreWebPath, coreContent, packName)
);
sections.push(this.formatSection(coreWebPath, coreContent, packName));
found = true;
} catch (error) {
} catch {
// Not in core either, continue
}
}
// If not found in core, try common folder
if (!found) {
const commonPath = path.join(
this.rootDir,
"common",
resourceType,
resourceName
);
const commonPath = path.join(this.rootDir, 'common', resourceType, resourceName);
try {
const commonContent = await fs.readFile(commonPath, "utf8");
const commonContent = await fs.readFile(commonPath, 'utf8');
const commonWebPath = this.convertToWebPath(commonPath, packName);
sections.push(
this.formatSection(commonWebPath, commonContent, packName)
);
sections.push(this.formatSection(commonWebPath, commonContent, packName));
found = true;
} catch (error) {
} catch {
// Not in common either, continue
}
}
if (!found) {
console.warn(
` ⚠ Dependency ${resourceType}#${resourceName} not found in expansion pack or core`
` ⚠ Dependency ${resourceType}#${resourceName} not found in expansion pack or core`,
);
}
}
@@ -462,7 +456,7 @@ These references map directly to bundle sections:
}
}
return sections.join("\n");
return sections.join('\n');
}
async buildExpansionTeamBundle(packName, packDir, teamConfigPath) {
@@ -471,38 +465,38 @@ These references map directly to bundle sections:
const sections = [template];
// Add team configuration and parse to get agent list
const teamContent = await fs.readFile(teamConfigPath, "utf8");
const teamFileName = path.basename(teamConfigPath, ".yaml");
const teamContent = await fs.readFile(teamConfigPath, 'utf8');
const teamFileName = path.basename(teamConfigPath, '.yaml');
const teamConfig = this.parseYaml(teamContent);
const teamWebPath = this.convertToWebPath(teamConfigPath, packName);
sections.push(this.formatSection(teamWebPath, teamContent, packName));
// Get list of expansion pack agents
const expansionAgents = new Set();
const agentsDir = path.join(packDir, "agents");
const agentsDir = path.join(packDir, 'agents');
try {
const agentFiles = await fs.readdir(agentsDir);
for (const agentFile of agentFiles.filter((f) => f.endsWith(".md"))) {
const agentName = agentFile.replace(".md", "");
for (const agentFile of agentFiles.filter((f) => f.endsWith('.md'))) {
const agentName = agentFile.replace('.md', '');
expansionAgents.add(agentName);
}
} catch (error) {
} catch {
console.warn(` ⚠ No agents directory found in ${packName}`);
}
// Build a map of all available expansion pack resources for override checking
const expansionResources = new Map();
const resourceDirs = ["templates", "tasks", "checklists", "workflows", "data"];
for (const resourceDir of resourceDirs) {
const resourceDirectories = ['templates', 'tasks', 'checklists', 'workflows', 'data'];
for (const resourceDir of resourceDirectories) {
const resourcePath = path.join(packDir, resourceDir);
try {
const resourceFiles = await fs.readdir(resourcePath);
for (const resourceFile of resourceFiles.filter(
(f) => f.endsWith(".md") || f.endsWith(".yaml")
(f) => f.endsWith('.md') || f.endsWith('.yaml'),
)) {
expansionResources.set(`${resourceDir}#${resourceFile}`, true);
}
} catch (error) {
} catch {
// Directory might not exist, that's fine
}
}
@@ -511,9 +505,9 @@ These references map directly to bundle sections:
const agentsToProcess = teamConfig.agents || [];
// Ensure bmad-orchestrator is always included for teams
if (!agentsToProcess.includes("bmad-orchestrator")) {
if (!agentsToProcess.includes('bmad-orchestrator')) {
console.warn(` ⚠ Team ${teamFileName} missing bmad-orchestrator, adding automatically`);
agentsToProcess.unshift("bmad-orchestrator");
agentsToProcess.unshift('bmad-orchestrator');
}
// Track all dependencies from all agents (deduplicated)
@@ -523,7 +517,7 @@ These references map directly to bundle sections:
if (expansionAgents.has(agentId)) {
// Use expansion pack version (override)
const agentPath = path.join(agentsDir, `${agentId}.md`);
const agentContent = await fs.readFile(agentPath, "utf8");
const agentContent = await fs.readFile(agentPath, 'utf8');
const expansionAgentWebPath = this.convertToWebPath(agentPath, packName);
sections.push(this.formatSection(expansionAgentWebPath, agentContent, packName));
@@ -551,13 +545,13 @@ These references map directly to bundle sections:
} else {
// Use core BMad version
try {
const coreAgentPath = path.join(this.rootDir, "bmad-core", "agents", `${agentId}.md`);
const coreAgentContent = await fs.readFile(coreAgentPath, "utf8");
const coreAgentPath = path.join(this.rootDir, 'bmad-core', 'agents', `${agentId}.md`);
const coreAgentContent = await fs.readFile(coreAgentPath, 'utf8');
const coreAgentWebPath = this.convertToWebPath(coreAgentPath, packName);
sections.push(this.formatSection(coreAgentWebPath, coreAgentContent, packName));
// Parse and collect dependencies from core agent
const yamlContent = yamlUtils.extractYamlFromAgent(coreAgentContent, true);
const yamlContent = yamlUtilities.extractYamlFromAgent(coreAgentContent, true);
if (yamlContent) {
try {
const agentConfig = this.parseYaml(yamlContent);
@@ -577,7 +571,7 @@ These references map directly to bundle sections:
console.debug(`Failed to parse agent YAML for ${agentId}:`, error.message);
}
}
} catch (error) {
} catch {
console.warn(` ⚠ Agent ${agentId} not found in core or expansion pack`);
}
}
@@ -593,38 +587,38 @@ These references map directly to bundle sections:
// We know it exists in expansion pack, find and load it
const expansionPath = path.join(packDir, dep.type, dep.name);
try {
const content = await fs.readFile(expansionPath, "utf8");
const content = await fs.readFile(expansionPath, 'utf8');
const expansionWebPath = this.convertToWebPath(expansionPath, packName);
sections.push(this.formatSection(expansionWebPath, content, packName));
console.log(` ✓ Using expansion override for ${key}`);
found = true;
} catch (error) {
} catch {
// Try next extension
}
}
// If not found in expansion pack (or doesn't exist there), try core
if (!found) {
const corePath = path.join(this.rootDir, "bmad-core", dep.type, dep.name);
const corePath = path.join(this.rootDir, 'bmad-core', dep.type, dep.name);
try {
const content = await fs.readFile(corePath, "utf8");
const content = await fs.readFile(corePath, 'utf8');
const coreWebPath = this.convertToWebPath(corePath, packName);
sections.push(this.formatSection(coreWebPath, content, packName));
found = true;
} catch (error) {
} catch {
// Not in core either, continue
}
}
// If not found in core, try common folder
if (!found) {
const commonPath = path.join(this.rootDir, "common", dep.type, dep.name);
const commonPath = path.join(this.rootDir, 'common', dep.type, dep.name);
try {
const content = await fs.readFile(commonPath, "utf8");
const content = await fs.readFile(commonPath, 'utf8');
const commonWebPath = this.convertToWebPath(commonPath, packName);
sections.push(this.formatSection(commonWebPath, content, packName));
found = true;
} catch (error) {
} catch {
// Not in common either, continue
}
}
@@ -635,16 +629,16 @@ These references map directly to bundle sections:
}
// Add remaining expansion pack resources not already included as dependencies
for (const resourceDir of resourceDirs) {
for (const resourceDir of resourceDirectories) {
const resourcePath = path.join(packDir, resourceDir);
try {
const resourceFiles = await fs.readdir(resourcePath);
for (const resourceFile of resourceFiles.filter(
(f) => f.endsWith(".md") || f.endsWith(".yaml")
(f) => f.endsWith('.md') || f.endsWith('.yaml'),
)) {
const filePath = path.join(resourcePath, resourceFile);
const fileContent = await fs.readFile(filePath, "utf8");
const fileName = resourceFile.replace(/\.(md|yaml)$/, "");
const fileContent = await fs.readFile(filePath, 'utf8');
const fileName = resourceFile.replace(/\.(md|yaml)$/, '');
// Only add if not already included as a dependency
const resourceKey = `${resourceDir}#${fileName}`;
@@ -654,21 +648,21 @@ These references map directly to bundle sections:
sections.push(this.formatSection(resourceWebPath, fileContent, packName));
}
}
} catch (error) {
} catch {
// Directory might not exist, that's fine
}
}
return sections.join("\n");
return sections.join('\n');
}
async listExpansionPacks() {
const expansionPacksDir = path.join(this.rootDir, "expansion-packs");
const expansionPacksDir = path.join(this.rootDir, 'expansion-packs');
try {
const entries = await fs.readdir(expansionPacksDir, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
} catch (error) {
console.warn("No expansion-packs directory found");
} catch {
console.warn('No expansion-packs directory found');
return [];
}
}

View File

@@ -1,11 +1,9 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const args = process.argv.slice(2);
const bumpType = args[0] || 'minor'; // default to minor
const arguments_ = process.argv.slice(2);
const bumpType = arguments_[0] || 'minor'; // default to minor
if (!['major', 'minor', 'patch'].includes(bumpType)) {
console.log('Usage: node bump-all-versions.js [major|minor|patch]');
@@ -15,22 +13,26 @@ if (!['major', 'minor', 'patch'].includes(bumpType)) {
function bumpVersion(currentVersion, type) {
const [major, minor, patch] = currentVersion.split('.').map(Number);
switch (type) {
case 'major':
case 'major': {
return `${major + 1}.0.0`;
case 'minor':
}
case 'minor': {
return `${major}.${minor + 1}.0`;
case 'patch':
}
case 'patch': {
return `${major}.${minor}.${patch + 1}`;
default:
}
default: {
return currentVersion;
}
}
}
async function bumpAllVersions() {
const updatedItems = [];
// First, bump the core version (package.json)
const packagePath = path.join(__dirname, '..', 'package.json');
try {
@@ -38,69 +40,76 @@ async function bumpAllVersions() {
const packageJson = JSON.parse(packageContent);
const oldCoreVersion = packageJson.version || '1.0.0';
const newCoreVersion = bumpVersion(oldCoreVersion, bumpType);
packageJson.version = newCoreVersion;
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2) + '\n');
updatedItems.push({ type: 'core', name: 'BMad Core', oldVersion: oldCoreVersion, newVersion: newCoreVersion });
updatedItems.push({
type: 'core',
name: 'BMad Core',
oldVersion: oldCoreVersion,
newVersion: newCoreVersion,
});
console.log(`✓ BMad Core (package.json): ${oldCoreVersion}${newCoreVersion}`);
} catch (error) {
console.error(`✗ Failed to update BMad Core: ${error.message}`);
}
// Then, bump all expansion packs
const expansionPacksDir = path.join(__dirname, '..', 'expansion-packs');
try {
const entries = fs.readdirSync(expansionPacksDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'README.md') {
const packId = entry.name;
const configPath = path.join(expansionPacksDir, packId, 'config.yaml');
if (fs.existsSync(configPath)) {
try {
const configContent = fs.readFileSync(configPath, 'utf8');
const config = yaml.load(configContent);
const oldVersion = config.version || '1.0.0';
const newVersion = bumpVersion(oldVersion, bumpType);
config.version = newVersion;
const updatedYaml = yaml.dump(config, { indent: 2 });
fs.writeFileSync(configPath, updatedYaml);
updatedItems.push({ type: 'expansion', name: packId, oldVersion, newVersion });
console.log(`${packId}: ${oldVersion}${newVersion}`);
} catch (error) {
console.error(`✗ Failed to update ${packId}: ${error.message}`);
}
}
}
}
if (updatedItems.length > 0) {
const coreCount = updatedItems.filter(i => i.type === 'core').length;
const expansionCount = updatedItems.filter(i => i.type === 'expansion').length;
console.log(`\n✓ Successfully bumped ${updatedItems.length} item(s) with ${bumpType} version bump`);
const coreCount = updatedItems.filter((index) => index.type === 'core').length;
const expansionCount = updatedItems.filter((index) => index.type === 'expansion').length;
console.log(
`\n✓ Successfully bumped ${updatedItems.length} item(s) with ${bumpType} version bump`,
);
if (coreCount > 0) console.log(` - ${coreCount} core`);
if (expansionCount > 0) console.log(` - ${expansionCount} expansion pack(s)`);
console.log('\nNext steps:');
console.log('1. Test the changes');
console.log('2. Commit: git add -A && git commit -m "chore: bump all versions (' + bumpType + ')"');
console.log(
'2. Commit: git add -A && git commit -m "chore: bump all versions (' + bumpType + ')"',
);
} else {
console.log('No items found to update');
}
} catch (error) {
console.error('Error reading expansion packs directory:', error.message);
process.exit(1);
}
}
bumpAllVersions();
bumpAllVersions();

View File

@@ -1,17 +1,15 @@
#!/usr/bin/env node
// Load required modules
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
// Parse CLI arguments
const args = process.argv.slice(2);
const packId = args[0];
const bumpType = args[1] || 'minor';
const arguments_ = process.argv.slice(2);
const packId = arguments_[0];
const bumpType = arguments_[1] || 'minor';
// Validate arguments
if (!packId || args.length > 2) {
if (!packId || arguments_.length > 2) {
console.log('Usage: node bump-expansion-version.js <expansion-pack-id> [major|minor|patch]');
console.log('Default: minor');
console.log('Example: node bump-expansion-version.js bmad-creator-tools patch');
@@ -28,10 +26,18 @@ function bumpVersion(currentVersion, type) {
const [major, minor, patch] = currentVersion.split('.').map(Number);
switch (type) {
case 'major': return `${major + 1}.0.0`;
case 'minor': return `${major}.${minor + 1}.0`;
case 'patch': return `${major}.${minor}.${patch + 1}`;
default: return currentVersion;
case 'major': {
return `${major + 1}.0.0`;
}
case 'minor': {
return `${major}.${minor + 1}.0`;
}
case 'patch': {
return `${major}.${minor}.${patch + 1}`;
}
default: {
return currentVersion;
}
}
}
@@ -47,11 +53,11 @@ async function updateVersion() {
const packsDir = path.join(__dirname, '..', 'expansion-packs');
const entries = fs.readdirSync(packsDir, { withFileTypes: true });
entries.forEach(entry => {
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
console.log(` - ${entry.name}`);
}
});
}
process.exit(1);
}
@@ -72,8 +78,9 @@ async function updateVersion() {
console.log(`\n✓ Successfully bumped ${packId} with ${bumpType} version bump`);
console.log('\nNext steps:');
console.log(`1. Test the changes`);
console.log(`2. Commit: git add -A && git commit -m "chore: bump ${packId} version (${bumpType})"`);
console.log(
`2. Commit: git add -A && git commit -m "chore: bump ${packId} version (${bumpType})"`,
);
} catch (error) {
console.error('Error updating version:', error.message);
process.exit(1);

View File

@@ -1,10 +1,8 @@
#!/usr/bin/env node
const { Command } = require('commander');
const WebBuilder = require('./builders/web-builder');
const V3ToV4Upgrader = require('./upgraders/v3-to-v4-upgrader');
const IdeSetup = require('./installer/lib/ide-setup');
const path = require('path');
const path = require('node:path');
const program = new Command();
@@ -23,7 +21,7 @@ program
.option('--no-clean', 'Skip cleaning output directories')
.action(async (options) => {
const builder = new WebBuilder({
rootDir: process.cwd()
rootDir: process.cwd(),
});
try {
@@ -66,7 +64,7 @@ program
.option('--no-clean', 'Skip cleaning output directories')
.action(async (options) => {
const builder = new WebBuilder({
rootDir: process.cwd()
rootDir: process.cwd(),
});
try {
@@ -92,7 +90,7 @@ program
const builder = new WebBuilder({ rootDir: process.cwd() });
const agents = await builder.resolver.listAgents();
console.log('Available agents:');
agents.forEach(agent => console.log(` - ${agent}`));
for (const agent of agents) console.log(` - ${agent}`);
process.exit(0);
});
@@ -103,7 +101,7 @@ program
const builder = new WebBuilder({ rootDir: process.cwd() });
const expansions = await builder.listExpansionPacks();
console.log('Available expansion packs:');
expansions.forEach(expansion => console.log(` - ${expansion}`));
for (const expansion of expansions) console.log(` - ${expansion}`);
process.exit(0);
});
@@ -116,19 +114,19 @@ program
// Validate by attempting to build all agents and teams
const agents = await builder.resolver.listAgents();
const teams = await builder.resolver.listTeams();
console.log('Validating agents...');
for (const agent of agents) {
await builder.resolver.resolveAgentDependencies(agent);
console.log(`${agent}`);
}
console.log('\nValidating teams...');
for (const team of teams) {
await builder.resolver.resolveTeamDependencies(team);
console.log(`${team}`);
}
console.log('\nAll configurations are valid!');
} catch (error) {
console.error('Validation failed:', error.message);
@@ -147,8 +145,8 @@ program
await upgrader.upgrade({
projectPath: options.project,
dryRun: options.dryRun,
backup: options.backup
backup: options.backup,
});
});
program.parse();
program.parse();

View File

@@ -1,7 +1,7 @@
const fs = require("fs-extra");
const path = require("node:path");
const os = require("node:os");
const { isBinaryFile } = require("./binary.js");
const fs = require('fs-extra');
const path = require('node:path');
const os = require('node:os');
const { isBinaryFile } = require('./binary.js');
/**
* Aggregate file contents with bounded concurrency.
@@ -22,7 +22,7 @@ async function aggregateFileContents(files, rootDir, spinner = null) {
// Automatic concurrency selection based on CPU count and workload size.
// - Base on 2x logical CPUs, clamped to [2, 64]
// - For very small workloads, avoid excessive parallelism
const cpuCount = (os.cpus && Array.isArray(os.cpus()) ? os.cpus().length : (os.cpus?.length || 4));
const cpuCount = os.cpus && Array.isArray(os.cpus()) ? os.cpus().length : os.cpus?.length || 4;
let concurrency = Math.min(64, Math.max(2, (Number(cpuCount) || 4) * 2));
if (files.length > 0 && files.length < concurrency) {
concurrency = Math.max(1, Math.min(concurrency, Math.ceil(files.length / 2)));
@@ -37,16 +37,16 @@ async function aggregateFileContents(files, rootDir, spinner = null) {
const binary = await isBinaryFile(filePath);
if (binary) {
const size = (await fs.stat(filePath)).size;
const { size } = await fs.stat(filePath);
results.binaryFiles.push({ path: relativePath, absolutePath: filePath, size });
} else {
const content = await fs.readFile(filePath, "utf8");
const content = await fs.readFile(filePath, 'utf8');
results.textFiles.push({
path: relativePath,
absolutePath: filePath,
content,
size: content.length,
lines: content.split("\n").length,
lines: content.split('\n').length,
});
}
} catch (error) {
@@ -63,8 +63,8 @@ async function aggregateFileContents(files, rootDir, spinner = null) {
}
}
for (let i = 0; i < files.length; i += concurrency) {
const slice = files.slice(i, i + concurrency);
for (let index = 0; index < files.length; index += concurrency) {
const slice = files.slice(index, index + concurrency);
await Promise.all(slice.map(processOne));
}

View File

@@ -1,6 +1,6 @@
const fsp = require("node:fs/promises");
const path = require("node:path");
const { Buffer } = require("node:buffer");
const fsp = require('node:fs/promises');
const path = require('node:path');
const { Buffer } = require('node:buffer');
/**
* Efficiently determine if a file is binary without reading the whole file.
@@ -13,25 +13,54 @@ async function isBinaryFile(filePath) {
try {
const stats = await fsp.stat(filePath);
if (stats.isDirectory()) {
throw new Error("EISDIR: illegal operation on a directory");
throw new Error('EISDIR: illegal operation on a directory');
}
const binaryExtensions = new Set([
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".svg",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".zip", ".tar", ".gz", ".rar", ".7z",
".exe", ".dll", ".so", ".dylib",
".mp3", ".mp4", ".avi", ".mov", ".wav",
".ttf", ".otf", ".woff", ".woff2",
".bin", ".dat", ".db", ".sqlite",
'.jpg',
'.jpeg',
'.png',
'.gif',
'.bmp',
'.ico',
'.svg',
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.zip',
'.tar',
'.gz',
'.rar',
'.7z',
'.exe',
'.dll',
'.so',
'.dylib',
'.mp3',
'.mp4',
'.avi',
'.mov',
'.wav',
'.ttf',
'.otf',
'.woff',
'.woff2',
'.bin',
'.dat',
'.db',
'.sqlite',
]);
const ext = path.extname(filePath).toLowerCase();
if (binaryExtensions.has(ext)) return true;
const extension = path.extname(filePath).toLowerCase();
if (binaryExtensions.has(extension)) return true;
if (stats.size === 0) return false;
const sampleSize = Math.min(4096, stats.size);
const fd = await fsp.open(filePath, "r");
const fd = await fsp.open(filePath, 'r');
try {
const buffer = Buffer.allocUnsafe(sampleSize);
const { bytesRead } = await fd.read(buffer, 0, sampleSize, 0);
@@ -41,9 +70,7 @@ async function isBinaryFile(filePath) {
await fd.close();
}
} catch (error) {
console.warn(
`Warning: Could not determine if file is binary: ${filePath} - ${error.message}`,
);
console.warn(`Warning: Could not determine if file is binary: ${filePath} - ${error.message}`);
return false;
}
}

View File

@@ -1,18 +1,21 @@
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { glob } = require("glob");
const { loadIgnore } = require("./ignoreRules.js");
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { glob } = require('glob');
const { loadIgnore } = require('./ignoreRules.js');
const pExecFile = promisify(execFile);
async function isGitRepo(rootDir) {
try {
const { stdout } = await pExecFile("git", [
"rev-parse",
"--is-inside-work-tree",
], { cwd: rootDir });
return String(stdout || "").toString().trim() === "true";
const { stdout } = await pExecFile('git', ['rev-parse', '--is-inside-work-tree'], {
cwd: rootDir,
});
return (
String(stdout || '')
.toString()
.trim() === 'true'
);
} catch {
return false;
}
@@ -20,12 +23,10 @@ async function isGitRepo(rootDir) {
async function gitListFiles(rootDir) {
try {
const { stdout } = await pExecFile("git", [
"ls-files",
"-co",
"--exclude-standard",
], { cwd: rootDir });
return String(stdout || "")
const { stdout } = await pExecFile('git', ['ls-files', '-co', '--exclude-standard'], {
cwd: rootDir,
});
return String(stdout || '')
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
@@ -48,14 +49,14 @@ async function discoverFiles(rootDir, options = {}) {
const { filter } = await loadIgnore(rootDir);
// Try git first
if (preferGit && await isGitRepo(rootDir)) {
if (preferGit && (await isGitRepo(rootDir))) {
const relFiles = await gitListFiles(rootDir);
const filteredRel = relFiles.filter((p) => filter(p));
return filteredRel.map((p) => path.resolve(rootDir, p));
}
// Glob fallback
const globbed = await glob("**/*", {
const globbed = await glob('**/*', {
cwd: rootDir,
nodir: true,
dot: true,

View File

@@ -1,8 +1,8 @@
const path = require("node:path");
const discovery = require("./discovery.js");
const ignoreRules = require("./ignoreRules.js");
const { isBinaryFile } = require("./binary.js");
const { aggregateFileContents } = require("./aggregate.js");
const path = require('node:path');
const discovery = require('./discovery.js');
const ignoreRules = require('./ignoreRules.js');
const { isBinaryFile } = require('./binary.js');
const { aggregateFileContents } = require('./aggregate.js');
// Backward-compatible signature; delegate to central loader
async function parseGitignore(gitignorePath) {
@@ -14,7 +14,7 @@ async function discoverFiles(rootDir) {
// Delegate to discovery module which respects .gitignore and defaults
return await discovery.discoverFiles(rootDir, { preferGit: true });
} catch (error) {
console.error("Error discovering files:", error.message);
console.error('Error discovering files:', error.message);
return [];
}
}

View File

@@ -1,147 +1,147 @@
const fs = require("fs-extra");
const path = require("node:path");
const ignore = require("ignore");
const fs = require('fs-extra');
const path = require('node:path');
const ignore = require('ignore');
// Central default ignore patterns for discovery and filtering.
// These complement .gitignore and are applied regardless of VCS presence.
const DEFAULT_PATTERNS = [
// Project/VCS
"**/.bmad-core/**",
"**/.git/**",
"**/.svn/**",
"**/.hg/**",
"**/.bzr/**",
'**/.bmad-core/**',
'**/.git/**',
'**/.svn/**',
'**/.hg/**',
'**/.bzr/**',
// Package/build outputs
"**/node_modules/**",
"**/bower_components/**",
"**/vendor/**",
"**/packages/**",
"**/build/**",
"**/dist/**",
"**/out/**",
"**/target/**",
"**/bin/**",
"**/obj/**",
"**/release/**",
"**/debug/**",
'**/node_modules/**',
'**/bower_components/**',
'**/vendor/**',
'**/packages/**',
'**/build/**',
'**/dist/**',
'**/out/**',
'**/target/**',
'**/bin/**',
'**/obj/**',
'**/release/**',
'**/debug/**',
// Environments
"**/.venv/**",
"**/venv/**",
"**/.virtualenv/**",
"**/virtualenv/**",
"**/env/**",
'**/.venv/**',
'**/venv/**',
'**/.virtualenv/**',
'**/virtualenv/**',
'**/env/**',
// Logs & coverage
"**/*.log",
"**/npm-debug.log*",
"**/yarn-debug.log*",
"**/yarn-error.log*",
"**/lerna-debug.log*",
"**/coverage/**",
"**/.nyc_output/**",
"**/.coverage/**",
"**/test-results/**",
'**/*.log',
'**/npm-debug.log*',
'**/yarn-debug.log*',
'**/yarn-error.log*',
'**/lerna-debug.log*',
'**/coverage/**',
'**/.nyc_output/**',
'**/.coverage/**',
'**/test-results/**',
// Caches & temp
"**/.cache/**",
"**/.tmp/**",
"**/.temp/**",
"**/tmp/**",
"**/temp/**",
"**/.sass-cache/**",
'**/.cache/**',
'**/.tmp/**',
'**/.temp/**',
'**/tmp/**',
'**/temp/**',
'**/.sass-cache/**',
// IDE/editor
"**/.vscode/**",
"**/.idea/**",
"**/*.swp",
"**/*.swo",
"**/*~",
"**/.project",
"**/.classpath",
"**/.settings/**",
"**/*.sublime-project",
"**/*.sublime-workspace",
'**/.vscode/**',
'**/.idea/**',
'**/*.swp',
'**/*.swo',
'**/*~',
'**/.project',
'**/.classpath',
'**/.settings/**',
'**/*.sublime-project',
'**/*.sublime-workspace',
// Lockfiles
"**/package-lock.json",
"**/yarn.lock",
"**/pnpm-lock.yaml",
"**/composer.lock",
"**/Pipfile.lock",
'**/package-lock.json',
'**/yarn.lock',
'**/pnpm-lock.yaml',
'**/composer.lock',
'**/Pipfile.lock',
// Python/Java/compiled artifacts
"**/*.pyc",
"**/*.pyo",
"**/*.pyd",
"**/__pycache__/**",
"**/*.class",
"**/*.jar",
"**/*.war",
"**/*.ear",
"**/*.o",
"**/*.so",
"**/*.dll",
"**/*.exe",
'**/*.pyc',
'**/*.pyo',
'**/*.pyd',
'**/__pycache__/**',
'**/*.class',
'**/*.jar',
'**/*.war',
'**/*.ear',
'**/*.o',
'**/*.so',
'**/*.dll',
'**/*.exe',
// System junk
"**/lib64/**",
"**/.venv/lib64/**",
"**/venv/lib64/**",
"**/_site/**",
"**/.jekyll-cache/**",
"**/.jekyll-metadata",
"**/.DS_Store",
"**/.DS_Store?",
"**/._*",
"**/.Spotlight-V100/**",
"**/.Trashes/**",
"**/ehthumbs.db",
"**/Thumbs.db",
"**/desktop.ini",
'**/lib64/**',
'**/.venv/lib64/**',
'**/venv/lib64/**',
'**/_site/**',
'**/.jekyll-cache/**',
'**/.jekyll-metadata',
'**/.DS_Store',
'**/.DS_Store?',
'**/._*',
'**/.Spotlight-V100/**',
'**/.Trashes/**',
'**/ehthumbs.db',
'**/Thumbs.db',
'**/desktop.ini',
// XML outputs
"**/flattened-codebase.xml",
"**/repomix-output.xml",
'**/flattened-codebase.xml',
'**/repomix-output.xml',
// Images, media, fonts, archives, docs, dylibs
"**/*.jpg",
"**/*.jpeg",
"**/*.png",
"**/*.gif",
"**/*.bmp",
"**/*.ico",
"**/*.svg",
"**/*.pdf",
"**/*.doc",
"**/*.docx",
"**/*.xls",
"**/*.xlsx",
"**/*.ppt",
"**/*.pptx",
"**/*.zip",
"**/*.tar",
"**/*.gz",
"**/*.rar",
"**/*.7z",
"**/*.dylib",
"**/*.mp3",
"**/*.mp4",
"**/*.avi",
"**/*.mov",
"**/*.wav",
"**/*.ttf",
"**/*.otf",
"**/*.woff",
"**/*.woff2",
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'**/*.gif',
'**/*.bmp',
'**/*.ico',
'**/*.svg',
'**/*.pdf',
'**/*.doc',
'**/*.docx',
'**/*.xls',
'**/*.xlsx',
'**/*.ppt',
'**/*.pptx',
'**/*.zip',
'**/*.tar',
'**/*.gz',
'**/*.rar',
'**/*.7z',
'**/*.dylib',
'**/*.mp3',
'**/*.mp4',
'**/*.avi',
'**/*.mov',
'**/*.wav',
'**/*.ttf',
'**/*.otf',
'**/*.woff',
'**/*.woff2',
// Env files
"**/.env",
"**/.env.*",
"**/*.env",
'**/.env',
'**/.env.*',
'**/*.env',
// Misc
"**/junit.xml",
'**/junit.xml',
];
async function readIgnoreFile(filePath) {
try {
if (!await fs.pathExists(filePath)) return [];
const content = await fs.readFile(filePath, "utf8");
if (!(await fs.pathExists(filePath))) return [];
const content = await fs.readFile(filePath, 'utf8');
return content
.split("\n")
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
} catch (err) {
.filter((l) => l && !l.startsWith('#'));
} catch {
return [];
}
}
@@ -153,18 +153,18 @@ async function parseGitignore(gitignorePath) {
async function loadIgnore(rootDir, extraPatterns = []) {
const ig = ignore();
const gitignorePath = path.join(rootDir, ".gitignore");
const gitignorePath = path.join(rootDir, '.gitignore');
const patterns = [
...await readIgnoreFile(gitignorePath),
...(await readIgnoreFile(gitignorePath)),
...DEFAULT_PATTERNS,
...extraPatterns,
];
// De-duplicate
const unique = Array.from(new Set(patterns.map((p) => String(p))));
const unique = [...new Set(patterns.map(String))];
ig.add(unique);
// Include-only filter: return true if path should be included
const filter = (relativePath) => !ig.ignores(relativePath.replace(/\\/g, "/"));
const filter = (relativePath) => !ig.ignores(relativePath.replaceAll('\\', '/'));
return { ig, filter, patterns: unique };
}

View File

@@ -1,20 +1,14 @@
#!/usr/bin/env node
const { Command } = require("commander");
const fs = require("fs-extra");
const path = require("node:path");
const process = require("node:process");
const { Command } = require('commander');
const fs = require('fs-extra');
const path = require('node:path');
const process = require('node:process');
// Modularized components
const { findProjectRoot } = require("./projectRoot.js");
const { promptYesNo, promptPath } = require("./prompts.js");
const {
discoverFiles,
filterFiles,
aggregateFileContents,
} = require("./files.js");
const { generateXMLOutput } = require("./xml.js");
const { calculateStatistics } = require("./stats.js");
const { findProjectRoot } = require('./projectRoot.js');
const { promptYesNo, promptPath } = require('./prompts.js');
const { discoverFiles, filterFiles, aggregateFileContents } = require('./files.js');
const { generateXMLOutput } = require('./xml.js');
const { calculateStatistics } = require('./stats.js');
/**
* Recursively discover all files in a directory
@@ -73,30 +67,30 @@ const { calculateStatistics } = require("./stats.js");
const program = new Command();
program
.name("bmad-flatten")
.description("BMad-Method codebase flattener tool")
.version("1.0.0")
.option("-i, --input <path>", "Input directory to flatten", process.cwd())
.option("-o, --output <path>", "Output file path", "flattened-codebase.xml")
.name('bmad-flatten')
.description('BMad-Method codebase flattener tool')
.version('1.0.0')
.option('-i, --input <path>', 'Input directory to flatten', process.cwd())
.option('-o, --output <path>', 'Output file path', 'flattened-codebase.xml')
.action(async (options) => {
let inputDir = path.resolve(options.input);
let outputPath = path.resolve(options.output);
// Detect if user explicitly provided -i/--input or -o/--output
const argv = process.argv.slice(2);
const userSpecifiedInput = argv.some((a) =>
a === "-i" || a === "--input" || a.startsWith("--input=")
const userSpecifiedInput = argv.some(
(a) => a === '-i' || a === '--input' || a.startsWith('--input='),
);
const userSpecifiedOutput = argv.some((a) =>
a === "-o" || a === "--output" || a.startsWith("--output=")
const userSpecifiedOutput = argv.some(
(a) => a === '-o' || a === '--output' || a.startsWith('--output='),
);
const noPathArgs = !userSpecifiedInput && !userSpecifiedOutput;
const noPathArguments = !userSpecifiedInput && !userSpecifiedOutput;
if (noPathArgs) {
if (noPathArguments) {
const detectedRoot = await findProjectRoot(process.cwd());
const suggestedOutput = detectedRoot
? path.join(detectedRoot, "flattened-codebase.xml")
: path.resolve("flattened-codebase.xml");
? path.join(detectedRoot, 'flattened-codebase.xml')
: path.resolve('flattened-codebase.xml');
if (detectedRoot) {
const useDefaults = await promptYesNo(
@@ -107,26 +101,25 @@ program
inputDir = detectedRoot;
outputPath = suggestedOutput;
} else {
inputDir = await promptPath(
"Enter input directory path",
process.cwd(),
);
inputDir = await promptPath('Enter input directory path', process.cwd());
outputPath = await promptPath(
"Enter output file path",
path.join(inputDir, "flattened-codebase.xml"),
'Enter output file path',
path.join(inputDir, 'flattened-codebase.xml'),
);
}
} else {
console.log("Could not auto-detect a project root.");
inputDir = await promptPath(
"Enter input directory path",
process.cwd(),
);
console.log('Could not auto-detect a project root.');
inputDir = await promptPath('Enter input directory path', process.cwd());
outputPath = await promptPath(
"Enter output file path",
path.join(inputDir, "flattened-codebase.xml"),
'Enter output file path',
path.join(inputDir, 'flattened-codebase.xml'),
);
}
} else {
console.error(
'Could not auto-detect a project root and no arguments were provided. Please specify -i/--input and -o/--output.',
);
process.exit(1);
}
// Ensure output directory exists
@@ -134,24 +127,23 @@ program
try {
// Verify input directory exists
if (!await fs.pathExists(inputDir)) {
if (!(await fs.pathExists(inputDir))) {
console.error(`❌ Error: Input directory does not exist: ${inputDir}`);
process.exit(1);
}
// Import ora dynamically
const { default: ora } = await import("ora");
const { default: ora } = await import('ora');
// Start file discovery with spinner
const discoverySpinner = ora("🔍 Discovering files...").start();
const discoverySpinner = ora('🔍 Discovering files...').start();
const files = await discoverFiles(inputDir);
const filteredFiles = await filterFiles(files, inputDir);
discoverySpinner.succeed(
`📁 Found ${filteredFiles.length} files to include`,
);
discoverySpinner.succeed(`📁 Found ${filteredFiles.length} files to include`);
// Process files with progress tracking
const processingSpinner = ora("📄 Processing files...").start();
console.log('Reading file contents');
const processingSpinner = ora('📄 Processing files...').start();
const aggregatedContent = await aggregateFileContents(
filteredFiles,
inputDir,
@@ -165,31 +157,23 @@ program
}
// Generate XML output using streaming
const xmlSpinner = ora("🔧 Generating XML output...").start();
const xmlSpinner = ora('🔧 Generating XML output...').start();
await generateXMLOutput(aggregatedContent, outputPath);
xmlSpinner.succeed("📝 XML generation completed");
xmlSpinner.succeed('📝 XML generation completed');
// Calculate and display statistics
const outputStats = await fs.stat(outputPath);
const stats = await calculateStatistics(
aggregatedContent,
outputStats.size,
inputDir,
);
const stats = await calculateStatistics(aggregatedContent, outputStats.size, inputDir);
// Display completion summary
console.log("\n📊 Completion Summary:");
console.log('\n📊 Completion Summary:');
console.log(
`✅ Successfully processed ${filteredFiles.length} files into ${
path.basename(outputPath)
}`,
`✅ Successfully processed ${filteredFiles.length} files into ${path.basename(outputPath)}`,
);
console.log(`📁 Output file: ${outputPath}`);
console.log(`📏 Total source size: ${stats.totalSize}`);
console.log(`📄 Generated XML size: ${stats.xmlSize}`);
console.log(
`📝 Total lines of code: ${stats.totalLines.toLocaleString()}`,
);
console.log(`📝 Total lines of code: ${stats.totalLines.toLocaleString()}`);
console.log(`🔢 Estimated tokens: ${stats.estimatedTokens}`);
console.log(
`📊 File breakdown: ${stats.textFiles} text, ${stats.binaryFiles} binary, ${stats.errorFiles} errors\n`,
@@ -197,92 +181,75 @@ program
// Ask user if they want detailed stats + markdown report
const generateDetailed = await promptYesNo(
"Generate detailed stats (console + markdown) now?",
'Generate detailed stats (console + markdown) now?',
true,
);
if (generateDetailed) {
// Additional detailed stats
console.log("\n📈 Size Percentiles:");
console.log('\n📈 Size Percentiles:');
console.log(
` Avg: ${
Math.round(stats.avgFileSize).toLocaleString()
} B, Median: ${
Math.round(stats.medianFileSize).toLocaleString()
} B, p90: ${stats.p90.toLocaleString()} B, p95: ${stats.p95.toLocaleString()} B, p99: ${stats.p99.toLocaleString()} B`,
` Avg: ${Math.round(stats.avgFileSize).toLocaleString()} B, Median: ${Math.round(
stats.medianFileSize,
).toLocaleString()} B, p90: ${stats.p90.toLocaleString()} B, p95: ${stats.p95.toLocaleString()} B, p99: ${stats.p99.toLocaleString()} B`,
);
if (Array.isArray(stats.histogram) && stats.histogram.length) {
console.log("\n🧮 Size Histogram:");
if (Array.isArray(stats.histogram) && stats.histogram.length > 0) {
console.log('\n🧮 Size Histogram:');
for (const b of stats.histogram.slice(0, 2)) {
console.log(
` ${b.label}: ${b.count} files, ${b.bytes.toLocaleString()} bytes`,
);
console.log(` ${b.label}: ${b.count} files, ${b.bytes.toLocaleString()} bytes`);
}
if (stats.histogram.length > 2) {
console.log(` … and ${stats.histogram.length - 2} more buckets`);
}
}
if (Array.isArray(stats.byExtension) && stats.byExtension.length) {
if (Array.isArray(stats.byExtension) && stats.byExtension.length > 0) {
const topExt = stats.byExtension.slice(0, 2);
console.log("\n📦 Top Extensions:");
console.log('\n📦 Top Extensions:');
for (const e of topExt) {
const pct = stats.totalBytes
? ((e.bytes / stats.totalBytes) * 100)
: 0;
const pct = stats.totalBytes ? (e.bytes / stats.totalBytes) * 100 : 0;
console.log(
` ${e.ext}: ${e.count} files, ${e.bytes.toLocaleString()} bytes (${
pct.toFixed(2)
}%)`,
` ${e.ext}: ${e.count} files, ${e.bytes.toLocaleString()} bytes (${pct.toFixed(
2,
)}%)`,
);
}
if (stats.byExtension.length > 2) {
console.log(
` … and ${stats.byExtension.length - 2} more extensions`,
);
console.log(` … and ${stats.byExtension.length - 2} more extensions`);
}
}
if (Array.isArray(stats.byDirectory) && stats.byDirectory.length) {
if (Array.isArray(stats.byDirectory) && stats.byDirectory.length > 0) {
const topDir = stats.byDirectory.slice(0, 2);
console.log("\n📂 Top Directories:");
console.log('\n📂 Top Directories:');
for (const d of topDir) {
const pct = stats.totalBytes
? ((d.bytes / stats.totalBytes) * 100)
: 0;
const pct = stats.totalBytes ? (d.bytes / stats.totalBytes) * 100 : 0;
console.log(
` ${d.dir}: ${d.count} files, ${d.bytes.toLocaleString()} bytes (${
pct.toFixed(2)
}%)`,
` ${d.dir}: ${d.count} files, ${d.bytes.toLocaleString()} bytes (${pct.toFixed(
2,
)}%)`,
);
}
if (stats.byDirectory.length > 2) {
console.log(
` … and ${stats.byDirectory.length - 2} more directories`,
);
console.log(` … and ${stats.byDirectory.length - 2} more directories`);
}
}
if (
Array.isArray(stats.depthDistribution) &&
stats.depthDistribution.length
) {
console.log("\n🌳 Depth Distribution:");
if (Array.isArray(stats.depthDistribution) && stats.depthDistribution.length > 0) {
console.log('\n🌳 Depth Distribution:');
const dd = stats.depthDistribution.slice(0, 2);
let line = " " + dd.map((d) => `${d.depth}:${d.count}`).join(" ");
let line = ' ' + dd.map((d) => `${d.depth}:${d.count}`).join(' ');
if (stats.depthDistribution.length > 2) {
line += ` … +${stats.depthDistribution.length - 2} more`;
}
console.log(line);
}
if (Array.isArray(stats.longestPaths) && stats.longestPaths.length) {
console.log("\n🧵 Longest Paths:");
if (Array.isArray(stats.longestPaths) && stats.longestPaths.length > 0) {
console.log('\n🧵 Longest Paths:');
for (const p of stats.longestPaths.slice(0, 2)) {
console.log(
` ${p.path} (${p.length} chars, ${p.size.toLocaleString()} bytes)`,
);
console.log(` ${p.path} (${p.length} chars, ${p.size.toLocaleString()} bytes)`);
}
if (stats.longestPaths.length > 2) {
console.log(` … and ${stats.longestPaths.length - 2} more paths`);
@@ -290,7 +257,7 @@ program
}
if (stats.temporal) {
console.log("\n⏱ Temporal:");
console.log('\n⏱ Temporal:');
if (stats.temporal.oldest) {
console.log(
` Oldest: ${stats.temporal.oldest.path} (${stats.temporal.oldest.mtime})`,
@@ -302,104 +269,82 @@ program
);
}
if (Array.isArray(stats.temporal.ageBuckets)) {
console.log(" Age buckets:");
console.log(' Age buckets:');
for (const b of stats.temporal.ageBuckets.slice(0, 2)) {
console.log(
` ${b.label}: ${b.count} files, ${b.bytes.toLocaleString()} bytes`,
);
console.log(` ${b.label}: ${b.count} files, ${b.bytes.toLocaleString()} bytes`);
}
if (stats.temporal.ageBuckets.length > 2) {
console.log(
` … and ${
stats.temporal.ageBuckets.length - 2
} more buckets`,
);
console.log(` … and ${stats.temporal.ageBuckets.length - 2} more buckets`);
}
}
}
if (stats.quality) {
console.log("\n✅ Quality Signals:");
console.log('\n✅ Quality Signals:');
console.log(` Zero-byte files: ${stats.quality.zeroByteFiles}`);
console.log(` Empty text files: ${stats.quality.emptyTextFiles}`);
console.log(` Hidden files: ${stats.quality.hiddenFiles}`);
console.log(` Symlinks: ${stats.quality.symlinks}`);
console.log(
` Large files (>= ${
(stats.quality.largeThreshold / (1024 * 1024)).toFixed(0)
} MB): ${stats.quality.largeFilesCount}`,
` Large files (>= ${(stats.quality.largeThreshold / (1024 * 1024)).toFixed(
0,
)} MB): ${stats.quality.largeFilesCount}`,
);
console.log(
` Suspiciously large files (>= 100 MB): ${stats.quality.suspiciousLargeFilesCount}`,
);
}
if (
Array.isArray(stats.duplicateCandidates) &&
stats.duplicateCandidates.length
) {
console.log("\n🧬 Duplicate Candidates:");
if (Array.isArray(stats.duplicateCandidates) && stats.duplicateCandidates.length > 0) {
console.log('\n🧬 Duplicate Candidates:');
for (const d of stats.duplicateCandidates.slice(0, 2)) {
console.log(
` ${d.reason}: ${d.count} files @ ${d.size.toLocaleString()} bytes`,
);
console.log(` ${d.reason}: ${d.count} files @ ${d.size.toLocaleString()} bytes`);
}
if (stats.duplicateCandidates.length > 2) {
console.log(
` … and ${stats.duplicateCandidates.length - 2} more groups`,
);
console.log(` … and ${stats.duplicateCandidates.length - 2} more groups`);
}
}
if (typeof stats.compressibilityRatio === "number") {
if (typeof stats.compressibilityRatio === 'number') {
console.log(
`\n🗜️ Compressibility ratio (sampled): ${
(stats.compressibilityRatio * 100).toFixed(2)
}%`,
`\n🗜️ Compressibility ratio (sampled): ${(stats.compressibilityRatio * 100).toFixed(
2,
)}%`,
);
}
if (stats.git && stats.git.isRepo) {
console.log("\n🔧 Git:");
console.log('\n🔧 Git:');
console.log(
` Tracked: ${stats.git.trackedCount} files, ${stats.git.trackedBytes.toLocaleString()} bytes`,
);
console.log(
` Untracked: ${stats.git.untrackedCount} files, ${stats.git.untrackedBytes.toLocaleString()} bytes`,
);
if (
Array.isArray(stats.git.lfsCandidates) &&
stats.git.lfsCandidates.length
) {
console.log(" LFS candidates (top 2):");
if (Array.isArray(stats.git.lfsCandidates) && stats.git.lfsCandidates.length > 0) {
console.log(' LFS candidates (top 2):');
for (const f of stats.git.lfsCandidates.slice(0, 2)) {
console.log(` ${f.path} (${f.size.toLocaleString()} bytes)`);
}
if (stats.git.lfsCandidates.length > 2) {
console.log(
` … and ${stats.git.lfsCandidates.length - 2} more`,
);
console.log(` … and ${stats.git.lfsCandidates.length - 2} more`);
}
}
}
if (Array.isArray(stats.largestFiles) && stats.largestFiles.length) {
console.log("\n📚 Largest Files (top 2):");
if (Array.isArray(stats.largestFiles) && stats.largestFiles.length > 0) {
console.log('\n📚 Largest Files (top 2):');
for (const f of stats.largestFiles.slice(0, 2)) {
// Show LOC for text files when available; omit ext and mtime
let locStr = "";
let locStr = '';
if (!f.isBinary && Array.isArray(aggregatedContent?.textFiles)) {
const tf = aggregatedContent.textFiles.find((t) =>
t.path === f.path
);
if (tf && typeof tf.lines === "number") {
const tf = aggregatedContent.textFiles.find((t) => t.path === f.path);
if (tf && typeof tf.lines === 'number') {
locStr = `, LOC: ${tf.lines.toLocaleString()}`;
}
}
console.log(
` ${f.path} ${f.sizeFormatted} (${
f.percentOfTotal.toFixed(2)
}%)${locStr}`,
` ${f.path} ${f.sizeFormatted} (${f.percentOfTotal.toFixed(2)}%)${locStr}`,
);
}
if (stats.largestFiles.length > 2) {
@@ -409,262 +354,214 @@ program
// Write a comprehensive markdown report next to the XML
{
const mdPath = outputPath.endsWith(".xml")
? outputPath.replace(/\.xml$/i, ".stats.md")
: outputPath + ".stats.md";
const mdPath = outputPath.endsWith('.xml')
? outputPath.replace(/\.xml$/i, '.stats.md')
: outputPath + '.stats.md';
try {
const pct = (num, den) => (den ? ((num / den) * 100) : 0);
const pct = (num, den) => (den ? (num / den) * 100 : 0);
const md = [];
md.push(`# 🧾 Flatten Stats for ${path.basename(outputPath)}`);
md.push("");
md.push("## 📊 Summary");
md.push(`- Total source size: ${stats.totalSize}`);
md.push(`- Generated XML size: ${stats.xmlSize}`);
md.push(
`# 🧾 Flatten Stats for ${path.basename(outputPath)}`,
'',
'## 📊 Summary',
`- Total source size: ${stats.totalSize}`,
`- Generated XML size: ${stats.xmlSize}`,
`- Total lines of code: ${stats.totalLines.toLocaleString()}`,
);
md.push(`- Estimated tokens: ${stats.estimatedTokens}`);
md.push(
`- Estimated tokens: ${stats.estimatedTokens}`,
`- File breakdown: ${stats.textFiles} text, ${stats.binaryFiles} binary, ${stats.errorFiles} errors`,
'',
'## 📈 Size Percentiles',
`Avg: ${Math.round(stats.avgFileSize).toLocaleString()} B, Median: ${Math.round(
stats.medianFileSize,
).toLocaleString()} B, p90: ${stats.p90.toLocaleString()} B, p95: ${stats.p95.toLocaleString()} B, p99: ${stats.p99.toLocaleString()} B`,
'',
);
md.push("");
// Percentiles
md.push("## 📈 Size Percentiles");
md.push(
`Avg: ${
Math.round(stats.avgFileSize).toLocaleString()
} B, Median: ${
Math.round(stats.medianFileSize).toLocaleString()
} B, p90: ${stats.p90.toLocaleString()} B, p95: ${stats.p95.toLocaleString()} B, p99: ${stats.p99.toLocaleString()} B`,
);
md.push("");
// Histogram
if (Array.isArray(stats.histogram) && stats.histogram.length) {
md.push("## 🧮 Size Histogram");
md.push("| Bucket | Files | Bytes |");
md.push("| --- | ---: | ---: |");
if (Array.isArray(stats.histogram) && stats.histogram.length > 0) {
md.push(
'## 🧮 Size Histogram',
'| Bucket | Files | Bytes |',
'| --- | ---: | ---: |',
);
for (const b of stats.histogram) {
md.push(
`| ${b.label} | ${b.count} | ${b.bytes.toLocaleString()} |`,
);
md.push(`| ${b.label} | ${b.count} | ${b.bytes.toLocaleString()} |`);
}
md.push("");
md.push('');
}
// Top Extensions
if (Array.isArray(stats.byExtension) && stats.byExtension.length) {
md.push("## 📦 Top Extensions by Bytes (Top 20)");
md.push("| Ext | Files | Bytes | % of total |");
md.push("| --- | ---: | ---: | ---: |");
if (Array.isArray(stats.byExtension) && stats.byExtension.length > 0) {
md.push(
'## 📦 Top Extensions by Bytes (Top 20)',
'| Ext | Files | Bytes | % of total |',
'| --- | ---: | ---: | ---: |',
);
for (const e of stats.byExtension.slice(0, 20)) {
const p = pct(e.bytes, stats.totalBytes);
md.push(
`| ${e.ext} | ${e.count} | ${e.bytes.toLocaleString()} | ${
p.toFixed(2)
}% |`,
`| ${e.ext} | ${e.count} | ${e.bytes.toLocaleString()} | ${p.toFixed(2)}% |`,
);
}
md.push("");
md.push('');
}
// Top Directories
if (Array.isArray(stats.byDirectory) && stats.byDirectory.length) {
md.push("## 📂 Top Directories by Bytes (Top 20)");
md.push("| Directory | Files | Bytes | % of total |");
md.push("| --- | ---: | ---: | ---: |");
if (Array.isArray(stats.byDirectory) && stats.byDirectory.length > 0) {
md.push(
'## 📂 Top Directories by Bytes (Top 20)',
'| Directory | Files | Bytes | % of total |',
'| --- | ---: | ---: | ---: |',
);
for (const d of stats.byDirectory.slice(0, 20)) {
const p = pct(d.bytes, stats.totalBytes);
md.push(
`| ${d.dir} | ${d.count} | ${d.bytes.toLocaleString()} | ${
p.toFixed(2)
}% |`,
`| ${d.dir} | ${d.count} | ${d.bytes.toLocaleString()} | ${p.toFixed(2)}% |`,
);
}
md.push("");
md.push('');
}
// Depth distribution
if (
Array.isArray(stats.depthDistribution) &&
stats.depthDistribution.length
) {
md.push("## 🌳 Depth Distribution");
md.push("| Depth | Count |");
md.push("| ---: | ---: |");
if (Array.isArray(stats.depthDistribution) && stats.depthDistribution.length > 0) {
md.push('## 🌳 Depth Distribution', '| Depth | Count |', '| ---: | ---: |');
for (const d of stats.depthDistribution) {
md.push(`| ${d.depth} | ${d.count} |`);
}
md.push("");
md.push('');
}
// Longest paths
if (
Array.isArray(stats.longestPaths) && stats.longestPaths.length
) {
md.push("## 🧵 Longest Paths (Top 25)");
md.push("| Path | Length | Bytes |");
md.push("| --- | ---: | ---: |");
if (Array.isArray(stats.longestPaths) && stats.longestPaths.length > 0) {
md.push(
'## 🧵 Longest Paths (Top 25)',
'| Path | Length | Bytes |',
'| --- | ---: | ---: |',
);
for (const pth of stats.longestPaths) {
md.push(
`| ${pth.path} | ${pth.length} | ${pth.size.toLocaleString()} |`,
);
md.push(`| ${pth.path} | ${pth.length} | ${pth.size.toLocaleString()} |`);
}
md.push("");
md.push('');
}
// Temporal
if (stats.temporal) {
md.push("## ⏱️ Temporal");
md.push('## ⏱️ Temporal');
if (stats.temporal.oldest) {
md.push(
`- Oldest: ${stats.temporal.oldest.path} (${stats.temporal.oldest.mtime})`,
);
md.push(`- Oldest: ${stats.temporal.oldest.path} (${stats.temporal.oldest.mtime})`);
}
if (stats.temporal.newest) {
md.push(
`- Newest: ${stats.temporal.newest.path} (${stats.temporal.newest.mtime})`,
);
md.push(`- Newest: ${stats.temporal.newest.path} (${stats.temporal.newest.mtime})`);
}
if (Array.isArray(stats.temporal.ageBuckets)) {
md.push("");
md.push("| Age | Files | Bytes |");
md.push("| --- | ---: | ---: |");
md.push('', '| Age | Files | Bytes |', '| --- | ---: | ---: |');
for (const b of stats.temporal.ageBuckets) {
md.push(
`| ${b.label} | ${b.count} | ${b.bytes.toLocaleString()} |`,
);
md.push(`| ${b.label} | ${b.count} | ${b.bytes.toLocaleString()} |`);
}
}
md.push("");
md.push('');
}
// Quality signals
if (stats.quality) {
md.push("## ✅ Quality Signals");
md.push(`- Zero-byte files: ${stats.quality.zeroByteFiles}`);
md.push(`- Empty text files: ${stats.quality.emptyTextFiles}`);
md.push(`- Hidden files: ${stats.quality.hiddenFiles}`);
md.push(`- Symlinks: ${stats.quality.symlinks}`);
md.push(
`- Large files (>= ${
(stats.quality.largeThreshold / (1024 * 1024)).toFixed(0)
} MB): ${stats.quality.largeFilesCount}`,
);
md.push(
'## ✅ Quality Signals',
`- Zero-byte files: ${stats.quality.zeroByteFiles}`,
`- Empty text files: ${stats.quality.emptyTextFiles}`,
`- Hidden files: ${stats.quality.hiddenFiles}`,
`- Symlinks: ${stats.quality.symlinks}`,
`- Large files (>= ${(stats.quality.largeThreshold / (1024 * 1024)).toFixed(0)} MB): ${stats.quality.largeFilesCount}`,
`- Suspiciously large files (>= 100 MB): ${stats.quality.suspiciousLargeFilesCount}`,
'',
);
md.push("");
}
// Duplicates
if (
Array.isArray(stats.duplicateCandidates) &&
stats.duplicateCandidates.length
) {
md.push("## 🧬 Duplicate Candidates");
md.push("| Reason | Files | Size (bytes) |");
md.push("| --- | ---: | ---: |");
if (Array.isArray(stats.duplicateCandidates) && stats.duplicateCandidates.length > 0) {
md.push(
'## 🧬 Duplicate Candidates',
'| Reason | Files | Size (bytes) |',
'| --- | ---: | ---: |',
);
for (const d of stats.duplicateCandidates) {
md.push(
`| ${d.reason} | ${d.count} | ${d.size.toLocaleString()} |`,
);
md.push(`| ${d.reason} | ${d.count} | ${d.size.toLocaleString()} |`);
}
md.push("");
// Detailed listing of duplicate file names and locations
md.push("### 🧬 Duplicate Groups Details");
md.push('', '### 🧬 Duplicate Groups Details');
let dupIndex = 1;
for (const d of stats.duplicateCandidates) {
md.push(
`#### Group ${dupIndex}: ${d.count} files @ ${d.size.toLocaleString()} bytes (${d.reason})`,
);
if (Array.isArray(d.files) && d.files.length) {
if (Array.isArray(d.files) && d.files.length > 0) {
for (const fp of d.files) {
md.push(`- ${fp}`);
}
} else {
md.push("- (file list unavailable)");
md.push('- (file list unavailable)');
}
md.push("");
md.push('');
dupIndex++;
}
md.push("");
md.push('');
}
// Compressibility
if (typeof stats.compressibilityRatio === "number") {
md.push("## 🗜️ Compressibility");
if (typeof stats.compressibilityRatio === 'number') {
md.push(
`Sampled compressibility ratio: ${
(stats.compressibilityRatio * 100).toFixed(2)
}%`,
'## 🗜️ Compressibility',
`Sampled compressibility ratio: ${(stats.compressibilityRatio * 100).toFixed(2)}%`,
'',
);
md.push("");
}
// Git
if (stats.git && stats.git.isRepo) {
md.push("## 🔧 Git");
md.push(
'## 🔧 Git',
`- Tracked: ${stats.git.trackedCount} files, ${stats.git.trackedBytes.toLocaleString()} bytes`,
);
md.push(
`- Untracked: ${stats.git.untrackedCount} files, ${stats.git.untrackedBytes.toLocaleString()} bytes`,
);
if (
Array.isArray(stats.git.lfsCandidates) &&
stats.git.lfsCandidates.length
) {
md.push("");
md.push("### 📦 LFS Candidates (Top 20)");
md.push("| Path | Bytes |");
md.push("| --- | ---: |");
if (Array.isArray(stats.git.lfsCandidates) && stats.git.lfsCandidates.length > 0) {
md.push('', '### 📦 LFS Candidates (Top 20)', '| Path | Bytes |', '| --- | ---: |');
for (const f of stats.git.lfsCandidates.slice(0, 20)) {
md.push(`| ${f.path} | ${f.size.toLocaleString()} |`);
}
}
md.push("");
md.push('');
}
// Largest Files
if (
Array.isArray(stats.largestFiles) && stats.largestFiles.length
) {
md.push("## 📚 Largest Files (Top 50)");
md.push("| Path | Size | % of total | LOC |");
md.push("| --- | ---: | ---: | ---: |");
if (Array.isArray(stats.largestFiles) && stats.largestFiles.length > 0) {
md.push(
'## 📚 Largest Files (Top 50)',
'| Path | Size | % of total | LOC |',
'| --- | ---: | ---: | ---: |',
);
for (const f of stats.largestFiles) {
let loc = "";
if (
!f.isBinary && Array.isArray(aggregatedContent?.textFiles)
) {
const tf = aggregatedContent.textFiles.find((t) =>
t.path === f.path
);
if (tf && typeof tf.lines === "number") {
let loc = '';
if (!f.isBinary && Array.isArray(aggregatedContent?.textFiles)) {
const tf = aggregatedContent.textFiles.find((t) => t.path === f.path);
if (tf && typeof tf.lines === 'number') {
loc = tf.lines.toLocaleString();
}
}
md.push(
`| ${f.path} | ${f.sizeFormatted} | ${
f.percentOfTotal.toFixed(2)
}% | ${loc} |`,
`| ${f.path} | ${f.sizeFormatted} | ${f.percentOfTotal.toFixed(2)}% | ${loc} |`,
);
}
md.push("");
md.push('');
}
await fs.writeFile(mdPath, md.join("\n"));
await fs.writeFile(mdPath, md.join('\n'));
console.log(`\n🧾 Detailed stats report written to: ${mdPath}`);
} catch (e) {
console.warn(`⚠️ Failed to write stats markdown: ${e.message}`);
} catch (error) {
console.warn(`⚠️ Failed to write stats markdown: ${error.message}`);
}
}
}
} catch (error) {
console.error("❌ Critical error:", error.message);
console.error("An unexpected error occurred.");
console.error('❌ Critical error:', error.message);
console.error('An unexpected error occurred.');
process.exit(1);
}
});

View File

@@ -1,10 +1,10 @@
const fs = require("fs-extra");
const path = require("node:path");
const fs = require('fs-extra');
const path = require('node:path');
// Deno/Node compatibility: explicitly import process
const process = require("node:process");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const process = require('node:process');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execFileAsync = promisify(execFile);
// Simple memoization across calls (keyed by realpath of startDir)
@@ -18,7 +18,7 @@ async function _tryRun(cmd, args, cwd, timeoutMs = 500) {
windowsHide: true,
maxBuffer: 1024 * 1024,
});
const out = String(stdout || "").trim();
const out = String(stdout || '').trim();
return out || null;
} catch {
return null;
@@ -27,15 +27,17 @@ async function _tryRun(cmd, args, cwd, timeoutMs = 500) {
async function _detectVcsTopLevel(startDir) {
// Run common VCS root queries in parallel; ignore failures
const gitP = _tryRun("git", ["rev-parse", "--show-toplevel"], startDir);
const hgP = _tryRun("hg", ["root"], startDir);
const gitP = _tryRun('git', ['rev-parse', '--show-toplevel'], startDir);
const hgP = _tryRun('hg', ['root'], startDir);
const svnP = (async () => {
const show = await _tryRun("svn", ["info", "--show-item", "wc-root"], startDir);
const show = await _tryRun('svn', ['info', '--show-item', 'wc-root'], startDir);
if (show) return show;
const info = await _tryRun("svn", ["info"], startDir);
const info = await _tryRun('svn', ['info'], startDir);
if (info) {
const line = info.split(/\r?\n/).find((l) => l.toLowerCase().startsWith("working copy root path:"));
if (line) return line.split(":").slice(1).join(":").trim();
const line = info
.split(/\r?\n/)
.find((l) => l.toLowerCase().startsWith('working copy root path:'));
if (line) return line.split(':').slice(1).join(':').trim();
}
return null;
})();
@@ -71,90 +73,92 @@ async function findProjectRoot(startDir) {
const checks = [];
const add = (rel, weight) => {
const makePath = (d) => Array.isArray(rel) ? path.join(d, ...rel) : path.join(d, rel);
const makePath = (d) => (Array.isArray(rel) ? path.join(d, ...rel) : path.join(d, rel));
checks.push({ makePath, weight });
};
// Highest priority: explicit sentinel markers
add(".project-root", 110);
add(".workspace-root", 110);
add(".repo-root", 110);
add('.project-root', 110);
add('.workspace-root', 110);
add('.repo-root', 110);
// Highest priority: VCS roots
add(".git", 100);
add(".hg", 95);
add(".svn", 95);
add('.git', 100);
add('.hg', 95);
add('.svn', 95);
// Monorepo/workspace indicators
add("pnpm-workspace.yaml", 90);
add("lerna.json", 90);
add("turbo.json", 90);
add("nx.json", 90);
add("rush.json", 90);
add("go.work", 90);
add("WORKSPACE", 90);
add("WORKSPACE.bazel", 90);
add("MODULE.bazel", 90);
add("pants.toml", 90);
add('pnpm-workspace.yaml', 90);
add('lerna.json', 90);
add('turbo.json', 90);
add('nx.json', 90);
add('rush.json', 90);
add('go.work', 90);
add('WORKSPACE', 90);
add('WORKSPACE.bazel', 90);
add('MODULE.bazel', 90);
add('pants.toml', 90);
// Lockfiles and package-manager/top-level locks
add("yarn.lock", 85);
add("pnpm-lock.yaml", 85);
add("package-lock.json", 85);
add("bun.lockb", 85);
add("Cargo.lock", 85);
add("composer.lock", 85);
add("poetry.lock", 85);
add("Pipfile.lock", 85);
add("Gemfile.lock", 85);
add('yarn.lock', 85);
add('pnpm-lock.yaml', 85);
add('package-lock.json', 85);
add('bun.lockb', 85);
add('Cargo.lock', 85);
add('composer.lock', 85);
add('poetry.lock', 85);
add('Pipfile.lock', 85);
add('Gemfile.lock', 85);
// Build-system root indicators
add("settings.gradle", 80);
add("settings.gradle.kts", 80);
add("gradlew", 80);
add("pom.xml", 80);
add("build.sbt", 80);
add(["project", "build.properties"], 80);
add('settings.gradle', 80);
add('settings.gradle.kts', 80);
add('gradlew', 80);
add('pom.xml', 80);
add('build.sbt', 80);
add(['project', 'build.properties'], 80);
// Language/project config markers
add("deno.json", 75);
add("deno.jsonc", 75);
add("pyproject.toml", 75);
add("Pipfile", 75);
add("requirements.txt", 75);
add("go.mod", 75);
add("Cargo.toml", 75);
add("composer.json", 75);
add("mix.exs", 75);
add("Gemfile", 75);
add("CMakeLists.txt", 75);
add("stack.yaml", 75);
add("cabal.project", 75);
add("rebar.config", 75);
add("pubspec.yaml", 75);
add("flake.nix", 75);
add("shell.nix", 75);
add("default.nix", 75);
add(".tool-versions", 75);
add("package.json", 74); // generic Node project (lower than lockfiles/workspaces)
add('deno.json', 75);
add('deno.jsonc', 75);
add('pyproject.toml', 75);
add('Pipfile', 75);
add('requirements.txt', 75);
add('go.mod', 75);
add('Cargo.toml', 75);
add('composer.json', 75);
add('mix.exs', 75);
add('Gemfile', 75);
add('CMakeLists.txt', 75);
add('stack.yaml', 75);
add('cabal.project', 75);
add('rebar.config', 75);
add('pubspec.yaml', 75);
add('flake.nix', 75);
add('shell.nix', 75);
add('default.nix', 75);
add('.tool-versions', 75);
add('package.json', 74); // generic Node project (lower than lockfiles/workspaces)
// Changesets
add([".changeset", "config.json"], 70);
add(".changeset", 70);
add(['.changeset', 'config.json'], 70);
add('.changeset', 70);
// Custom markers via env (comma-separated names)
if (process.env.PROJECT_ROOT_MARKERS) {
for (const name of process.env.PROJECT_ROOT_MARKERS.split(",").map((s) => s.trim()).filter(Boolean)) {
for (const name of process.env.PROJECT_ROOT_MARKERS.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
add(name, 72);
}
}
/** Check for package.json with "workspaces" */
const hasWorkspacePackageJson = async (d) => {
const pkgPath = path.join(d, "package.json");
const pkgPath = path.join(d, 'package.json');
if (!(await exists(pkgPath))) return false;
try {
const raw = await fs.readFile(pkgPath, "utf8");
const raw = await fs.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(raw);
return Boolean(pkg && pkg.workspaces);
} catch {
@@ -172,9 +176,8 @@ async function findProjectRoot(startDir) {
while (true) {
// Special check: package.json with "workspaces"
if (await hasWorkspacePackageJson(dir)) {
if (!best || 90 >= best.weight) best = { dir, weight: 90 };
}
if ((await hasWorkspacePackageJson(dir)) && (!best || 90 >= best.weight))
best = { dir, weight: 90 };
// Evaluate all other checks in parallel
const results = await Promise.all(
@@ -201,4 +204,3 @@ async function findProjectRoot(startDir) {
}
module.exports = { findProjectRoot };

View File

@@ -1,11 +1,11 @@
const os = require("node:os");
const path = require("node:path");
const readline = require("node:readline");
const process = require("node:process");
const os = require('node:os');
const path = require('node:path');
const readline = require('node:readline');
const process = require('node:process');
function expandHome(p) {
if (!p) return p;
if (p.startsWith("~")) return path.join(os.homedir(), p.slice(1));
if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1));
return p;
}
@@ -27,16 +27,16 @@ function promptQuestion(question) {
}
async function promptYesNo(question, defaultYes = true) {
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
const ans = (await promptQuestion(`${question}${suffix}`)).trim().toLowerCase();
if (!ans) return defaultYes;
if (["y", "yes"].includes(ans)) return true;
if (["n", "no"].includes(ans)) return false;
if (['y', 'yes'].includes(ans)) return true;
if (['n', 'no'].includes(ans)) return false;
return promptYesNo(question, defaultYes);
}
async function promptPath(question, defaultValue) {
const prompt = `${question}${defaultValue ? ` (default: ${defaultValue})` : ""}: `;
const prompt = `${question}${defaultValue ? ` (default: ${defaultValue})` : ''}: `;
const ans = (await promptQuestion(prompt)).trim();
return expandHome(ans || defaultValue);
}

View File

@@ -1,11 +1,11 @@
"use strict";
'use strict';
const fs = require("node:fs/promises");
const path = require("node:path");
const zlib = require("node:zlib");
const { Buffer } = require("node:buffer");
const crypto = require("node:crypto");
const cp = require("node:child_process");
const fs = require('node:fs/promises');
const path = require('node:path');
const zlib = require('node:zlib');
const { Buffer } = require('node:buffer');
const crypto = require('node:crypto');
const cp = require('node:child_process');
const KB = 1024;
const MB = 1024 * KB;
@@ -34,17 +34,19 @@ async function enrichAllFiles(textFiles, binaryFiles) {
const allFiles = [];
async function enrich(file, isBinary) {
const ext = (path.extname(file.path) || "").toLowerCase();
const dir = path.dirname(file.path) || ".";
const ext = (path.extname(file.path) || '').toLowerCase();
const dir = path.dirname(file.path) || '.';
const depth = file.path.split(path.sep).filter(Boolean).length;
const hidden = file.path.split(path.sep).some((seg) => seg.startsWith("."));
const hidden = file.path.split(path.sep).some((seg) => seg.startsWith('.'));
let mtimeMs = 0;
let isSymlink = false;
try {
const lst = await fs.lstat(file.absolutePath);
mtimeMs = lst.mtimeMs;
isSymlink = lst.isSymbolicLink();
} catch (_) { /* ignore lstat errors during enrichment */ }
} catch {
/* ignore lstat errors during enrichment */
}
allFiles.push({
path: file.path,
absolutePath: file.absolutePath,
@@ -67,18 +69,18 @@ async function enrichAllFiles(textFiles, binaryFiles) {
function buildHistogram(allFiles) {
const buckets = [
[1 * KB, "01KB"],
[10 * KB, "110KB"],
[100 * KB, "10100KB"],
[1 * MB, "100KB1MB"],
[10 * MB, "110MB"],
[100 * MB, "10100MB"],
[Infinity, ">=100MB"],
[1 * KB, '01KB'],
[10 * KB, '110KB'],
[100 * KB, '10100KB'],
[1 * MB, '100KB1MB'],
[10 * MB, '110MB'],
[100 * MB, '10100MB'],
[Infinity, '>=100MB'],
];
const histogram = buckets.map(([_, label]) => ({ label, count: 0, bytes: 0 }));
for (const f of allFiles) {
for (let i = 0; i < buckets.length; i++) {
if (f.size < buckets[i][0]) {
for (const [i, bucket] of buckets.entries()) {
if (f.size < bucket[0]) {
histogram[i].count++;
histogram[i].bytes += f.size;
break;
@@ -91,13 +93,13 @@ function buildHistogram(allFiles) {
function aggregateByExtension(allFiles) {
const byExtension = new Map();
for (const f of allFiles) {
const key = f.ext || "<none>";
const key = f.ext || '<none>';
const v = byExtension.get(key) || { ext: key, count: 0, bytes: 0 };
v.count++;
v.bytes += f.size;
byExtension.set(key, v);
}
return Array.from(byExtension.values()).sort((a, b) => b.bytes - a.bytes);
return [...byExtension.values()].sort((a, b) => b.bytes - a.bytes);
}
function aggregateByDirectory(allFiles) {
@@ -109,15 +111,15 @@ function aggregateByDirectory(allFiles) {
byDirectory.set(dir, v);
}
for (const f of allFiles) {
const parts = f.dir === "." ? [] : f.dir.split(path.sep);
let acc = "";
const parts = f.dir === '.' ? [] : f.dir.split(path.sep);
let acc = '';
for (let i = 0; i < parts.length; i++) {
acc = i === 0 ? parts[0] : acc + path.sep + parts[i];
addDirBytes(acc, f.size);
}
if (parts.length === 0) addDirBytes(".", f.size);
if (parts.length === 0) addDirBytes('.', f.size);
}
return Array.from(byDirectory.values()).sort((a, b) => b.bytes - a.bytes);
return [...byDirectory.values()].sort((a, b) => b.bytes - a.bytes);
}
function computeDepthAndLongest(allFiles) {
@@ -129,21 +131,22 @@ function computeDepthAndLongest(allFiles) {
.sort((a, b) => b.path.length - a.path.length)
.slice(0, 25)
.map((f) => ({ path: f.path, length: f.path.length, size: f.size }));
const depthDist = Array.from(depthDistribution.entries())
const depthDist = [...depthDistribution.entries()]
.sort((a, b) => a[0] - b[0])
.map(([depth, count]) => ({ depth, count }));
return { depthDist, longestPaths };
}
function computeTemporal(allFiles, nowMs) {
let oldest = null, newest = null;
let oldest = null,
newest = null;
const ageBuckets = [
{ label: "> 1 year", minDays: 365, maxDays: Infinity, count: 0, bytes: 0 },
{ label: "612 months", minDays: 180, maxDays: 365, count: 0, bytes: 0 },
{ label: "16 months", minDays: 30, maxDays: 180, count: 0, bytes: 0 },
{ label: "730 days", minDays: 7, maxDays: 30, count: 0, bytes: 0 },
{ label: "17 days", minDays: 1, maxDays: 7, count: 0, bytes: 0 },
{ label: "< 1 day", minDays: 0, maxDays: 1, count: 0, bytes: 0 },
{ label: '> 1 year', minDays: 365, maxDays: Infinity, count: 0, bytes: 0 },
{ label: '612 months', minDays: 180, maxDays: 365, count: 0, bytes: 0 },
{ label: '16 months', minDays: 30, maxDays: 180, count: 0, bytes: 0 },
{ label: '730 days', minDays: 7, maxDays: 30, count: 0, bytes: 0 },
{ label: '17 days', minDays: 1, maxDays: 7, count: 0, bytes: 0 },
{ label: '< 1 day', minDays: 0, maxDays: 1, count: 0, bytes: 0 },
];
for (const f of allFiles) {
const ageDays = Math.max(0, (nowMs - (f.mtimeMs || nowMs)) / (24 * 60 * 60 * 1000));
@@ -158,15 +161,21 @@ function computeTemporal(allFiles, nowMs) {
if (!newest || f.mtimeMs > newest.mtimeMs) newest = f;
}
return {
oldest: oldest ? { path: oldest.path, mtime: oldest.mtimeMs ? new Date(oldest.mtimeMs).toISOString() : null } : null,
newest: newest ? { path: newest.path, mtime: newest.mtimeMs ? new Date(newest.mtimeMs).toISOString() : null } : null,
oldest: oldest
? { path: oldest.path, mtime: oldest.mtimeMs ? new Date(oldest.mtimeMs).toISOString() : null }
: null,
newest: newest
? { path: newest.path, mtime: newest.mtimeMs ? new Date(newest.mtimeMs).toISOString() : null }
: null,
ageBuckets,
};
}
function computeQuality(allFiles, textFiles) {
const zeroByteFiles = allFiles.filter((f) => f.size === 0).length;
const emptyTextFiles = textFiles.filter((f) => (f.size || 0) === 0 || (f.lines || 0) === 0).length;
const emptyTextFiles = textFiles.filter(
(f) => (f.size || 0) === 0 || (f.lines || 0) === 0,
).length;
const hiddenFiles = allFiles.filter((f) => f.hidden).length;
const symlinks = allFiles.filter((f) => f.isSymlink).length;
const largeThreshold = 50 * MB;
@@ -201,18 +210,31 @@ function computeDuplicates(allFiles, textFiles) {
for (const tf of textGroup) {
try {
const src = textFiles.find((x) => x.absolutePath === tf.absolutePath);
const content = src ? src.content : "";
const h = crypto.createHash("sha1").update(content).digest("hex");
const content = src ? src.content : '';
const h = crypto.createHash('sha1').update(content).digest('hex');
const g = contentHashGroups.get(h) || [];
g.push(tf);
contentHashGroups.set(h, g);
} catch (_) { /* ignore hashing errors for duplicate detection */ }
} catch {
/* ignore hashing errors for duplicate detection */
}
}
for (const [_h, g] of contentHashGroups.entries()) {
if (g.length > 1) duplicateCandidates.push({ reason: "same-size+text-hash", size: Number(sizeKey), count: g.length, files: g.map((f) => f.path) });
if (g.length > 1)
duplicateCandidates.push({
reason: 'same-size+text-hash',
size: Number(sizeKey),
count: g.length,
files: g.map((f) => f.path),
});
}
if (otherGroup.length > 1) {
duplicateCandidates.push({ reason: "same-size", size: Number(sizeKey), count: otherGroup.length, files: otherGroup.map((f) => f.path) });
duplicateCandidates.push({
reason: 'same-size',
size: Number(sizeKey),
count: otherGroup.length,
files: otherGroup.map((f) => f.path),
});
}
}
return duplicateCandidates;
@@ -226,10 +248,12 @@ function estimateCompressibility(textFiles) {
const sampleLen = Math.min(256 * 1024, tf.size || 0);
if (sampleLen <= 0) continue;
const sample = tf.content.slice(0, sampleLen);
const gz = zlib.gzipSync(Buffer.from(sample, "utf8"));
const gz = zlib.gzipSync(Buffer.from(sample, 'utf8'));
compSampleBytes += sampleLen;
compCompressedBytes += gz.length;
} catch (_) { /* ignore compression errors during sampling */ }
} catch {
/* ignore compression errors during sampling */
}
}
return compSampleBytes > 0 ? compCompressedBytes / compSampleBytes : null;
}
@@ -245,20 +269,34 @@ function computeGitInfo(allFiles, rootDir, largeThreshold) {
};
try {
if (!rootDir) return info;
const top = cp.execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: rootDir, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
const top = cp
.execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: rootDir,
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
if (!top) return info;
info.isRepo = true;
const out = cp.execFileSync("git", ["ls-files", "-z"], { cwd: rootDir, stdio: ["ignore", "pipe", "ignore"] });
const tracked = new Set(out.toString().split("\0").filter(Boolean));
let trackedBytes = 0, trackedCount = 0, untrackedBytes = 0, untrackedCount = 0;
const out = cp.execFileSync('git', ['ls-files', '-z'], {
cwd: rootDir,
stdio: ['ignore', 'pipe', 'ignore'],
});
const tracked = new Set(out.toString().split('\0').filter(Boolean));
let trackedBytes = 0,
trackedCount = 0,
untrackedBytes = 0,
untrackedCount = 0;
const lfsCandidates = [];
for (const f of allFiles) {
const isTracked = tracked.has(f.path);
if (isTracked) {
trackedCount++; trackedBytes += f.size;
trackedCount++;
trackedBytes += f.size;
if (f.size >= largeThreshold) lfsCandidates.push({ path: f.path, size: f.size });
} else {
untrackedCount++; untrackedBytes += f.size;
untrackedCount++;
untrackedBytes += f.size;
}
}
info.trackedCount = trackedCount;
@@ -266,7 +304,9 @@ function computeGitInfo(allFiles, rootDir, largeThreshold) {
info.untrackedCount = untrackedCount;
info.untrackedBytes = untrackedBytes;
info.lfsCandidates = lfsCandidates.sort((a, b) => b.size - a.size).slice(0, 50);
} catch (_) { /* git not available or not a repo, ignore */ }
} catch {
/* git not available or not a repo, ignore */
}
return info;
}
@@ -280,34 +320,58 @@ function computeLargestFiles(allFiles, totalBytes) {
size: f.size,
sizeFormatted: formatSize(f.size),
percentOfTotal: toPct(f.size, totalBytes),
ext: f.ext || "",
ext: f.ext || '',
isBinary: f.isBinary,
mtime: f.mtimeMs ? new Date(f.mtimeMs).toISOString() : null,
}));
}
function mdTable(rows, headers) {
const header = `| ${headers.join(" | ")} |`;
const sep = `| ${headers.map(() => "---").join(" | ")} |`;
const body = rows.map((r) => `| ${r.join(" | ")} |`).join("\n");
const header = `| ${headers.join(' | ')} |`;
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
const body = rows.map((r) => `| ${r.join(' | ')} |`).join('\n');
return `${header}\n${sep}\n${body}`;
}
function buildMarkdownReport(largestFiles, byExtensionArr, byDirectoryArr, totalBytes) {
const toPct = (num, den) => (den === 0 ? 0 : (num / den) * 100);
const md = [];
md.push("\n### Top Largest Files (Top 50)\n");
md.push(mdTable(
largestFiles.map((f) => [f.path, f.sizeFormatted, `${f.percentOfTotal.toFixed(2)}%`, f.ext || "", f.isBinary ? "binary" : "text"]),
["Path", "Size", "% of total", "Ext", "Type"],
));
md.push("\n\n### Top Extensions by Bytes (Top 20)\n");
const topExtRows = byExtensionArr.slice(0, 20).map((e) => [e.ext, String(e.count), formatSize(e.bytes), `${toPct(e.bytes, totalBytes).toFixed(2)}%`]);
md.push(mdTable(topExtRows, ["Ext", "Count", "Bytes", "% of total"]));
md.push("\n\n### Top Directories by Bytes (Top 20)\n");
const topDirRows = byDirectoryArr.slice(0, 20).map((d) => [d.dir, String(d.count), formatSize(d.bytes), `${toPct(d.bytes, totalBytes).toFixed(2)}%`]);
md.push(mdTable(topDirRows, ["Directory", "Files", "Bytes", "% of total"]));
return md.join("\n");
md.push(
'\n### Top Largest Files (Top 50)\n',
mdTable(
largestFiles.map((f) => [
f.path,
f.sizeFormatted,
`${f.percentOfTotal.toFixed(2)}%`,
f.ext || '',
f.isBinary ? 'binary' : 'text',
]),
['Path', 'Size', '% of total', 'Ext', 'Type'],
),
'\n\n### Top Extensions by Bytes (Top 20)\n',
);
const topExtRows = byExtensionArr
.slice(0, 20)
.map((e) => [
e.ext,
String(e.count),
formatSize(e.bytes),
`${toPct(e.bytes, totalBytes).toFixed(2)}%`,
]);
md.push(
mdTable(topExtRows, ['Ext', 'Count', 'Bytes', '% of total']),
'\n\n### Top Directories by Bytes (Top 20)\n',
);
const topDirRows = byDirectoryArr
.slice(0, 20)
.map((d) => [
d.dir,
String(d.count),
formatSize(d.bytes),
`${toPct(d.bytes, totalBytes).toFixed(2)}%`,
]);
md.push(mdTable(topDirRows, ['Directory', 'Files', 'Bytes', '% of total']));
return md.join('\n');
}
module.exports = {

View File

@@ -1,4 +1,4 @@
const H = require("./stats.helpers.js");
const H = require('./stats.helpers.js');
async function calculateStatistics(aggregatedContent, xmlFileSize, rootDir) {
const { textFiles, binaryFiles, errors } = aggregatedContent;
@@ -10,8 +10,8 @@ async function calculateStatistics(aggregatedContent, xmlFileSize, rootDir) {
const allFiles = await H.enrichAllFiles(textFiles, binaryFiles);
const totalBytes = allFiles.reduce((s, f) => s + f.size, 0);
const sizes = allFiles.map((f) => f.size).sort((a, b) => a - b);
const avgSize = sizes.length ? totalBytes / sizes.length : 0;
const medianSize = sizes.length ? H.percentile(sizes, 50) : 0;
const avgSize = sizes.length > 0 ? totalBytes / sizes.length : 0;
const medianSize = sizes.length > 0 ? H.percentile(sizes, 50) : 0;
const p90 = H.percentile(sizes, 90);
const p95 = H.percentile(sizes, 95);
const p99 = H.percentile(sizes, 99);

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env node
/* deno-lint-ignore-file */
/*
Automatic test matrix for project root detection.
@@ -6,65 +5,65 @@
No external options or flags required. Safe to run multiple times.
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("fs-extra");
const { promisify } = require("node:util");
const { execFile } = require("node:child_process");
const process = require("node:process");
const os = require('node:os');
const path = require('node:path');
const fs = require('fs-extra');
const { promisify } = require('node:util');
const { execFile } = require('node:child_process');
const process = require('node:process');
const execFileAsync = promisify(execFile);
const { findProjectRoot } = require("./projectRoot.js");
const { findProjectRoot } = require('./projectRoot.js');
async function cmdAvailable(cmd) {
try {
await execFileAsync(cmd, ["--version"], { timeout: 500, windowsHide: true });
await execFileAsync(cmd, ['--version'], { timeout: 500, windowsHide: true });
return true;
} catch {
return false;
}
async function testSvnMarker() {
const root = await mkTmpDir("svn");
const nested = path.join(root, "proj", "code");
await fs.ensureDir(nested);
await fs.ensureDir(path.join(root, ".svn"));
const found = await findProjectRoot(nested);
assertEqual(found, root, ".svn marker should be detected");
return { name: "svn-marker", ok: true };
}
async function testSymlinkStart() {
const root = await mkTmpDir("symlink-start");
const nested = path.join(root, "a", "b");
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, ".project-root"), "\n");
const tmp = await mkTmpDir("symlink-tmp");
const link = path.join(tmp, "link-to-b");
try {
await fs.symlink(nested, link);
} catch {
// symlink may not be permitted on some systems; skip
return { name: "symlink-start", ok: true, skipped: true };
async function testSvnMarker() {
const root = await mkTmpDir('svn');
const nested = path.join(root, 'proj', 'code');
await fs.ensureDir(nested);
await fs.ensureDir(path.join(root, '.svn'));
const found = await findProjectRoot(nested);
assertEqual(found, root, '.svn marker should be detected');
return { name: 'svn-marker', ok: true };
}
const found = await findProjectRoot(link);
assertEqual(found, root, "should resolve symlinked start to real root");
return { name: "symlink-start", ok: true };
}
async function testSubmoduleLikeInnerGitFile() {
const root = await mkTmpDir("submodule-like");
const mid = path.join(root, "mid");
const leaf = path.join(mid, "leaf");
await fs.ensureDir(leaf);
// outer repo
await fs.ensureDir(path.join(root, ".git"));
// inner submodule-like .git file
await fs.writeFile(path.join(mid, ".git"), "gitdir: ../.git/modules/mid\n");
const found = await findProjectRoot(leaf);
assertEqual(found, root, "outermost .git should win on tie weight");
return { name: "submodule-like-gitfile", ok: true };
}
async function testSymlinkStart() {
const root = await mkTmpDir('symlink-start');
const nested = path.join(root, 'a', 'b');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, '.project-root'), '\n');
const tmp = await mkTmpDir('symlink-tmp');
const link = path.join(tmp, 'link-to-b');
try {
await fs.symlink(nested, link);
} catch {
// symlink may not be permitted on some systems; skip
return { name: 'symlink-start', ok: true, skipped: true };
}
const found = await findProjectRoot(link);
assertEqual(found, root, 'should resolve symlinked start to real root');
return { name: 'symlink-start', ok: true };
}
async function testSubmoduleLikeInnerGitFile() {
const root = await mkTmpDir('submodule-like');
const mid = path.join(root, 'mid');
const leaf = path.join(mid, 'leaf');
await fs.ensureDir(leaf);
// outer repo
await fs.ensureDir(path.join(root, '.git'));
// inner submodule-like .git file
await fs.writeFile(path.join(mid, '.git'), 'gitdir: ../.git/modules/mid\n');
const found = await findProjectRoot(leaf);
assertEqual(found, root, 'outermost .git should win on tie weight');
return { name: 'submodule-like-gitfile', ok: true };
}
}
async function mkTmpDir(name) {
@@ -75,274 +74,283 @@ async function mkTmpDir(name) {
function assertEqual(actual, expected, msg) {
if (actual !== expected) {
throw new Error(`${msg}: expected=\"${expected}\" actual=\"${actual}\"`);
throw new Error(`${msg}: expected="${expected}" actual="${actual}"`);
}
}
async function testSentinel() {
const root = await mkTmpDir("sentinel");
const nested = path.join(root, "a", "b", "c");
const root = await mkTmpDir('sentinel');
const nested = path.join(root, 'a', 'b', 'c');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, ".project-root"), "\n");
await fs.writeFile(path.join(root, '.project-root'), '\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "sentinel .project-root should win");
return { name: "sentinel", ok: true };
await assertEqual(found, root, 'sentinel .project-root should win');
return { name: 'sentinel', ok: true };
}
async function testOtherSentinels() {
const root = await mkTmpDir("other-sentinels");
const nested = path.join(root, "x", "y");
const root = await mkTmpDir('other-sentinels');
const nested = path.join(root, 'x', 'y');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, ".workspace-root"), "\n");
await fs.writeFile(path.join(root, '.workspace-root'), '\n');
const found1 = await findProjectRoot(nested);
assertEqual(found1, root, "sentinel .workspace-root should win");
assertEqual(found1, root, 'sentinel .workspace-root should win');
await fs.remove(path.join(root, ".workspace-root"));
await fs.writeFile(path.join(root, ".repo-root"), "\n");
await fs.remove(path.join(root, '.workspace-root'));
await fs.writeFile(path.join(root, '.repo-root'), '\n');
const found2 = await findProjectRoot(nested);
assertEqual(found2, root, "sentinel .repo-root should win");
return { name: "other-sentinels", ok: true };
assertEqual(found2, root, 'sentinel .repo-root should win');
return { name: 'other-sentinels', ok: true };
}
async function testGitCliAndMarker() {
const hasGit = await cmdAvailable("git");
if (!hasGit) return { name: "git-cli", ok: true, skipped: true };
const hasGit = await cmdAvailable('git');
if (!hasGit) return { name: 'git-cli', ok: true, skipped: true };
const root = await mkTmpDir("git");
const nested = path.join(root, "pkg", "src");
const root = await mkTmpDir('git');
const nested = path.join(root, 'pkg', 'src');
await fs.ensureDir(nested);
await execFileAsync("git", ["init"], { cwd: root, timeout: 2000 });
await execFileAsync('git', ['init'], { cwd: root, timeout: 2000 });
const found = await findProjectRoot(nested);
await assertEqual(found, root, "git toplevel should be detected");
return { name: "git-cli", ok: true };
await assertEqual(found, root, 'git toplevel should be detected');
return { name: 'git-cli', ok: true };
}
async function testHgMarkerOrCli() {
// Prefer simple marker test to avoid requiring Mercurial install
const root = await mkTmpDir("hg");
const nested = path.join(root, "lib");
const root = await mkTmpDir('hg');
const nested = path.join(root, 'lib');
await fs.ensureDir(nested);
await fs.ensureDir(path.join(root, ".hg"));
await fs.ensureDir(path.join(root, '.hg'));
const found = await findProjectRoot(nested);
await assertEqual(found, root, ".hg marker should be detected");
return { name: "hg-marker", ok: true };
await assertEqual(found, root, '.hg marker should be detected');
return { name: 'hg-marker', ok: true };
}
async function testWorkspacePnpm() {
const root = await mkTmpDir("pnpm-workspace");
const pkgA = path.join(root, "packages", "a");
const root = await mkTmpDir('pnpm-workspace');
const pkgA = path.join(root, 'packages', 'a');
await fs.ensureDir(pkgA);
await fs.writeFile(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
await fs.writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n');
const found = await findProjectRoot(pkgA);
await assertEqual(found, root, "pnpm-workspace.yaml should be detected");
return { name: "pnpm-workspace", ok: true };
await assertEqual(found, root, 'pnpm-workspace.yaml should be detected');
return { name: 'pnpm-workspace', ok: true };
}
async function testPackageJsonWorkspaces() {
const root = await mkTmpDir("package-workspaces");
const pkgA = path.join(root, "packages", "a");
const root = await mkTmpDir('package-workspaces');
const pkgA = path.join(root, 'packages', 'a');
await fs.ensureDir(pkgA);
await fs.writeJson(path.join(root, "package.json"), { private: true, workspaces: ["packages/*"] }, { spaces: 2 });
await fs.writeJson(
path.join(root, 'package.json'),
{ private: true, workspaces: ['packages/*'] },
{ spaces: 2 },
);
const found = await findProjectRoot(pkgA);
await assertEqual(found, root, "package.json workspaces should be detected");
return { name: "package.json-workspaces", ok: true };
await assertEqual(found, root, 'package.json workspaces should be detected');
return { name: 'package.json-workspaces', ok: true };
}
async function testLockfiles() {
const root = await mkTmpDir("lockfiles");
const nested = path.join(root, "src");
const root = await mkTmpDir('lockfiles');
const nested = path.join(root, 'src');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "yarn.lock"), "\n");
await fs.writeFile(path.join(root, 'yarn.lock'), '\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "yarn.lock should be detected");
return { name: "lockfiles", ok: true };
await assertEqual(found, root, 'yarn.lock should be detected');
return { name: 'lockfiles', ok: true };
}
async function testLanguageConfigs() {
const root = await mkTmpDir("lang-configs");
const nested = path.join(root, "x", "y");
const root = await mkTmpDir('lang-configs');
const nested = path.join(root, 'x', 'y');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "pyproject.toml"), "[tool.poetry]\nname='tmp'\n");
await fs.writeFile(path.join(root, 'pyproject.toml'), "[tool.poetry]\nname='tmp'\n");
const found = await findProjectRoot(nested);
await assertEqual(found, root, "pyproject.toml should be detected");
return { name: "language-configs", ok: true };
await assertEqual(found, root, 'pyproject.toml should be detected');
return { name: 'language-configs', ok: true };
}
async function testPreferOuterOnTie() {
const root = await mkTmpDir("tie");
const mid = path.join(root, "mid");
const leaf = path.join(mid, "leaf");
const root = await mkTmpDir('tie');
const mid = path.join(root, 'mid');
const leaf = path.join(mid, 'leaf');
await fs.ensureDir(leaf);
// same weight marker at two levels
await fs.writeFile(path.join(root, "requirements.txt"), "\n");
await fs.writeFile(path.join(mid, "requirements.txt"), "\n");
await fs.writeFile(path.join(root, 'requirements.txt'), '\n');
await fs.writeFile(path.join(mid, 'requirements.txt'), '\n');
const found = await findProjectRoot(leaf);
await assertEqual(found, root, "outermost directory should win on equal weight");
return { name: "prefer-outermost-tie", ok: true };
await assertEqual(found, root, 'outermost directory should win on equal weight');
return { name: 'prefer-outermost-tie', ok: true };
}
// Additional coverage: Bazel, Nx/Turbo/Rush, Go workspaces, Deno, Java/Scala, PHP, Rust, Nix, Changesets, env markers,
// and priority interaction between package.json and lockfiles.
async function testBazelWorkspace() {
const root = await mkTmpDir("bazel");
const nested = path.join(root, "apps", "svc");
const root = await mkTmpDir('bazel');
const nested = path.join(root, 'apps', 'svc');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "WORKSPACE"), "workspace(name=\"tmp\")\n");
await fs.writeFile(path.join(root, 'WORKSPACE'), 'workspace(name="tmp")\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "Bazel WORKSPACE should be detected");
return { name: "bazel-workspace", ok: true };
await assertEqual(found, root, 'Bazel WORKSPACE should be detected');
return { name: 'bazel-workspace', ok: true };
}
async function testNx() {
const root = await mkTmpDir("nx");
const nested = path.join(root, "apps", "web");
const root = await mkTmpDir('nx');
const nested = path.join(root, 'apps', 'web');
await fs.ensureDir(nested);
await fs.writeJson(path.join(root, "nx.json"), { npmScope: "tmp" }, { spaces: 2 });
await fs.writeJson(path.join(root, 'nx.json'), { npmScope: 'tmp' }, { spaces: 2 });
const found = await findProjectRoot(nested);
await assertEqual(found, root, "nx.json should be detected");
return { name: "nx", ok: true };
await assertEqual(found, root, 'nx.json should be detected');
return { name: 'nx', ok: true };
}
async function testTurbo() {
const root = await mkTmpDir("turbo");
const nested = path.join(root, "packages", "x");
const root = await mkTmpDir('turbo');
const nested = path.join(root, 'packages', 'x');
await fs.ensureDir(nested);
await fs.writeJson(path.join(root, "turbo.json"), { pipeline: {} }, { spaces: 2 });
await fs.writeJson(path.join(root, 'turbo.json'), { pipeline: {} }, { spaces: 2 });
const found = await findProjectRoot(nested);
await assertEqual(found, root, "turbo.json should be detected");
return { name: "turbo", ok: true };
await assertEqual(found, root, 'turbo.json should be detected');
return { name: 'turbo', ok: true };
}
async function testRush() {
const root = await mkTmpDir("rush");
const nested = path.join(root, "apps", "a");
const root = await mkTmpDir('rush');
const nested = path.join(root, 'apps', 'a');
await fs.ensureDir(nested);
await fs.writeJson(path.join(root, "rush.json"), { projectFolderMinDepth: 1 }, { spaces: 2 });
await fs.writeJson(path.join(root, 'rush.json'), { projectFolderMinDepth: 1 }, { spaces: 2 });
const found = await findProjectRoot(nested);
await assertEqual(found, root, "rush.json should be detected");
return { name: "rush", ok: true };
await assertEqual(found, root, 'rush.json should be detected');
return { name: 'rush', ok: true };
}
async function testGoWorkAndMod() {
const root = await mkTmpDir("gowork");
const mod = path.join(root, "modA");
const nested = path.join(mod, "pkg");
const root = await mkTmpDir('gowork');
const mod = path.join(root, 'modA');
const nested = path.join(mod, 'pkg');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "go.work"), "go 1.22\nuse ./modA\n");
await fs.writeFile(path.join(mod, "go.mod"), "module example.com/a\ngo 1.22\n");
await fs.writeFile(path.join(root, 'go.work'), 'go 1.22\nuse ./modA\n');
await fs.writeFile(path.join(mod, 'go.mod'), 'module example.com/a\ngo 1.22\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "go.work should define the workspace root");
return { name: "go-work", ok: true };
await assertEqual(found, root, 'go.work should define the workspace root');
return { name: 'go-work', ok: true };
}
async function testDenoJson() {
const root = await mkTmpDir("deno");
const nested = path.join(root, "src");
const root = await mkTmpDir('deno');
const nested = path.join(root, 'src');
await fs.ensureDir(nested);
await fs.writeJson(path.join(root, "deno.json"), { tasks: {} }, { spaces: 2 });
await fs.writeJson(path.join(root, 'deno.json'), { tasks: {} }, { spaces: 2 });
const found = await findProjectRoot(nested);
await assertEqual(found, root, "deno.json should be detected");
return { name: "deno-json", ok: true };
await assertEqual(found, root, 'deno.json should be detected');
return { name: 'deno-json', ok: true };
}
async function testGradleSettings() {
const root = await mkTmpDir("gradle");
const nested = path.join(root, "app");
const root = await mkTmpDir('gradle');
const nested = path.join(root, 'app');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "settings.gradle"), "rootProject.name='tmp'\n");
await fs.writeFile(path.join(root, 'settings.gradle'), "rootProject.name='tmp'\n");
const found = await findProjectRoot(nested);
await assertEqual(found, root, "settings.gradle should be detected");
return { name: "gradle-settings", ok: true };
await assertEqual(found, root, 'settings.gradle should be detected');
return { name: 'gradle-settings', ok: true };
}
async function testMavenPom() {
const root = await mkTmpDir("maven");
const nested = path.join(root, "module");
const root = await mkTmpDir('maven');
const nested = path.join(root, 'module');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "pom.xml"), "<project></project>\n");
await fs.writeFile(path.join(root, 'pom.xml'), '<project></project>\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "pom.xml should be detected");
return { name: "maven-pom", ok: true };
await assertEqual(found, root, 'pom.xml should be detected');
return { name: 'maven-pom', ok: true };
}
async function testSbtBuild() {
const root = await mkTmpDir("sbt");
const nested = path.join(root, "sub");
const root = await mkTmpDir('sbt');
const nested = path.join(root, 'sub');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "build.sbt"), "name := \"tmp\"\n");
await fs.writeFile(path.join(root, 'build.sbt'), 'name := "tmp"\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "build.sbt should be detected");
return { name: "sbt-build", ok: true };
await assertEqual(found, root, 'build.sbt should be detected');
return { name: 'sbt-build', ok: true };
}
async function testComposer() {
const root = await mkTmpDir("composer");
const nested = path.join(root, "src");
const root = await mkTmpDir('composer');
const nested = path.join(root, 'src');
await fs.ensureDir(nested);
await fs.writeJson(path.join(root, "composer.json"), { name: "tmp/pkg" }, { spaces: 2 });
await fs.writeFile(path.join(root, "composer.lock"), "{}\n");
await fs.writeJson(path.join(root, 'composer.json'), { name: 'tmp/pkg' }, { spaces: 2 });
await fs.writeFile(path.join(root, 'composer.lock'), '{}\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "composer.{json,lock} should be detected");
return { name: "composer", ok: true };
await assertEqual(found, root, 'composer.{json,lock} should be detected');
return { name: 'composer', ok: true };
}
async function testCargo() {
const root = await mkTmpDir("cargo");
const nested = path.join(root, "src");
const root = await mkTmpDir('cargo');
const nested = path.join(root, 'src');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "Cargo.toml"), "[package]\nname='tmp'\nversion='0.0.0'\n");
await fs.writeFile(path.join(root, 'Cargo.toml'), "[package]\nname='tmp'\nversion='0.0.0'\n");
const found = await findProjectRoot(nested);
await assertEqual(found, root, "Cargo.toml should be detected");
return { name: "cargo", ok: true };
await assertEqual(found, root, 'Cargo.toml should be detected');
return { name: 'cargo', ok: true };
}
async function testNixFlake() {
const root = await mkTmpDir("nix");
const nested = path.join(root, "work");
const root = await mkTmpDir('nix');
const nested = path.join(root, 'work');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "flake.nix"), "{ }\n");
await fs.writeFile(path.join(root, 'flake.nix'), '{ }\n');
const found = await findProjectRoot(nested);
await assertEqual(found, root, "flake.nix should be detected");
return { name: "nix-flake", ok: true };
await assertEqual(found, root, 'flake.nix should be detected');
return { name: 'nix-flake', ok: true };
}
async function testChangesetConfig() {
const root = await mkTmpDir("changeset");
const nested = path.join(root, "pkg");
const root = await mkTmpDir('changeset');
const nested = path.join(root, 'pkg');
await fs.ensureDir(nested);
await fs.ensureDir(path.join(root, ".changeset"));
await fs.writeJson(path.join(root, ".changeset", "config.json"), { $schema: "https://unpkg.com/@changesets/config@2.3.1/schema.json" }, { spaces: 2 });
await fs.ensureDir(path.join(root, '.changeset'));
await fs.writeJson(
path.join(root, '.changeset', 'config.json'),
{ $schema: 'https://unpkg.com/@changesets/config@2.3.1/schema.json' },
{ spaces: 2 },
);
const found = await findProjectRoot(nested);
await assertEqual(found, root, ".changeset/config.json should be detected");
return { name: "changesets", ok: true };
await assertEqual(found, root, '.changeset/config.json should be detected');
return { name: 'changesets', ok: true };
}
async function testEnvCustomMarker() {
const root = await mkTmpDir("env-marker");
const nested = path.join(root, "dir");
const root = await mkTmpDir('env-marker');
const nested = path.join(root, 'dir');
await fs.ensureDir(nested);
await fs.writeFile(path.join(root, "MY_ROOT"), "\n");
await fs.writeFile(path.join(root, 'MY_ROOT'), '\n');
const prev = process.env.PROJECT_ROOT_MARKERS;
process.env.PROJECT_ROOT_MARKERS = "MY_ROOT";
process.env.PROJECT_ROOT_MARKERS = 'MY_ROOT';
try {
const found = await findProjectRoot(nested);
await assertEqual(found, root, "custom env marker should be honored");
await assertEqual(found, root, 'custom env marker should be honored');
} finally {
if (prev === undefined) delete process.env.PROJECT_ROOT_MARKERS; else process.env.PROJECT_ROOT_MARKERS = prev;
if (prev === undefined) delete process.env.PROJECT_ROOT_MARKERS;
else process.env.PROJECT_ROOT_MARKERS = prev;
}
return { name: "env-custom-marker", ok: true };
return { name: 'env-custom-marker', ok: true };
}
async function testPackageLowPriorityVsLock() {
const root = await mkTmpDir("pkg-vs-lock");
const nested = path.join(root, "nested");
await fs.ensureDir(path.join(nested, "deep"));
await fs.writeJson(path.join(nested, "package.json"), { name: "nested" }, { spaces: 2 });
await fs.writeFile(path.join(root, "yarn.lock"), "\n");
const found = await findProjectRoot(path.join(nested, "deep"));
await assertEqual(found, root, "lockfile at root should outrank nested package.json");
return { name: "package-vs-lock-priority", ok: true };
const root = await mkTmpDir('pkg-vs-lock');
const nested = path.join(root, 'nested');
await fs.ensureDir(path.join(nested, 'deep'));
await fs.writeJson(path.join(nested, 'package.json'), { name: 'nested' }, { spaces: 2 });
await fs.writeFile(path.join(root, 'yarn.lock'), '\n');
const found = await findProjectRoot(path.join(nested, 'deep'));
await assertEqual(found, root, 'lockfile at root should outrank nested package.json');
return { name: 'package-vs-lock-priority', ok: true };
}
async function run() {
@@ -381,25 +389,25 @@ async function run() {
try {
const r = await t();
results.push({ ...r, ok: true });
console.log(`${r.name}${r.skipped ? " (skipped)" : ""}`);
} catch (err) {
console.error(`${t.name}:`, err && err.message ? err.message : err);
results.push({ name: t.name, ok: false, error: String(err) });
console.log(`${r.name}${r.skipped ? ' (skipped)' : ''}`);
} catch (error) {
console.error(`${t.name}:`, error && error.message ? error.message : error);
results.push({ name: t.name, ok: false, error: String(error) });
}
}
const failed = results.filter((r) => !r.ok);
console.log("\nSummary:");
console.log('\nSummary:');
for (const r of results) {
console.log(`- ${r.name}: ${r.ok ? "ok" : "FAIL"}${r.skipped ? " (skipped)" : ""}`);
console.log(`- ${r.name}: ${r.ok ? 'ok' : 'FAIL'}${r.skipped ? ' (skipped)' : ''}`);
}
if (failed.length) {
if (failed.length > 0) {
process.exitCode = 1;
}
}
run().catch((e) => {
console.error("Fatal error:", e);
run().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});

View File

@@ -1,49 +1,44 @@
const fs = require("fs-extra");
const fs = require('fs-extra');
function escapeXml(str) {
if (typeof str !== "string") {
return String(str);
function escapeXml(string_) {
if (typeof string_ !== 'string') {
return String(string_);
}
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/'/g, "&apos;");
return string_.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll("'", '&apos;');
}
function indentFileContent(content) {
if (typeof content !== "string") {
if (typeof content !== 'string') {
return String(content);
}
return content.split("\n").map((line) => ` ${line}`);
return content.split('\n').map((line) => ` ${line}`);
}
function generateXMLOutput(aggregatedContent, outputPath) {
const { textFiles } = aggregatedContent;
const writeStream = fs.createWriteStream(outputPath, { encoding: "utf8" });
const writeStream = fs.createWriteStream(outputPath, { encoding: 'utf8' });
return new Promise((resolve, reject) => {
writeStream.on("error", reject);
writeStream.on("finish", resolve);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
writeStream.write('<?xml version="1.0" encoding="UTF-8"?>\n');
writeStream.write("<files>\n");
writeStream.write('<files>\n');
// Sort files by path for deterministic order
const filesSorted = [...textFiles].sort((a, b) =>
a.path.localeCompare(b.path)
);
const filesSorted = [...textFiles].sort((a, b) => a.path.localeCompare(b.path));
let index = 0;
const writeNext = () => {
if (index >= filesSorted.length) {
writeStream.write("</files>\n");
writeStream.write('</files>\n');
writeStream.end();
return;
}
const file = filesSorted[index++];
const p = escapeXml(file.path);
const content = typeof file.content === "string" ? file.content : "";
const content = typeof file.content === 'string' ? file.content : '';
if (content.length === 0) {
writeStream.write(`\t<file path='${p}'/>\n`);
@@ -51,27 +46,34 @@ function generateXMLOutput(aggregatedContent, outputPath) {
return;
}
const needsCdata = content.includes("<") || content.includes("&") ||
content.includes("]]>");
const needsCdata = content.includes('<') || content.includes('&') || content.includes(']]>');
if (needsCdata) {
// Open tag and CDATA on their own line with tab indent; content lines indented with two tabs
writeStream.write(`\t<file path='${p}'><![CDATA[\n`);
// Safely split any occurrences of "]]>" inside content, trim trailing newlines, indent each line with two tabs
const safe = content.replace(/]]>/g, "]]]]><![CDATA[>");
const trimmed = safe.replace(/[\r\n]+$/, "");
const indented = trimmed.length > 0
? trimmed.split("\n").map((line) => `\t\t${line}`).join("\n")
: "";
const safe = content.replaceAll(']]>', ']]]]><![CDATA[>');
const trimmed = safe.replace(/[\r\n]+$/, '');
const indented =
trimmed.length > 0
? trimmed
.split('\n')
.map((line) => `\t\t${line}`)
.join('\n')
: '';
writeStream.write(indented);
// Close CDATA and attach closing tag directly after the last content line
writeStream.write("]]></file>\n");
writeStream.write(']]></file>\n');
} else {
// Write opening tag then newline; indent content with two tabs; attach closing tag directly after last content char
writeStream.write(`\t<file path='${p}'>\n`);
const trimmed = content.replace(/[\r\n]+$/, "");
const indented = trimmed.length > 0
? trimmed.split("\n").map((line) => `\t\t${line}`).join("\n")
: "";
const trimmed = content.replace(/[\r\n]+$/, '');
const indented =
trimmed.length > 0
? trimmed
.split('\n')
.map((line) => `\t\t${line}`)
.join('\n')
: '';
writeStream.write(indented);
writeStream.write(`</file>\n`);
}

View File

@@ -1,13 +1,13 @@
#!/usr/bin/env node
const { program } = require('commander');
const path = require('path');
const fs = require('fs').promises;
const path = require('node:path');
const fs = require('node:fs').promises;
const yaml = require('js-yaml');
const chalk = require('chalk').default || require('chalk');
const inquirer = require('inquirer').default || require('inquirer');
const semver = require('semver');
const https = require('https');
const https = require('node:https');
// Handle both execution contexts (from root via npx or from installer directory)
let version;
@@ -18,18 +18,20 @@ try {
version = require('../package.json').version;
packageName = require('../package.json').name;
installer = require('../lib/installer');
} catch (e) {
} catch (error) {
// Fall back to root context (when run via npx from GitHub)
console.log(`Installer context not found (${e.message}), trying root context...`);
console.log(`Installer context not found (${error.message}), trying root context...`);
try {
version = require('../../../package.json').version;
installer = require('../../../tools/installer/lib/installer');
} catch (e2) {
console.error('Error: Could not load required modules. Please ensure you are running from the correct directory.');
} catch (error) {
console.error(
'Error: Could not load required modules. Please ensure you are running from the correct directory.',
);
console.error('Debug info:', {
__dirname,
cwd: process.cwd(),
error: e2.message
error: error.message,
});
process.exit(1);
}
@@ -45,8 +47,14 @@ program
.option('-f, --full', 'Install complete BMad Method')
.option('-x, --expansion-only', 'Install only expansion packs (no bmad-core)')
.option('-d, --directory <path>', 'Installation directory')
.option('-i, --ide <ide...>', 'Configure for specific IDE(s) - can specify multiple (cursor, claude-code, windsurf, trae, roo, kilo, cline, gemini, qwen-code, github-copilot, crush, other)')
.option('-e, --expansion-packs <packs...>', 'Install specific expansion packs (can specify multiple)')
.option(
'-i, --ide <ide...>',
'Configure for specific IDE(s) - can specify multiple (cursor, claude-code, windsurf, trae, roo, kilo, cline, gemini, qwen-code, github-copilot, other)',
)
.option(
'-e, --expansion-packs <packs...>',
'Install specific expansion packs (can specify multiple)',
)
.action(async (options) => {
try {
if (!options.full && !options.expansionOnly) {
@@ -64,8 +72,8 @@ program
const config = {
installType,
directory: options.directory || '.',
ides: (options.ide || []).filter(ide => ide !== 'other'),
expansionPacks: options.expansionPacks || []
ides: (options.ide || []).filter((ide) => ide !== 'other'),
expansionPacks: options.expansionPacks || [],
};
await installer.install(config);
process.exit(0);
@@ -96,28 +104,30 @@ program
.description('Check for BMad Update')
.action(async () => {
console.log('Checking for updates...');
// Make HTTP request to npm registry for latest version info
const req = https.get(`https://registry.npmjs.org/${packageName}/latest`, res => {
const req = https.get(`https://registry.npmjs.org/${packageName}/latest`, (res) => {
// Check for HTTP errors (non-200 status codes)
if (res.statusCode !== 200) {
console.error(chalk.red(`Update check failed: Received status code ${res.statusCode}`));
return;
}
// Accumulate response data chunks
let data = '';
res.on('data', chunk => data += chunk);
res.on('data', (chunk) => (data += chunk));
// Process complete response
res.on('end', () => {
try {
// Parse npm registry response and extract version
const latest = JSON.parse(data).version;
// Compare versions using semver
if (semver.gt(latest, version)) {
console.log(chalk.bold.blue(`⚠️ ${packageName} update available: ${version}${latest}`));
console.log(
chalk.bold.blue(`⚠️ ${packageName} update available: ${version}${latest}`),
);
console.log(chalk.bold.blue('\nInstall latest by running:'));
console.log(chalk.bold.magenta(` npm install ${packageName}@latest`));
console.log(chalk.dim(' or'));
@@ -131,14 +141,14 @@ program
}
});
});
// Handle network/connection errors
req.on('error', error => {
req.on('error', (error) => {
console.error(chalk.red('Update check failed:'), error.message);
});
// Set 30 second timeout to prevent hanging
req.setTimeout(30000, () => {
req.setTimeout(30_000, () => {
req.destroy();
console.error(chalk.red('Update check timed out'));
});
@@ -183,16 +193,17 @@ program
});
async function promptInstallation() {
// Display ASCII logo
console.log(chalk.bold.cyan(`
██████╗ ███╗ ███╗ █████╗ ██████╗ ███╗ ███╗███████╗████████╗██╗ ██╗ ██████╗ ██████╗
console.log(
chalk.bold.cyan(`
██████╗ ███╗ ███╗ █████╗ ██████╗ ███╗ ███╗███████╗████████╗██╗ ██╗ ██████╗ ██████╗
██╔══██╗████╗ ████║██╔══██╗██╔══██╗ ████╗ ████║██╔════╝╚══██╔══╝██║ ██║██╔═══██╗██╔══██╗
██████╔╝██╔████╔██║███████║██║ ██║█████╗██╔████╔██║█████╗ ██║ ███████║██║ ██║██║ ██║
██╔══██╗██║╚██╔╝██║██╔══██║██║ ██║╚════╝██║╚██╔╝██║██╔══╝ ██║ ██╔══██║██║ ██║██║ ██║
██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝ ██║ ╚═╝ ██║███████╗ ██║ ██║ ██║╚██████╔╝██████╔╝
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝
`));
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝
`),
);
console.log(chalk.bold.magenta('🚀 Universal AI Agent Framework for Any Domain'));
console.log(chalk.bold.blue(`✨ Installer v${version}\n`));
@@ -210,8 +221,8 @@ async function promptInstallation() {
return 'Please enter a valid project path';
}
return true;
}
}
},
},
]);
answers.directory = directory;
@@ -238,9 +249,10 @@ async function promptInstallation() {
if (state.type === 'v4_existing') {
const currentVersion = state.manifest?.version || 'unknown';
const newVersion = version; // Always use package.json version
const versionInfo = currentVersion === newVersion
? `(v${currentVersion} - reinstall)`
: `(v${currentVersion} → v${newVersion})`;
const versionInfo =
currentVersion === newVersion
? `(v${currentVersion} - reinstall)`
: `(v${currentVersion} → v${newVersion})`;
bmadOptionText = `Update ${coreShortTitle} ${versionInfo} .bmad-core`;
} else {
bmadOptionText = `${coreShortTitle} (v${version}) .bmad-core`;
@@ -249,7 +261,7 @@ async function promptInstallation() {
choices.push({
name: bmadOptionText,
value: 'bmad-core',
checked: true
checked: true,
});
// Add expansion pack options
@@ -260,9 +272,10 @@ async function promptInstallation() {
if (existing) {
const currentVersion = existing.manifest?.version || 'unknown';
const newVersion = pack.version;
const versionInfo = currentVersion === newVersion
? `(v${currentVersion} - reinstall)`
: `(v${currentVersion} → v${newVersion})`;
const versionInfo =
currentVersion === newVersion
? `(v${currentVersion} - reinstall)`
: `(v${currentVersion} → v${newVersion})`;
packOptionText = `Update ${pack.shortTitle} ${versionInfo} .${pack.id}`;
} else {
packOptionText = `${pack.shortTitle} (v${pack.version}) .${pack.id}`;
@@ -271,7 +284,7 @@ async function promptInstallation() {
choices.push({
name: packOptionText,
value: pack.id,
checked: false
checked: false,
});
}
@@ -287,13 +300,13 @@ async function promptInstallation() {
return 'Please select at least one item to install';
}
return true;
}
}
},
},
]);
// Process selections
answers.installType = selectedItems.includes('bmad-core') ? 'full' : 'expansion-only';
answers.expansionPacks = selectedItems.filter(item => item !== 'bmad-core');
answers.expansionPacks = selectedItems.filter((item) => item !== 'bmad-core');
// Ask sharding questions if installing BMad core
if (selectedItems.includes('bmad-core')) {
@@ -306,8 +319,8 @@ async function promptInstallation() {
type: 'confirm',
name: 'prdSharded',
message: 'Will the PRD (Product Requirements Document) be sharded into multiple files?',
default: true
}
default: true,
},
]);
answers.prdSharded = prdSharded;
@@ -317,18 +330,30 @@ async function promptInstallation() {
type: 'confirm',
name: 'architectureSharded',
message: 'Will the architecture documentation be sharded into multiple files?',
default: true
}
default: true,
},
]);
answers.architectureSharded = architectureSharded;
// Show warning if architecture sharding is disabled
if (!architectureSharded) {
console.log(chalk.yellow.bold('\n⚠ IMPORTANT: Architecture Sharding Disabled'));
console.log(chalk.yellow('With architecture sharding disabled, you should still create the files listed'));
console.log(chalk.yellow('in devLoadAlwaysFiles (like coding-standards.md, tech-stack.md, source-tree.md)'));
console.log(
chalk.yellow(
'With architecture sharding disabled, you should still create the files listed',
),
);
console.log(
chalk.yellow(
'in devLoadAlwaysFiles (like coding-standards.md, tech-stack.md, source-tree.md)',
),
);
console.log(chalk.yellow('as these are used by the dev agent at runtime.'));
console.log(chalk.yellow('\nAlternatively, you can remove these files from the devLoadAlwaysFiles list'));
console.log(
chalk.yellow(
'\nAlternatively, you can remove these files from the devLoadAlwaysFiles list',
),
);
console.log(chalk.yellow('in your core-config.yaml after installation.'));
const { acknowledge } = await inquirer.prompt([
@@ -336,8 +361,8 @@ async function promptInstallation() {
type: 'confirm',
name: 'acknowledge',
message: 'Do you acknowledge this requirement and want to proceed?',
default: false
}
default: false,
},
]);
if (!acknowledge) {
@@ -353,7 +378,11 @@ async function promptInstallation() {
while (!ideSelectionComplete) {
console.log(chalk.cyan('\n🛠 IDE Configuration'));
console.log(chalk.bold.yellow.bgRed(' ⚠️ IMPORTANT: This is a MULTISELECT! Use SPACEBAR to toggle each IDE! '));
console.log(
chalk.bold.yellow.bgRed(
' ⚠️ IMPORTANT: This is a MULTISELECT! Use SPACEBAR to toggle each IDE! ',
),
);
console.log(chalk.bold.magenta('🔸 Use arrow keys to navigate'));
console.log(chalk.bold.magenta('🔸 Use SPACEBAR to select/deselect IDEs'));
console.log(chalk.bold.magenta('🔸 Press ENTER when finished selecting\n'));
@@ -362,7 +391,8 @@ async function promptInstallation() {
{
type: 'checkbox',
name: 'ides',
message: 'Which IDE(s) do you want to configure? (Select with SPACEBAR, confirm with ENTER):',
message:
'Which IDE(s) do you want to configure? (Select with SPACEBAR, confirm with ENTER):',
choices: [
{ name: 'Cursor', value: 'cursor' },
{ name: 'Claude Code', value: 'claude-code' },
@@ -374,9 +404,9 @@ async function promptInstallation() {
{ name: 'Gemini CLI', value: 'gemini' },
{ name: 'Qwen Code', value: 'qwen-code' },
{ name: 'Crush', value: 'crush' },
{ name: 'Github Copilot', value: 'github-copilot' }
]
}
{ name: 'Github Copilot', value: 'github-copilot' },
],
},
]);
ides = ideResponse.ides;
@@ -387,13 +417,19 @@ async function promptInstallation() {
{
type: 'confirm',
name: 'confirmNoIde',
message: chalk.red('⚠️ You have NOT selected any IDEs. This means NO IDE integration will be set up. Is this correct?'),
default: false
}
message: chalk.red(
'⚠️ You have NOT selected any IDEs. This means NO IDE integration will be set up. Is this correct?',
),
default: false,
},
]);
if (!confirmNoIde) {
console.log(chalk.bold.red('\n🔄 Returning to IDE selection. Remember to use SPACEBAR to select IDEs!\n'));
console.log(
chalk.bold.red(
'\n🔄 Returning to IDE selection. Remember to use SPACEBAR to select IDEs!\n',
),
);
continue; // Go back to IDE selection only
}
}
@@ -407,7 +443,9 @@ async function promptInstallation() {
// Configure GitHub Copilot immediately if selected
if (ides.includes('github-copilot')) {
console.log(chalk.cyan('\n🔧 GitHub Copilot Configuration'));
console.log(chalk.dim('BMad works best with specific VS Code settings for optimal agent experience.\n'));
console.log(
chalk.dim('BMad works best with specific VS Code settings for optimal agent experience.\n'),
);
const { configChoice } = await inquirer.prompt([
{
@@ -417,19 +455,19 @@ async function promptInstallation() {
choices: [
{
name: 'Use recommended defaults (fastest setup)',
value: 'defaults'
value: 'defaults',
},
{
name: 'Configure each setting manually (customize to your preferences)',
value: 'manual'
value: 'manual',
},
{
name: 'Skip settings configuration (I\'ll configure manually later)',
value: 'skip'
}
name: "Skip settings configuration (I'll configure manually later)",
value: 'skip',
},
],
default: 'defaults'
}
default: 'defaults',
},
]);
answers.githubCopilotConfig = { configChoice };
@@ -440,14 +478,17 @@ async function promptInstallation() {
{
type: 'confirm',
name: 'includeWebBundles',
message: 'Would you like to include pre-built web bundles? (standalone files for ChatGPT, Claude, Gemini)',
default: false
}
message:
'Would you like to include pre-built web bundles? (standalone files for ChatGPT, Claude, Gemini)',
default: false,
},
]);
if (includeWebBundles) {
console.log(chalk.cyan('\n📦 Web bundles are standalone files perfect for web AI platforms.'));
console.log(chalk.dim(' You can choose different teams/agents than your IDE installation.\n'));
console.log(
chalk.dim(' You can choose different teams/agents than your IDE installation.\n'),
);
const { webBundleType } = await inquirer.prompt([
{
@@ -457,22 +498,22 @@ async function promptInstallation() {
choices: [
{
name: 'All available bundles (agents, teams, expansion packs)',
value: 'all'
value: 'all',
},
{
name: 'Specific teams only',
value: 'teams'
value: 'teams',
},
{
name: 'Individual agents only',
value: 'agents'
value: 'agents',
},
{
name: 'Custom selection',
value: 'custom'
}
]
}
value: 'custom',
},
],
},
]);
answers.webBundleType = webBundleType;
@@ -485,18 +526,18 @@ async function promptInstallation() {
type: 'checkbox',
name: 'selectedTeams',
message: 'Select team bundles to include:',
choices: teams.map(t => ({
choices: teams.map((t) => ({
name: `${t.icon || '📋'} ${t.name}: ${t.description}`,
value: t.id,
checked: webBundleType === 'teams' // Check all if teams-only mode
checked: webBundleType === 'teams', // Check all if teams-only mode
})),
validate: (answer) => {
if (answer.length < 1) {
if (answer.length === 0) {
return 'You must select at least one team.';
}
return true;
}
}
},
},
]);
answers.selectedWebBundleTeams = selectedTeams;
}
@@ -508,8 +549,8 @@ async function promptInstallation() {
type: 'confirm',
name: 'includeIndividualAgents',
message: 'Also include individual agent bundles?',
default: true
}
default: true,
},
]);
answers.includeIndividualAgents = includeIndividualAgents;
}
@@ -525,8 +566,8 @@ async function promptInstallation() {
return 'Please enter a valid directory path';
}
return true;
}
}
},
},
]);
answers.webBundlesDirectory = webBundlesDirectory;
}
@@ -539,6 +580,6 @@ async function promptInstallation() {
program.parse(process.argv);
// Show help if no command provided
if (!process.argv.slice(2).length) {
if (process.argv.slice(2).length === 0) {
program.outputHelp();
}
}

View File

@@ -55,4 +55,4 @@ cline-order:
game-designer: 12
game-developer: 13
game-sm: 14
infra-devops-platform: 15
infra-devops-platform: 15

View File

@@ -40,12 +40,12 @@ ide-configurations:
# 3. Crush will switch to that agent's persona / task
windsurf:
name: Windsurf
rule-dir: .windsurf/rules/
rule-dir: .windsurf/workflows/
format: multi-file
command-suffix: .md
instructions: |
# To use BMad agents in Windsurf:
# 1. Type @agent-name (e.g., "@dev", "@pm")
# 1. Type /agent-name (e.g., "/dev", "/pm")
# 2. Windsurf will adopt that agent's persona
trae:
name: Trae

View File

@@ -1,5 +1,5 @@
const fs = require('fs-extra');
const path = require('path');
const path = require('node:path');
const yaml = require('js-yaml');
const { extractYamlFromAgent } = require('../../lib/yaml-utils');
@@ -11,7 +11,7 @@ class ConfigLoader {
async load() {
if (this.config) return this.config;
try {
const configContent = await fs.readFile(this.configPath, 'utf8');
this.config = yaml.load(configContent);
@@ -28,30 +28,30 @@ class ConfigLoader {
async getAvailableAgents() {
const agentsDir = path.join(this.getBmadCorePath(), 'agents');
try {
const entries = await fs.readdir(agentsDir, { withFileTypes: true });
const agents = [];
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
const agentPath = path.join(agentsDir, entry.name);
const agentId = path.basename(entry.name, '.md');
try {
const agentContent = await fs.readFile(agentPath, 'utf8');
// Extract YAML block from agent file
const yamlContentText = extractYamlFromAgent(agentContent);
if (yamlContentText) {
const yamlContent = yaml.load(yamlContentText);
const agentConfig = yamlContent.agent || {};
agents.push({
id: agentId,
name: agentConfig.title || agentConfig.name || agentId,
file: `bmad-core/agents/${entry.name}`,
description: agentConfig.whenToUse || 'No description available'
description: agentConfig.whenToUse || 'No description available',
});
}
} catch (error) {
@@ -59,10 +59,10 @@ class ConfigLoader {
}
}
}
// Sort agents by name for consistent display
agents.sort((a, b) => a.name.localeCompare(b.name));
return agents;
} catch (error) {
console.warn(`Failed to read agents directory: ${error.message}`);
@@ -72,41 +72,45 @@ class ConfigLoader {
async getAvailableExpansionPacks() {
const expansionPacksDir = path.join(this.getBmadCorePath(), '..', 'expansion-packs');
try {
const entries = await fs.readdir(expansionPacksDir, { withFileTypes: true });
const expansionPacks = [];
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const packPath = path.join(expansionPacksDir, entry.name);
const configPath = path.join(packPath, 'config.yaml');
try {
// Read config.yaml
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.load(configContent);
expansionPacks.push({
id: entry.name,
name: config.name || entry.name,
description: config['short-title'] || config.description || 'No description available',
fullDescription: config.description || config['short-title'] || 'No description available',
description:
config['short-title'] || config.description || 'No description available',
fullDescription:
config.description || config['short-title'] || 'No description available',
version: config.version || '1.0.0',
author: config.author || 'BMad Team',
packPath: packPath,
dependencies: config.dependencies?.agents || []
dependencies: config.dependencies?.agents || [],
});
} catch (error) {
// Fallback if config.yaml doesn't exist or can't be read
console.warn(`Failed to read config for expansion pack ${entry.name}: ${error.message}`);
console.warn(
`Failed to read config for expansion pack ${entry.name}: ${error.message}`,
);
// Try to derive info from directory name as fallback
const name = entry.name
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
expansionPacks.push({
id: entry.name,
name: name,
@@ -115,12 +119,12 @@ class ConfigLoader {
version: '1.0.0',
author: 'BMad Team',
packPath: packPath,
dependencies: []
dependencies: [],
});
}
}
}
return expansionPacks;
} catch (error) {
console.warn(`Failed to read expansion packs directory: ${error.message}`);
@@ -132,16 +136,16 @@ class ConfigLoader {
// Use DependencyResolver to dynamically parse agent dependencies
const DependencyResolver = require('../../lib/dependency-resolver');
const resolver = new DependencyResolver(path.join(__dirname, '..', '..', '..'));
const agentDeps = await resolver.resolveAgentDependencies(agentId);
// Convert to flat list of file paths
const depPaths = [];
// Core files and utilities are included automatically by DependencyResolver
// Add agent file itself is already handled by installer
// Add all resolved resources
for (const resource of agentDeps.resources) {
const filePath = `.bmad-core/${resource.type}/${resource.id}.md`;
@@ -149,7 +153,7 @@ class ConfigLoader {
depPaths.push(filePath);
}
}
return depPaths;
}
@@ -175,25 +179,25 @@ class ConfigLoader {
async getAvailableTeams() {
const teamsDir = path.join(this.getBmadCorePath(), 'agent-teams');
try {
const entries = await fs.readdir(teamsDir, { withFileTypes: true });
const teams = [];
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.yaml')) {
const teamPath = path.join(teamsDir, entry.name);
try {
const teamContent = await fs.readFile(teamPath, 'utf8');
const teamConfig = yaml.load(teamContent);
if (teamConfig.bundle) {
teams.push({
id: path.basename(entry.name, '.yaml'),
name: teamConfig.bundle.name || entry.name,
description: teamConfig.bundle.description || 'Team configuration',
icon: teamConfig.bundle.icon || '📋'
icon: teamConfig.bundle.icon || '📋',
});
}
} catch (error) {
@@ -201,7 +205,7 @@ class ConfigLoader {
}
}
}
return teams;
} catch (error) {
console.warn(`Warning: Could not scan teams directory: ${error.message}`);
@@ -217,16 +221,16 @@ class ConfigLoader {
// Use DependencyResolver to dynamically parse team dependencies
const DependencyResolver = require('../../lib/dependency-resolver');
const resolver = new DependencyResolver(path.join(__dirname, '..', '..', '..'));
try {
const teamDeps = await resolver.resolveTeamDependencies(teamId);
// Convert to flat list of file paths
const depPaths = [];
// Add team config file
depPaths.push(`.bmad-core/agent-teams/${teamId}.yaml`);
// Add all agents
for (const agent of teamDeps.agents) {
const filePath = `.bmad-core/agents/${agent.id}.md`;
@@ -234,7 +238,7 @@ class ConfigLoader {
depPaths.push(filePath);
}
}
// Add all resolved resources
for (const resource of teamDeps.resources) {
const filePath = `.bmad-core/${resource.type}/${resource.id}.${resource.type === 'workflows' ? 'yaml' : 'md'}`;
@@ -242,7 +246,7 @@ class ConfigLoader {
depPaths.push(filePath);
}
}
return depPaths;
} catch (error) {
throw new Error(`Failed to resolve team dependencies for ${teamId}: ${error.message}`);
@@ -250,4 +254,4 @@ class ConfigLoader {
}
}
module.exports = new ConfigLoader();
module.exports = new ConfigLoader();

View File

@@ -1,32 +1,24 @@
const fs = require("fs-extra");
const path = require("path");
const crypto = require("crypto");
const yaml = require("js-yaml");
const chalk = require("chalk").default || require("chalk");
const { createReadStream, createWriteStream, promises: fsPromises } = require('fs');
const { pipeline } = require('stream/promises');
const fs = require('fs-extra');
const path = require('node:path');
const crypto = require('node:crypto');
const yaml = require('js-yaml');
const chalk = require('chalk');
const { createReadStream, createWriteStream, promises: fsPromises } = require('node:fs');
const { pipeline } = require('node:stream/promises');
const resourceLocator = require('./resource-locator');
class FileManager {
constructor() {
this.manifestDir = ".bmad-core";
this.manifestFile = "install-manifest.yaml";
}
constructor() {}
async copyFile(source, destination) {
try {
await fs.ensureDir(path.dirname(destination));
// Use streaming for large files (> 10MB)
const stats = await fs.stat(source);
if (stats.size > 10 * 1024 * 1024) {
await pipeline(
createReadStream(source),
createWriteStream(destination)
);
} else {
await fs.copy(source, destination);
}
await (stats.size > 10 * 1024 * 1024
? pipeline(createReadStream(source), createWriteStream(destination))
: fs.copy(source, destination));
return true;
} catch (error) {
console.error(chalk.red(`Failed to copy ${source}:`), error.message);
@@ -37,32 +29,24 @@ class FileManager {
async copyDirectory(source, destination) {
try {
await fs.ensureDir(destination);
// Use streaming copy for large directories
const files = await resourceLocator.findFiles('**/*', {
cwd: source,
nodir: true
nodir: true,
});
// Process files in batches to avoid memory issues
const batchSize = 50;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
for (let index = 0; index < files.length; index += batchSize) {
const batch = files.slice(index, index + batchSize);
await Promise.all(
batch.map(file =>
this.copyFile(
path.join(source, file),
path.join(destination, file)
)
)
batch.map((file) => this.copyFile(path.join(source, file), path.join(destination, file))),
);
}
return true;
} catch (error) {
console.error(
chalk.red(`Failed to copy directory ${source}:`),
error.message
);
console.error(chalk.red(`Failed to copy directory ${source}:`), error.message);
return false;
}
}
@@ -73,17 +57,16 @@ class FileManager {
for (const file of files) {
const sourcePath = path.join(sourceDir, file);
const destPath = path.join(destDir, file);
const destinationPath = path.join(destDir, file);
// Use root replacement if rootValue is provided and file needs it
const needsRootReplacement = rootValue && (file.endsWith('.md') || file.endsWith('.yaml') || file.endsWith('.yml'));
const needsRootReplacement =
rootValue && (file.endsWith('.md') || file.endsWith('.yaml') || file.endsWith('.yml'));
let success = false;
if (needsRootReplacement) {
success = await this.copyFileWithRootReplacement(sourcePath, destPath, rootValue);
} else {
success = await this.copyFile(sourcePath, destPath);
}
success = await (needsRootReplacement
? this.copyFileWithRootReplacement(sourcePath, destinationPath, rootValue)
: this.copyFile(sourcePath, destinationPath));
if (success) {
copied.push(file);
@@ -97,32 +80,28 @@ class FileManager {
try {
// Use streaming for hash calculation to reduce memory usage
const stream = createReadStream(filePath);
const hash = crypto.createHash("sha256");
const hash = crypto.createHash('sha256');
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest("hex").slice(0, 16);
} catch (error) {
return hash.digest('hex').slice(0, 16);
} catch {
return null;
}
}
async createManifest(installDir, config, files) {
const manifestPath = path.join(
installDir,
this.manifestDir,
this.manifestFile
);
const manifestPath = path.join(installDir, this.manifestDir, this.manifestFile);
// Read version from package.json
let coreVersion = "unknown";
let coreVersion = 'unknown';
try {
const packagePath = path.join(__dirname, '..', '..', '..', 'package.json');
const packageJson = require(packagePath);
coreVersion = packageJson.version;
} catch (error) {
} catch {
console.warn("Could not read version from package.json, using 'unknown'");
}
@@ -156,31 +135,23 @@ class FileManager {
}
async readManifest(installDir) {
const manifestPath = path.join(
installDir,
this.manifestDir,
this.manifestFile
);
const manifestPath = path.join(installDir, this.manifestDir, this.manifestFile);
try {
const content = await fs.readFile(manifestPath, "utf8");
const content = await fs.readFile(manifestPath, 'utf8');
return yaml.load(content);
} catch (error) {
} catch {
return null;
}
}
async readExpansionPackManifest(installDir, packId) {
const manifestPath = path.join(
installDir,
`.${packId}`,
this.manifestFile
);
const manifestPath = path.join(installDir, `.${packId}`, this.manifestFile);
try {
const content = await fs.readFile(manifestPath, "utf8");
const content = await fs.readFile(manifestPath, 'utf8');
return yaml.load(content);
} catch (error) {
} catch {
return null;
}
}
@@ -203,24 +174,24 @@ class FileManager {
async checkFileIntegrity(installDir, manifest) {
const result = {
missing: [],
modified: []
modified: [],
};
for (const file of manifest.files) {
const filePath = path.join(installDir, file.path);
// Skip checking the manifest file itself - it will always be different due to timestamps
if (file.path.endsWith('install-manifest.yaml')) {
continue;
}
if (!(await this.pathExists(filePath))) {
result.missing.push(file.path);
} else {
if (await this.pathExists(filePath)) {
const currentHash = await this.calculateFileHash(filePath);
if (currentHash && currentHash !== file.hash) {
result.modified.push(file.path);
}
} else {
result.missing.push(file.path);
}
}
@@ -228,7 +199,7 @@ class FileManager {
}
async backupFile(filePath) {
const backupPath = filePath + ".bak";
const backupPath = filePath + '.bak';
let counter = 1;
let finalBackupPath = backupPath;
@@ -256,7 +227,7 @@ class FileManager {
}
async readFile(filePath) {
return fs.readFile(filePath, "utf8");
return fs.readFile(filePath, 'utf8');
}
async writeFile(filePath, content) {
@@ -269,14 +240,10 @@ class FileManager {
}
async createExpansionPackManifest(installDir, packId, config, files) {
const manifestPath = path.join(
installDir,
`.${packId}`,
this.manifestFile
);
const manifestPath = path.join(installDir, `.${packId}`, this.manifestFile);
const manifest = {
version: config.expansionPackVersion || require("../../../package.json").version,
version: config.expansionPackVersion || require('../../../package.json').version,
installed_at: new Date().toISOString(),
install_type: config.installType,
expansion_pack_id: config.expansionPackId,
@@ -306,24 +273,24 @@ class FileManager {
async modifyCoreConfig(installDir, config) {
const coreConfigPath = path.join(installDir, '.bmad-core', 'core-config.yaml');
try {
// Read the existing core-config.yaml
const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8');
const coreConfig = yaml.load(coreConfigContent);
// Modify sharding settings if provided
if (config.prdSharded !== undefined) {
coreConfig.prd.prdSharded = config.prdSharded;
}
if (config.architectureSharded !== undefined) {
coreConfig.architecture.architectureSharded = config.architectureSharded;
}
// Write back the modified config
await fs.writeFile(coreConfigPath, yaml.dump(coreConfig, { indent: 2 }));
return true;
} catch (error) {
console.error(chalk.red(`Failed to modify core-config.yaml:`), error.message);
@@ -335,31 +302,32 @@ class FileManager {
try {
// Check file size to determine if we should stream
const stats = await fs.stat(source);
if (stats.size > 5 * 1024 * 1024) { // 5MB threshold
if (stats.size > 5 * 1024 * 1024) {
// 5MB threshold
// Use streaming for large files
const { Transform } = require('stream');
const { Transform } = require('node:stream');
const replaceStream = new Transform({
transform(chunk, encoding, callback) {
const modified = chunk.toString().replace(/\{root\}/g, rootValue);
const modified = chunk.toString().replaceAll('{root}', rootValue);
callback(null, modified);
}
},
});
await this.ensureDirectory(path.dirname(destination));
await pipeline(
createReadStream(source, { encoding: 'utf8' }),
replaceStream,
createWriteStream(destination, { encoding: 'utf8' })
createWriteStream(destination, { encoding: 'utf8' }),
);
} else {
// Regular approach for smaller files
const content = await fsPromises.readFile(source, 'utf8');
const updatedContent = content.replace(/\{root\}/g, rootValue);
const updatedContent = content.replaceAll('{root}', rootValue);
await this.ensureDirectory(path.dirname(destination));
await fsPromises.writeFile(destination, updatedContent, 'utf8');
}
return true;
} catch (error) {
console.error(chalk.red(`Failed to copy ${source} with root replacement:`), error.message);
@@ -367,45 +335,55 @@ class FileManager {
}
}
async copyDirectoryWithRootReplacement(source, destination, rootValue, fileExtensions = ['.md', '.yaml', '.yml']) {
async copyDirectoryWithRootReplacement(
source,
destination,
rootValue,
fileExtensions = ['.md', '.yaml', '.yml'],
) {
try {
await this.ensureDirectory(destination);
// Get all files in source directory
const files = await resourceLocator.findFiles('**/*', {
cwd: source,
nodir: true
const files = await resourceLocator.findFiles('**/*', {
cwd: source,
nodir: true,
});
let replacedCount = 0;
for (const file of files) {
const sourcePath = path.join(source, file);
const destPath = path.join(destination, file);
const destinationPath = path.join(destination, file);
// Check if this file type should have {root} replacement
const shouldReplace = fileExtensions.some(ext => file.endsWith(ext));
const shouldReplace = fileExtensions.some((extension) => file.endsWith(extension));
if (shouldReplace) {
if (await this.copyFileWithRootReplacement(sourcePath, destPath, rootValue)) {
if (await this.copyFileWithRootReplacement(sourcePath, destinationPath, rootValue)) {
replacedCount++;
}
} else {
// Regular copy for files that don't need replacement
await this.copyFile(sourcePath, destPath);
await this.copyFile(sourcePath, destinationPath);
}
}
if (replacedCount > 0) {
console.log(chalk.dim(` Processed ${replacedCount} files with {root} replacement`));
}
return true;
} catch (error) {
console.error(chalk.red(`Failed to copy directory ${source} with root replacement:`), error.message);
console.error(
chalk.red(`Failed to copy directory ${source} with root replacement:`),
error.message,
);
return false;
}
}
manifestDir = '.bmad-core';
manifestFile = 'install-manifest.yaml';
}
module.exports = new FileManager();

View File

@@ -3,13 +3,13 @@
* Reduces duplication and provides shared methods
*/
const path = require("path");
const fs = require("fs-extra");
const yaml = require("js-yaml");
const chalk = require("chalk").default || require("chalk");
const fileManager = require("./file-manager");
const resourceLocator = require("./resource-locator");
const { extractYamlFromAgent } = require("../../lib/yaml-utils");
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const chalk = require('chalk').default || require('chalk');
const fileManager = require('./file-manager');
const resourceLocator = require('./resource-locator');
const { extractYamlFromAgent } = require('../../lib/yaml-utils');
class BaseIdeSetup {
constructor() {
@@ -27,19 +27,19 @@ class BaseIdeSetup {
}
const allAgents = new Set();
// Get core agents
const coreAgents = await this.getCoreAgentIds(installDir);
coreAgents.forEach(id => allAgents.add(id));
for (const id of coreAgents) allAgents.add(id);
// Get expansion pack agents
const expansionPacks = await this.getInstalledExpansionPacks(installDir);
for (const pack of expansionPacks) {
const packAgents = await this.getExpansionPackAgents(pack.path);
packAgents.forEach(id => allAgents.add(id));
for (const id of packAgents) allAgents.add(id);
}
const result = Array.from(allAgents);
const result = [...allAgents];
this._agentCache.set(cacheKey, result);
return result;
}
@@ -50,14 +50,14 @@ class BaseIdeSetup {
async getCoreAgentIds(installDir) {
const coreAgents = [];
const corePaths = [
path.join(installDir, ".bmad-core", "agents"),
path.join(installDir, "bmad-core", "agents")
path.join(installDir, '.bmad-core', 'agents'),
path.join(installDir, 'bmad-core', 'agents'),
];
for (const agentsDir of corePaths) {
if (await fileManager.pathExists(agentsDir)) {
const files = await resourceLocator.findFiles("*.md", { cwd: agentsDir });
coreAgents.push(...files.map(file => path.basename(file, ".md")));
const files = await resourceLocator.findFiles('*.md', { cwd: agentsDir });
coreAgents.push(...files.map((file) => path.basename(file, '.md')));
break; // Use first found
}
}
@@ -76,13 +76,13 @@ class BaseIdeSetup {
// Use resource locator for efficient path finding
let agentPath = await resourceLocator.getAgentPath(agentId);
if (!agentPath) {
// Check installation-specific paths
const possiblePaths = [
path.join(installDir, ".bmad-core", "agents", `${agentId}.md`),
path.join(installDir, "bmad-core", "agents", `${agentId}.md`),
path.join(installDir, "common", "agents", `${agentId}.md`)
path.join(installDir, '.bmad-core', 'agents', `${agentId}.md`),
path.join(installDir, 'bmad-core', 'agents', `${agentId}.md`),
path.join(installDir, 'common', 'agents', `${agentId}.md`),
];
for (const testPath of possiblePaths) {
@@ -113,7 +113,7 @@ class BaseIdeSetup {
const metadata = yaml.load(yamlContent);
return metadata.agent_name || agentId;
}
} catch (error) {
} catch {
// Fallback to agent ID
}
return agentId;
@@ -129,31 +129,31 @@ class BaseIdeSetup {
}
const expansionPacks = [];
// Check for dot-prefixed expansion packs
const dotExpansions = await resourceLocator.findFiles(".bmad-*", { cwd: installDir });
const dotExpansions = await resourceLocator.findFiles('.bmad-*', { cwd: installDir });
for (const dotExpansion of dotExpansions) {
if (dotExpansion !== ".bmad-core") {
if (dotExpansion !== '.bmad-core') {
const packPath = path.join(installDir, dotExpansion);
const packName = dotExpansion.substring(1); // remove the dot
const packName = dotExpansion.slice(1); // remove the dot
expansionPacks.push({
name: packName,
path: packPath
path: packPath,
});
}
}
// Check other dot folders that have config.yaml
const allDotFolders = await resourceLocator.findFiles(".*", { cwd: installDir });
const allDotFolders = await resourceLocator.findFiles('.*', { cwd: installDir });
for (const folder of allDotFolders) {
if (!folder.startsWith(".bmad-") && folder !== ".bmad-core") {
if (!folder.startsWith('.bmad-') && folder !== '.bmad-core') {
const packPath = path.join(installDir, folder);
const configPath = path.join(packPath, "config.yaml");
const configPath = path.join(packPath, 'config.yaml');
if (await fileManager.pathExists(configPath)) {
expansionPacks.push({
name: folder.substring(1), // remove the dot
path: packPath
name: folder.slice(1), // remove the dot
path: packPath,
});
}
}
@@ -167,13 +167,13 @@ class BaseIdeSetup {
* Get expansion pack agents
*/
async getExpansionPackAgents(packPath) {
const agentsDir = path.join(packPath, "agents");
const agentsDir = path.join(packPath, 'agents');
if (!(await fileManager.pathExists(agentsDir))) {
return [];
}
const agentFiles = await resourceLocator.findFiles("*.md", { cwd: agentsDir });
return agentFiles.map(file => path.basename(file, ".md"));
const agentFiles = await resourceLocator.findFiles('*.md', { cwd: agentsDir });
return agentFiles.map((file) => path.basename(file, '.md'));
}
/**
@@ -183,27 +183,28 @@ class BaseIdeSetup {
const agentContent = await fileManager.readFile(agentPath);
const agentTitle = await this.getAgentTitle(agentId, installDir);
const yamlContent = extractYamlFromAgent(agentContent);
let content = "";
let content = '';
if (format === 'mdc') {
// MDC format for Cursor
content = "---\n";
content += "description: \n";
content += "globs: []\n";
content += "alwaysApply: false\n";
content += "---\n\n";
content = '---\n';
content += 'description: \n';
content += 'globs: []\n';
content += 'alwaysApply: false\n';
content += '---\n\n';
content += `# ${agentId.toUpperCase()} Agent Rule\n\n`;
content += `This rule is triggered when the user types \`@${agentId}\` and activates the ${agentTitle} agent persona.\n\n`;
content += "## Agent Activation\n\n";
content += "CRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:\n\n";
content += "```yaml\n";
content += yamlContent || agentContent.replace(/^#.*$/m, "").trim();
content += "\n```\n\n";
content += "## File Reference\n\n";
const relativePath = path.relative(installDir, agentPath).replace(/\\/g, '/');
content += '## Agent Activation\n\n';
content +=
'CRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:\n\n';
content += '```yaml\n';
content += yamlContent || agentContent.replace(/^#.*$/m, '').trim();
content += '\n```\n\n';
content += '## File Reference\n\n';
const relativePath = path.relative(installDir, agentPath).replaceAll('\\', '/');
content += `The complete agent definition is available in [${relativePath}](mdc:${relativePath}).\n\n`;
content += "## Usage\n\n";
content += '## Usage\n\n';
content += `When the user types \`@${agentId}\`, activate this ${agentTitle} persona and follow all instructions defined in the YAML configuration above.\n`;
} else if (format === 'claude') {
// Claude Code format
@@ -211,7 +212,7 @@ class BaseIdeSetup {
content += `When this command is used, adopt the following agent persona:\n\n`;
content += agentContent;
}
return content;
}
@@ -224,4 +225,4 @@ class BaseIdeSetup {
}
}
module.exports = BaseIdeSetup;
module.exports = BaseIdeSetup;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
* Helps identify memory leaks and optimize resource usage
*/
const v8 = require('v8');
const v8 = require('node:v8');
class MemoryProfiler {
constructor() {
@@ -19,7 +19,7 @@ class MemoryProfiler {
checkpoint(label) {
const memUsage = process.memoryUsage();
const heapStats = v8.getHeapStatistics();
const checkpoint = {
label,
timestamp: Date.now() - this.startTime,
@@ -28,18 +28,18 @@ class MemoryProfiler {
heapTotal: this.formatBytes(memUsage.heapTotal),
heapUsed: this.formatBytes(memUsage.heapUsed),
external: this.formatBytes(memUsage.external),
arrayBuffers: this.formatBytes(memUsage.arrayBuffers || 0)
arrayBuffers: this.formatBytes(memUsage.arrayBuffers || 0),
},
heap: {
totalHeapSize: this.formatBytes(heapStats.total_heap_size),
usedHeapSize: this.formatBytes(heapStats.used_heap_size),
heapSizeLimit: this.formatBytes(heapStats.heap_size_limit),
mallocedMemory: this.formatBytes(heapStats.malloced_memory),
externalMemory: this.formatBytes(heapStats.external_memory)
externalMemory: this.formatBytes(heapStats.external_memory),
},
raw: {
heapUsed: memUsage.heapUsed
}
heapUsed: memUsage.heapUsed,
},
};
// Track peak memory
@@ -55,8 +55,8 @@ class MemoryProfiler {
* Force garbage collection (requires --expose-gc flag)
*/
forceGC() {
if (global.gc) {
global.gc();
if (globalThis.gc) {
globalThis.gc();
return true;
}
return false;
@@ -67,16 +67,16 @@ class MemoryProfiler {
*/
getSummary() {
const currentMemory = process.memoryUsage();
return {
currentUsage: {
rss: this.formatBytes(currentMemory.rss),
heapTotal: this.formatBytes(currentMemory.heapTotal),
heapUsed: this.formatBytes(currentMemory.heapUsed)
heapUsed: this.formatBytes(currentMemory.heapUsed),
},
peakMemory: this.formatBytes(this.peakMemory),
totalCheckpoints: this.checkpoints.length,
runTime: `${((Date.now() - this.startTime) / 1000).toFixed(2)}s`
runTime: `${((Date.now() - this.startTime) / 1000).toFixed(2)}s`,
};
}
@@ -86,12 +86,12 @@ class MemoryProfiler {
getDetailedReport() {
const summary = this.getSummary();
const memoryGrowth = this.calculateMemoryGrowth();
return {
summary,
memoryGrowth,
checkpoints: this.checkpoints,
recommendations: this.getRecommendations(memoryGrowth)
recommendations: this.getRecommendations(memoryGrowth),
};
}
@@ -100,23 +100,23 @@ class MemoryProfiler {
*/
calculateMemoryGrowth() {
if (this.checkpoints.length < 2) return [];
const growth = [];
for (let i = 1; i < this.checkpoints.length; i++) {
const prev = this.checkpoints[i - 1];
const curr = this.checkpoints[i];
const heapDiff = curr.raw.heapUsed - prev.raw.heapUsed;
for (let index = 1; index < this.checkpoints.length; index++) {
const previous = this.checkpoints[index - 1];
const current = this.checkpoints[index];
const heapDiff = current.raw.heapUsed - previous.raw.heapUsed;
growth.push({
from: prev.label,
to: curr.label,
from: previous.label,
to: current.label,
heapGrowth: this.formatBytes(Math.abs(heapDiff)),
isIncrease: heapDiff > 0,
timeDiff: `${((curr.timestamp - prev.timestamp) / 1000).toFixed(2)}s`
timeDiff: `${((current.timestamp - previous.timestamp) / 1000).toFixed(2)}s`,
});
}
return growth;
}
@@ -125,40 +125,41 @@ class MemoryProfiler {
*/
getRecommendations(memoryGrowth) {
const recommendations = [];
// Check for large memory growth
const largeGrowths = memoryGrowth.filter(g => {
const largeGrowths = memoryGrowth.filter((g) => {
const bytes = this.parseBytes(g.heapGrowth);
return bytes > 50 * 1024 * 1024; // 50MB
});
if (largeGrowths.length > 0) {
recommendations.push({
type: 'warning',
message: `Large memory growth detected in ${largeGrowths.length} operations`,
details: largeGrowths.map(g => `${g.from}${g.to}: ${g.heapGrowth}`)
details: largeGrowths.map((g) => `${g.from}${g.to}: ${g.heapGrowth}`),
});
}
// Check peak memory
if (this.peakMemory > 500 * 1024 * 1024) { // 500MB
if (this.peakMemory > 500 * 1024 * 1024) {
// 500MB
recommendations.push({
type: 'warning',
message: `High peak memory usage: ${this.formatBytes(this.peakMemory)}`,
suggestion: 'Consider processing files in smaller batches'
suggestion: 'Consider processing files in smaller batches',
});
}
// Check for potential memory leaks
const continuousGrowth = this.checkContinuousGrowth();
if (continuousGrowth) {
recommendations.push({
type: 'error',
message: 'Potential memory leak detected',
details: 'Memory usage continuously increases without significant decreases'
details: 'Memory usage continuously increases without significant decreases',
});
}
return recommendations;
}
@@ -167,14 +168,14 @@ class MemoryProfiler {
*/
checkContinuousGrowth() {
if (this.checkpoints.length < 5) return false;
let increasingCount = 0;
for (let i = 1; i < this.checkpoints.length; i++) {
if (this.checkpoints[i].raw.heapUsed > this.checkpoints[i - 1].raw.heapUsed) {
for (let index = 1; index < this.checkpoints.length; index++) {
if (this.checkpoints[index].raw.heapUsed > this.checkpoints[index - 1].raw.heapUsed) {
increasingCount++;
}
}
// If memory increases in more than 80% of checkpoints, might be a leak
return increasingCount / (this.checkpoints.length - 1) > 0.8;
}
@@ -184,31 +185,31 @@ class MemoryProfiler {
*/
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
const index = Math.floor(Math.log(bytes) / Math.log(k));
return Number.parseFloat((bytes / Math.pow(k, index)).toFixed(2)) + ' ' + sizes[index];
}
/**
* Parse human-readable bytes back to number
*/
parseBytes(str) {
const match = str.match(/^([\d.]+)\s*([KMGT]?B?)$/i);
parseBytes(string_) {
const match = string_.match(/^([\d.]+)\s*([KMGT]?B?)$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const value = Number.parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multipliers = {
'B': 1,
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024
B: 1,
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
};
return value * (multipliers[unit] || 1);
}
@@ -221,4 +222,4 @@ class MemoryProfiler {
}
// Export singleton instance
module.exports = new MemoryProfiler();
module.exports = new MemoryProfiler();

View File

@@ -17,13 +17,13 @@ class ModuleManager {
const modules = await Promise.all([
this.getModule('chalk'),
this.getModule('ora'),
this.getModule('inquirer')
this.getModule('inquirer'),
]);
return {
chalk: modules[0],
ora: modules[1],
inquirer: modules[2]
inquirer: modules[2],
};
}
@@ -64,18 +64,24 @@ class ModuleManager {
*/
async _loadModule(moduleName) {
switch (moduleName) {
case 'chalk':
case 'chalk': {
return (await import('chalk')).default;
case 'ora':
}
case 'ora': {
return (await import('ora')).default;
case 'inquirer':
}
case 'inquirer': {
return (await import('inquirer')).default;
case 'glob':
}
case 'glob': {
return (await import('glob')).glob;
case 'globSync':
}
case 'globSync': {
return (await import('glob')).globSync;
default:
}
default: {
throw new Error(`Unknown module: ${moduleName}`);
}
}
}
@@ -93,13 +99,11 @@ class ModuleManager {
* @returns {Promise<Object>} Object with module names as keys
*/
async getModules(moduleNames) {
const modules = await Promise.all(
moduleNames.map(name => this.getModule(name))
);
const modules = await Promise.all(moduleNames.map((name) => this.getModule(name)));
return moduleNames.reduce((acc, name, index) => {
acc[name] = modules[index];
return acc;
return moduleNames.reduce((accumulator, name, index) => {
accumulator[name] = modules[index];
return accumulator;
}, {});
}
}
@@ -107,4 +111,4 @@ class ModuleManager {
// Singleton instance
const moduleManager = new ModuleManager();
module.exports = moduleManager;
module.exports = moduleManager;

View File

@@ -43,18 +43,18 @@ class ResourceLocator {
*/
async findFiles(pattern, options = {}) {
const cacheKey = `${pattern}:${JSON.stringify(options)}`;
if (this._globCache.has(cacheKey)) {
return this._globCache.get(cacheKey);
}
const { glob } = await moduleManager.getModules(['glob']);
const files = await glob(pattern, options);
// Cache for 5 minutes
this._globCache.set(cacheKey, files);
setTimeout(() => this._globCache.delete(cacheKey), 5 * 60 * 1000);
return files;
}
@@ -65,7 +65,7 @@ class ResourceLocator {
*/
async getAgentPath(agentId) {
const cacheKey = `agent:${agentId}`;
if (this._pathCache.has(cacheKey)) {
return this._pathCache.get(cacheKey);
}
@@ -96,7 +96,7 @@ class ResourceLocator {
*/
async getAvailableAgents() {
const cacheKey = 'all-agents';
if (this._pathCache.has(cacheKey)) {
return this._pathCache.get(cacheKey);
}
@@ -107,14 +107,11 @@ class ResourceLocator {
// Get agents from bmad-core
const coreAgents = await this.findFiles('agents/*.md', {
cwd: this.getBmadCorePath()
cwd: this.getBmadCorePath(),
});
for (const agentFile of coreAgents) {
const content = await fs.readFile(
path.join(this.getBmadCorePath(), agentFile),
'utf8'
);
const content = await fs.readFile(path.join(this.getBmadCorePath(), agentFile), 'utf8');
const yamlContent = extractYamlFromAgent(content);
if (yamlContent) {
try {
@@ -123,9 +120,9 @@ class ResourceLocator {
id: path.basename(agentFile, '.md'),
name: metadata.agent_name || path.basename(agentFile, '.md'),
description: metadata.description || 'No description available',
source: 'core'
source: 'core',
});
} catch (e) {
} catch {
// Skip invalid agents
}
}
@@ -144,7 +141,7 @@ class ResourceLocator {
*/
async getExpansionPacks() {
const cacheKey = 'expansion-packs';
if (this._pathCache.has(cacheKey)) {
return this._pathCache.get(cacheKey);
}
@@ -154,7 +151,7 @@ class ResourceLocator {
if (await fs.pathExists(expansionPacksPath)) {
const entries = await fs.readdir(expansionPacksPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const configPath = path.join(expansionPacksPath, entry.name, 'config.yaml');
@@ -167,11 +164,12 @@ class ResourceLocator {
name: config.name || entry.name,
version: config.version || '1.0.0',
description: config.description || 'No description available',
shortTitle: config['short-title'] || config.description || 'No description available',
shortTitle:
config['short-title'] || config.description || 'No description available',
author: config.author || 'Unknown',
path: path.join(expansionPacksPath, entry.name)
path: path.join(expansionPacksPath, entry.name),
});
} catch (e) {
} catch {
// Skip invalid packs
}
}
@@ -193,13 +191,13 @@ class ResourceLocator {
*/
async getTeamConfig(teamId) {
const cacheKey = `team:${teamId}`;
if (this._pathCache.has(cacheKey)) {
return this._pathCache.get(cacheKey);
}
const teamPath = path.join(this.getBmadCorePath(), 'agent-teams', `${teamId}.yaml`);
if (await fs.pathExists(teamPath)) {
try {
const yaml = require('js-yaml');
@@ -207,7 +205,7 @@ class ResourceLocator {
const config = yaml.load(content);
this._pathCache.set(cacheKey, config);
return config;
} catch (e) {
} catch {
return null;
}
}
@@ -222,7 +220,7 @@ class ResourceLocator {
*/
async getAgentDependencies(agentId) {
const cacheKey = `deps:${agentId}`;
if (this._pathCache.has(cacheKey)) {
return this._pathCache.get(cacheKey);
}
@@ -244,11 +242,11 @@ class ResourceLocator {
const yaml = require('js-yaml');
const metadata = yaml.load(yamlContent);
const dependencies = metadata.dependencies || {};
// Flatten dependencies
const allDeps = [];
const byType = {};
for (const [type, deps] of Object.entries(dependencies)) {
if (Array.isArray(deps)) {
byType[type] = deps;
@@ -261,7 +259,7 @@ class ResourceLocator {
const result = { all: allDeps, byType };
this._pathCache.set(cacheKey, result);
return result;
} catch (e) {
} catch {
return { all: [], byType: {} };
}
}
@@ -281,13 +279,13 @@ class ResourceLocator {
*/
async getIdeConfig(ideId) {
const cacheKey = `ide:${ideId}`;
if (this._pathCache.has(cacheKey)) {
return this._pathCache.get(cacheKey);
}
const idePath = path.join(this.getBmadCorePath(), 'ide-rules', `${ideId}.yaml`);
if (await fs.pathExists(idePath)) {
try {
const yaml = require('js-yaml');
@@ -295,7 +293,7 @@ class ResourceLocator {
const config = yaml.load(content);
this._pathCache.set(cacheKey, config);
return config;
} catch (e) {
} catch {
return null;
}
}
@@ -307,4 +305,4 @@ class ResourceLocator {
// Singleton instance
const resourceLocator = new ResourceLocator();
module.exports = resourceLocator;
module.exports = resourceLocator;

View File

@@ -2,14 +2,6 @@
"name": "bmad-method",
"version": "5.0.0",
"description": "BMad Method installer - AI-powered Agile development framework",
"main": "lib/installer.js",
"bin": {
"bmad": "./bin/bmad.js",
"bmad-method": "./bin/bmad.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"bmad",
"agile",
@@ -19,8 +11,24 @@
"installer",
"agents"
],
"author": "BMad Team",
"homepage": "https://github.com/bmad-team/bmad-method#readme",
"bugs": {
"url": "https://github.com/bmad-team/bmad-method/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/bmad-team/bmad-method.git"
},
"license": "MIT",
"author": "BMad Team",
"main": "lib/installer.js",
"bin": {
"bmad": "./bin/bmad.js",
"bmad-method": "./bin/bmad.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"chalk": "^4.1.2",
"commander": "^14.0.0",
@@ -32,13 +40,5 @@
},
"engines": {
"node": ">=20.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/bmad-team/bmad-method.git"
},
"bugs": {
"url": "https://github.com/bmad-team/bmad-method/issues"
},
"homepage": "https://github.com/bmad-team/bmad-method#readme"
}
}

View File

@@ -1,5 +1,5 @@
const fs = require('fs').promises;
const path = require('path');
const fs = require('node:fs').promises;
const path = require('node:path');
const yaml = require('js-yaml');
const { extractYamlFromAgent } = require('./yaml-utils');
@@ -14,23 +14,23 @@ class DependencyResolver {
async resolveAgentDependencies(agentId) {
const agentPath = path.join(this.bmadCore, 'agents', `${agentId}.md`);
const agentContent = await fs.readFile(agentPath, 'utf8');
// Extract YAML from markdown content with command cleaning
const yamlContent = extractYamlFromAgent(agentContent, true);
if (!yamlContent) {
throw new Error(`No YAML configuration found in agent ${agentId}`);
}
const agentConfig = yaml.load(yamlContent);
const dependencies = {
agent: {
id: agentId,
path: agentPath,
content: agentContent,
config: agentConfig
config: agentConfig,
},
resources: []
resources: [],
};
// Personas are now embedded in agent configs, no need to resolve separately
@@ -52,49 +52,49 @@ class DependencyResolver {
const teamPath = path.join(this.bmadCore, 'agent-teams', `${teamId}.yaml`);
const teamContent = await fs.readFile(teamPath, 'utf8');
const teamConfig = yaml.load(teamContent);
const dependencies = {
team: {
id: teamId,
path: teamPath,
content: teamContent,
config: teamConfig
config: teamConfig,
},
agents: [],
resources: new Map() // Use Map to deduplicate resources
resources: new Map(), // Use Map to deduplicate resources
};
// Always add bmad-orchestrator agent first if it's a team
const bmadAgent = await this.resolveAgentDependencies('bmad-orchestrator');
dependencies.agents.push(bmadAgent.agent);
bmadAgent.resources.forEach(res => {
for (const res of bmadAgent.resources) {
dependencies.resources.set(res.path, res);
});
}
// Resolve all agents in the team
let agentsToResolve = teamConfig.agents || [];
// Handle wildcard "*" - include all agents except bmad-master
if (agentsToResolve.includes('*')) {
const allAgents = await this.listAgents();
// Remove wildcard and add all agents except those already in the list and bmad-master
agentsToResolve = agentsToResolve.filter(a => a !== '*');
agentsToResolve = agentsToResolve.filter((a) => a !== '*');
for (const agent of allAgents) {
if (!agentsToResolve.includes(agent) && agent !== 'bmad-master') {
agentsToResolve.push(agent);
}
}
}
for (const agentId of agentsToResolve) {
if (agentId === 'bmad-orchestrator' || agentId === 'bmad-master') continue; // Already added or excluded
const agentDeps = await this.resolveAgentDependencies(agentId);
dependencies.agents.push(agentDeps.agent);
// Add resources with deduplication
agentDeps.resources.forEach(res => {
for (const res of agentDeps.resources) {
dependencies.resources.set(res.path, res);
});
}
}
// Resolve workflows
@@ -104,7 +104,7 @@ class DependencyResolver {
}
// Convert Map back to array
dependencies.resources = Array.from(dependencies.resources.values());
dependencies.resources = [...dependencies.resources.values()];
return dependencies;
}
@@ -123,12 +123,12 @@ class DependencyResolver {
try {
filePath = path.join(this.bmadCore, type, id);
content = await fs.readFile(filePath, 'utf8');
} catch (e) {
} catch {
// If not found in bmad-core, try common folder
try {
filePath = path.join(this.common, type, id);
content = await fs.readFile(filePath, 'utf8');
} catch (e2) {
} catch {
// File not found in either location
}
}
@@ -142,7 +142,7 @@ class DependencyResolver {
type,
id,
path: filePath,
content
content,
};
this.cache.set(cacheKey, resource);
@@ -156,10 +156,8 @@ class DependencyResolver {
async listAgents() {
try {
const files = await fs.readdir(path.join(this.bmadCore, 'agents'));
return files
.filter(f => f.endsWith('.md'))
.map(f => f.replace('.md', ''));
} catch (error) {
return files.filter((f) => f.endsWith('.md')).map((f) => f.replace('.md', ''));
} catch {
return [];
}
}
@@ -167,10 +165,8 @@ class DependencyResolver {
async listTeams() {
try {
const files = await fs.readdir(path.join(this.bmadCore, 'agent-teams'));
return files
.filter(f => f.endsWith('.yaml'))
.map(f => f.replace('.yaml', ''));
} catch (error) {
return files.filter((f) => f.endsWith('.yaml')).map((f) => f.replace('.yaml', ''));
} catch {
return [];
}
}

View File

@@ -10,20 +10,20 @@
*/
function extractYamlFromAgent(agentContent, cleanCommands = false) {
// Remove carriage returns and match YAML block
const yamlMatch = agentContent.replace(/\r/g, "").match(/```ya?ml\n([\s\S]*?)\n```/);
const yamlMatch = agentContent.replaceAll('\r', '').match(/```ya?ml\n([\s\S]*?)\n```/);
if (!yamlMatch) return null;
let yamlContent = yamlMatch[1].trim();
// Clean up command descriptions if requested
// Converts "- command - description" to just "- command"
if (cleanCommands) {
yamlContent = yamlContent.replace(/^(\s*-)(\s*"[^"]+")(\s*-\s*.*)$/gm, '$1$2');
yamlContent = yamlContent.replaceAll(/^(\s*-)(\s*"[^"]+")(\s*-\s*.*)$/gm, '$1$2');
}
return yamlContent;
}
module.exports = {
extractYamlFromAgent
};
extractYamlFromAgent,
};

View File

@@ -2,8 +2,8 @@
* Semantic-release plugin to sync installer package.json version
*/
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
// This function runs during the "prepare" step of semantic-release
function prepare(_, { nextRelease, logger }) {
@@ -14,13 +14,13 @@ function prepare(_, { nextRelease, logger }) {
if (!fs.existsSync(file)) return logger.log('Installer package.json not found, skipping');
// Read and parse the package.json file
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
const package_ = JSON.parse(fs.readFileSync(file, 'utf8'));
// Update the version field with the next release version
pkg.version = nextRelease.version;
package_.version = nextRelease.version;
// Write the updated JSON back to the file
fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');
fs.writeFileSync(file, JSON.stringify(package_, null, 2) + '\n');
// Log success message
logger.log(`Synced installer package.json to version ${nextRelease.version}`);

View File

@@ -1,8 +1,8 @@
// ASCII banner art definitions extracted from banners.js to separate art from logic
const BMAD_TITLE = "BMAD-METHOD";
const FLATTENER_TITLE = "FLATTENER";
const INSTALLER_TITLE = "INSTALLER";
const BMAD_TITLE = 'BMAD-METHOD';
const FLATTENER_TITLE = 'FLATTENER';
const INSTALLER_TITLE = 'INSTALLER';
// Large ASCII blocks (block-style fonts)
const BMAD_LARGE = `

View File

@@ -1,28 +1,26 @@
#!/usr/bin/env node
/**
* Sync installer package.json version with main package.json
* Used by semantic-release to keep versions in sync
*/
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
function syncInstallerVersion() {
// Read main package.json
const mainPackagePath = path.join(__dirname, '..', 'package.json');
const mainPackage = JSON.parse(fs.readFileSync(mainPackagePath, 'utf8'));
// Read installer package.json
const installerPackagePath = path.join(__dirname, 'installer', 'package.json');
const installerPackage = JSON.parse(fs.readFileSync(installerPackagePath, 'utf8'));
// Update installer version to match main version
installerPackage.version = mainPackage.version;
// Write back installer package.json
fs.writeFileSync(installerPackagePath, JSON.stringify(installerPackage, null, 2) + '\n');
console.log(`Synced installer version to ${mainPackage.version}`);
}
@@ -31,4 +29,4 @@ if (require.main === module) {
syncInstallerVersion();
}
module.exports = { syncInstallerVersion };
module.exports = { syncInstallerVersion };

View File

@@ -1,18 +1,16 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const args = process.argv.slice(2);
const arguments_ = process.argv.slice(2);
if (args.length < 2) {
if (arguments_.length < 2) {
console.log('Usage: node update-expansion-version.js <expansion-pack-id> <new-version>');
console.log('Example: node update-expansion-version.js bmad-creator-tools 1.1.0');
process.exit(1);
}
const [packId, newVersion] = args;
const [packId, newVersion] = arguments_;
// Validate version format
if (!/^\d+\.\d+\.\d+$/.test(newVersion)) {
@@ -24,31 +22,32 @@ async function updateVersion() {
try {
// Update in config.yaml
const configPath = path.join(__dirname, '..', 'expansion-packs', packId, 'config.yaml');
if (!fs.existsSync(configPath)) {
console.error(`Error: Expansion pack '${packId}' not found`);
process.exit(1);
}
const configContent = fs.readFileSync(configPath, 'utf8');
const config = yaml.load(configContent);
const oldVersion = config.version || 'unknown';
config.version = newVersion;
const updatedYaml = yaml.dump(config, { indent: 2 });
fs.writeFileSync(configPath, updatedYaml);
console.log(`✓ Updated ${packId}/config.yaml: ${oldVersion}${newVersion}`);
console.log(`\n✓ Successfully updated ${packId} to version ${newVersion}`);
console.log('\nNext steps:');
console.log('1. Test the changes');
console.log('2. Commit: git add -A && git commit -m "chore: bump ' + packId + ' to v' + newVersion + '"');
console.log(
'2. Commit: git add -A && git commit -m "chore: bump ' + packId + ' to v' + newVersion + '"',
);
} catch (error) {
console.error('Error updating version:', error.message);
process.exit(1);
}
}
updateVersion();
updateVersion();

View File

@@ -1,15 +1,15 @@
const fs = require("fs").promises;
const path = require("path");
const { glob } = require("glob");
const fs = require('node:fs').promises;
const path = require('node:path');
const { glob } = require('glob');
// Dynamic imports for ES modules
let chalk, ora, inquirer;
// Initialize ES modules
async function initializeModules() {
chalk = (await import("chalk")).default;
ora = (await import("ora")).default;
inquirer = (await import("inquirer")).default;
chalk = (await import('chalk')).default;
ora = (await import('ora')).default;
inquirer = (await import('inquirer')).default;
}
class V3ToV4Upgrader {
@@ -25,23 +25,15 @@ class V3ToV4Upgrader {
process.stdin.resume();
// 1. Welcome message
console.log(
chalk.bold("\nWelcome to BMad-Method V3 to V4 Upgrade Tool\n")
);
console.log(
"This tool will help you upgrade your BMad-Method V3 project to V4.\n"
);
console.log(chalk.cyan("What this tool does:"));
console.log("- Creates a backup of your V3 files (.bmad-v3-backup/)");
console.log("- Installs the new V4 .bmad-core structure");
console.log(
"- Preserves your PRD, Architecture, and Stories in the new format\n"
);
console.log(chalk.yellow("What this tool does NOT do:"));
console.log(
"- Modify your document content (use doc-migration-task after upgrade)"
);
console.log("- Touch any files outside bmad-agent/ and docs/\n");
console.log(chalk.bold('\nWelcome to BMad-Method V3 to V4 Upgrade Tool\n'));
console.log('This tool will help you upgrade your BMad-Method V3 project to V4.\n');
console.log(chalk.cyan('What this tool does:'));
console.log('- Creates a backup of your V3 files (.bmad-v3-backup/)');
console.log('- Installs the new V4 .bmad-core structure');
console.log('- Preserves your PRD, Architecture, and Stories in the new format\n');
console.log(chalk.yellow('What this tool does NOT do:'));
console.log('- Modify your document content (use doc-migration-task after upgrade)');
console.log('- Touch any files outside bmad-agent/ and docs/\n');
// 2. Get project path
const projectPath = await this.getProjectPath(options.projectPath);
@@ -49,15 +41,11 @@ class V3ToV4Upgrader {
// 3. Validate V3 structure
const validation = await this.validateV3Project(projectPath);
if (!validation.isValid) {
console.error(
chalk.red("\nError: This doesn't appear to be a V3 project.")
);
console.error("Expected to find:");
console.error("- bmad-agent/ directory");
console.error("- docs/ directory\n");
console.error(
"Please check you're in the correct directory and try again."
);
console.error(chalk.red("\nError: This doesn't appear to be a V3 project."));
console.error('Expected to find:');
console.error('- bmad-agent/ directory');
console.error('- docs/ directory\n');
console.error("Please check you're in the correct directory and try again.");
return;
}
@@ -68,15 +56,15 @@ class V3ToV4Upgrader {
if (!options.dryRun) {
const { confirm } = await inquirer.prompt([
{
type: "confirm",
name: "confirm",
message: "Continue with upgrade?",
type: 'confirm',
name: 'confirm',
message: 'Continue with upgrade?',
default: true,
},
]);
if (!confirm) {
console.log("Upgrade cancelled.");
console.log('Upgrade cancelled.');
return;
}
}
@@ -106,7 +94,7 @@ class V3ToV4Upgrader {
process.exit(0);
} catch (error) {
console.error(chalk.red("\nUpgrade error:"), error.message);
console.error(chalk.red('\nUpgrade error:'), error.message);
process.exit(1);
}
}
@@ -118,9 +106,9 @@ class V3ToV4Upgrader {
const { projectPath } = await inquirer.prompt([
{
type: "input",
name: "projectPath",
message: "Please enter the path to your V3 project:",
type: 'input',
name: 'projectPath',
message: 'Please enter the path to your V3 project:',
default: process.cwd(),
},
]);
@@ -129,45 +117,45 @@ class V3ToV4Upgrader {
}
async validateV3Project(projectPath) {
const spinner = ora("Validating project structure...").start();
const spinner = ora('Validating project structure...').start();
try {
const bmadAgentPath = path.join(projectPath, "bmad-agent");
const docsPath = path.join(projectPath, "docs");
const bmadAgentPath = path.join(projectPath, 'bmad-agent');
const docsPath = path.join(projectPath, 'docs');
const hasBmadAgent = await this.pathExists(bmadAgentPath);
const hasDocs = await this.pathExists(docsPath);
if (hasBmadAgent) {
spinner.text = "✓ Found bmad-agent/ directory";
console.log(chalk.green("\n✓ Found bmad-agent/ directory"));
spinner.text = '✓ Found bmad-agent/ directory';
console.log(chalk.green('\n✓ Found bmad-agent/ directory'));
}
if (hasDocs) {
console.log(chalk.green("✓ Found docs/ directory"));
console.log(chalk.green('✓ Found docs/ directory'));
}
const isValid = hasBmadAgent && hasDocs;
if (isValid) {
spinner.succeed("This appears to be a valid V3 project");
spinner.succeed('This appears to be a valid V3 project');
} else {
spinner.fail("Invalid V3 project structure");
spinner.fail('Invalid V3 project structure');
}
return { isValid, hasBmadAgent, hasDocs };
} catch (error) {
spinner.fail("Validation failed");
spinner.fail('Validation failed');
throw error;
}
}
async analyzeProject(projectPath) {
const docsPath = path.join(projectPath, "docs");
const bmadAgentPath = path.join(projectPath, "bmad-agent");
const docsPath = path.join(projectPath, 'docs');
const bmadAgentPath = path.join(projectPath, 'bmad-agent');
// Find PRD
const prdCandidates = ["prd.md", "PRD.md", "product-requirements.md"];
const prdCandidates = ['prd.md', 'PRD.md', 'product-requirements.md'];
let prdFile = null;
for (const candidate of prdCandidates) {
const candidatePath = path.join(docsPath, candidate);
@@ -178,11 +166,7 @@ class V3ToV4Upgrader {
}
// Find Architecture
const archCandidates = [
"architecture.md",
"Architecture.md",
"technical-architecture.md",
];
const archCandidates = ['architecture.md', 'Architecture.md', 'technical-architecture.md'];
let archFile = null;
for (const candidate of archCandidates) {
const candidatePath = path.join(docsPath, candidate);
@@ -194,9 +178,9 @@ class V3ToV4Upgrader {
// Find Front-end Architecture (V3 specific)
const frontEndCandidates = [
"front-end-architecture.md",
"frontend-architecture.md",
"ui-architecture.md",
'front-end-architecture.md',
'frontend-architecture.md',
'ui-architecture.md',
];
let frontEndArchFile = null;
for (const candidate of frontEndCandidates) {
@@ -209,10 +193,10 @@ class V3ToV4Upgrader {
// Find UX/UI spec
const uxSpecCandidates = [
"ux-ui-spec.md",
"ux-ui-specification.md",
"ui-spec.md",
"ux-spec.md",
'ux-ui-spec.md',
'ux-ui-specification.md',
'ui-spec.md',
'ux-spec.md',
];
let uxSpecFile = null;
for (const candidate of uxSpecCandidates) {
@@ -224,12 +208,7 @@ class V3ToV4Upgrader {
}
// Find v0 prompt or UX prompt
const uxPromptCandidates = [
"v0-prompt.md",
"ux-prompt.md",
"ui-prompt.md",
"design-prompt.md",
];
const uxPromptCandidates = ['v0-prompt.md', 'ux-prompt.md', 'ui-prompt.md', 'design-prompt.md'];
let uxPromptFile = null;
for (const candidate of uxPromptCandidates) {
const candidatePath = path.join(docsPath, candidate);
@@ -240,19 +219,19 @@ class V3ToV4Upgrader {
}
// Find epic files
const epicFiles = await glob("epic*.md", { cwd: docsPath });
const epicFiles = await glob('epic*.md', { cwd: docsPath });
// Find story files
const storiesPath = path.join(docsPath, "stories");
const storiesPath = path.join(docsPath, 'stories');
let storyFiles = [];
if (await this.pathExists(storiesPath)) {
storyFiles = await glob("*.md", { cwd: storiesPath });
storyFiles = await glob('*.md', { cwd: storiesPath });
}
// Count custom files in bmad-agent
const bmadAgentFiles = await glob("**/*.md", {
const bmadAgentFiles = await glob('**/*.md', {
cwd: bmadAgentPath,
ignore: ["node_modules/**"],
ignore: ['node_modules/**'],
});
return {
@@ -268,279 +247,233 @@ class V3ToV4Upgrader {
}
async showPreflightCheck(analysis, options) {
console.log(chalk.bold("\nProject Analysis:"));
console.log(chalk.bold('\nProject Analysis:'));
console.log(
`- PRD found: ${
analysis.prdFile
? `docs/${analysis.prdFile}`
: chalk.yellow("Not found")
}`
`- PRD found: ${analysis.prdFile ? `docs/${analysis.prdFile}` : chalk.yellow('Not found')}`,
);
console.log(
`- Architecture found: ${
analysis.archFile
? `docs/${analysis.archFile}`
: chalk.yellow("Not found")
}`
analysis.archFile ? `docs/${analysis.archFile}` : chalk.yellow('Not found')
}`,
);
if (analysis.frontEndArchFile) {
console.log(
`- Front-end Architecture found: docs/${analysis.frontEndArchFile}`
);
console.log(`- Front-end Architecture found: docs/${analysis.frontEndArchFile}`);
}
console.log(
`- UX/UI Spec found: ${
analysis.uxSpecFile
? `docs/${analysis.uxSpecFile}`
: chalk.yellow("Not found")
}`
analysis.uxSpecFile ? `docs/${analysis.uxSpecFile}` : chalk.yellow('Not found')
}`,
);
console.log(
`- UX/Design Prompt found: ${
analysis.uxPromptFile
? `docs/${analysis.uxPromptFile}`
: chalk.yellow("Not found")
}`
);
console.log(
`- Epic files found: ${analysis.epicFiles.length} files (epic*.md)`
);
console.log(
`- Stories found: ${analysis.storyFiles.length} files in docs/stories/`
analysis.uxPromptFile ? `docs/${analysis.uxPromptFile}` : chalk.yellow('Not found')
}`,
);
console.log(`- Epic files found: ${analysis.epicFiles.length} files (epic*.md)`);
console.log(`- Stories found: ${analysis.storyFiles.length} files in docs/stories/`);
console.log(`- Custom files in bmad-agent/: ${analysis.customFileCount}`);
if (!options.dryRun) {
console.log("\nThe following will be backed up to .bmad-v3-backup/:");
console.log("- bmad-agent/ (entire directory)");
console.log("- docs/ (entire directory)");
console.log('\nThe following will be backed up to .bmad-v3-backup/:');
console.log('- bmad-agent/ (entire directory)');
console.log('- docs/ (entire directory)');
if (analysis.epicFiles.length > 0) {
console.log(
chalk.green(
"\nNote: Epic files found! They will be placed in docs/prd/ with an index.md file."
)
'\nNote: Epic files found! They will be placed in docs/prd/ with an index.md file.',
),
);
console.log(
chalk.green(
"Since epic files exist, you won't need to shard the PRD after upgrade."
)
chalk.green("Since epic files exist, you won't need to shard the PRD after upgrade."),
);
}
}
}
async createBackup(projectPath) {
const spinner = ora("Creating backup...").start();
const spinner = ora('Creating backup...').start();
try {
const backupPath = path.join(projectPath, ".bmad-v3-backup");
const backupPath = path.join(projectPath, '.bmad-v3-backup');
// Check if backup already exists
if (await this.pathExists(backupPath)) {
spinner.fail("Backup directory already exists");
console.error(
chalk.red(
"\nError: Backup directory .bmad-v3-backup/ already exists."
)
);
console.error("\nThis might mean an upgrade was already attempted.");
console.error(
"Please remove or rename the existing backup and try again."
);
throw new Error("Backup already exists");
spinner.fail('Backup directory already exists');
console.error(chalk.red('\nError: Backup directory .bmad-v3-backup/ already exists.'));
console.error('\nThis might mean an upgrade was already attempted.');
console.error('Please remove or rename the existing backup and try again.');
throw new Error('Backup already exists');
}
// Create backup directory
await fs.mkdir(backupPath, { recursive: true });
spinner.text = "✓ Created .bmad-v3-backup/";
console.log(chalk.green("\n✓ Created .bmad-v3-backup/"));
spinner.text = '✓ Created .bmad-v3-backup/';
console.log(chalk.green('\n✓ Created .bmad-v3-backup/'));
// Move bmad-agent
const bmadAgentSrc = path.join(projectPath, "bmad-agent");
const bmadAgentDest = path.join(backupPath, "bmad-agent");
await fs.rename(bmadAgentSrc, bmadAgentDest);
console.log(chalk.green("✓ Moved bmad-agent/ to backup"));
const bmadAgentSource = path.join(projectPath, 'bmad-agent');
const bmadAgentDestination = path.join(backupPath, 'bmad-agent');
await fs.rename(bmadAgentSource, bmadAgentDestination);
console.log(chalk.green('✓ Moved bmad-agent/ to backup'));
// Move docs
const docsSrc = path.join(projectPath, "docs");
const docsDest = path.join(backupPath, "docs");
const docsSrc = path.join(projectPath, 'docs');
const docsDest = path.join(backupPath, 'docs');
await fs.rename(docsSrc, docsDest);
console.log(chalk.green("✓ Moved docs/ to backup"));
console.log(chalk.green('✓ Moved docs/ to backup'));
spinner.succeed("Backup created successfully");
spinner.succeed('Backup created successfully');
} catch (error) {
spinner.fail("Backup failed");
spinner.fail('Backup failed');
throw error;
}
}
async installV4Structure(projectPath) {
const spinner = ora("Installing V4 structure...").start();
const spinner = ora('Installing V4 structure...').start();
try {
// Get the source bmad-core directory (without dot prefix)
const sourcePath = path.join(__dirname, "..", "..", "bmad-core");
const destPath = path.join(projectPath, ".bmad-core");
const sourcePath = path.join(__dirname, '..', '..', 'bmad-core');
const destinationPath = path.join(projectPath, '.bmad-core');
// Copy .bmad-core
await this.copyDirectory(sourcePath, destPath);
spinner.text = "✓ Copied fresh .bmad-core/ directory from V4";
console.log(
chalk.green("\n✓ Copied fresh .bmad-core/ directory from V4")
);
await this.copyDirectory(sourcePath, destinationPath);
spinner.text = '✓ Copied fresh .bmad-core/ directory from V4';
console.log(chalk.green('\n✓ Copied fresh .bmad-core/ directory from V4'));
// Create docs directory
const docsPath = path.join(projectPath, "docs");
const docsPath = path.join(projectPath, 'docs');
await fs.mkdir(docsPath, { recursive: true });
console.log(chalk.green("✓ Created new docs/ directory"));
console.log(chalk.green('✓ Created new docs/ directory'));
// Create install manifest for future updates
await this.createInstallManifest(projectPath);
console.log(chalk.green("✓ Created install manifest"));
console.log(chalk.green('✓ Created install manifest'));
console.log(
chalk.yellow(
"\nNote: Your V3 bmad-agent content has been backed up and NOT migrated."
)
chalk.yellow('\nNote: Your V3 bmad-agent content has been backed up and NOT migrated.'),
);
console.log(
chalk.yellow(
"The new V4 agents are completely different and look for different file structures."
)
'The new V4 agents are completely different and look for different file structures.',
),
);
spinner.succeed("V4 structure installed successfully");
spinner.succeed('V4 structure installed successfully');
} catch (error) {
spinner.fail("V4 installation failed");
spinner.fail('V4 installation failed');
throw error;
}
}
async migrateDocuments(projectPath, analysis) {
const spinner = ora("Migrating your project documents...").start();
const spinner = ora('Migrating your project documents...').start();
try {
const backupDocsPath = path.join(projectPath, ".bmad-v3-backup", "docs");
const newDocsPath = path.join(projectPath, "docs");
const backupDocsPath = path.join(projectPath, '.bmad-v3-backup', 'docs');
const newDocsPath = path.join(projectPath, 'docs');
let copiedCount = 0;
// Copy PRD
if (analysis.prdFile) {
const src = path.join(backupDocsPath, analysis.prdFile);
const dest = path.join(newDocsPath, analysis.prdFile);
await fs.copyFile(src, dest);
const source = path.join(backupDocsPath, analysis.prdFile);
const destination = path.join(newDocsPath, analysis.prdFile);
await fs.copyFile(source, destination);
console.log(chalk.green(`\n✓ Copied PRD to docs/${analysis.prdFile}`));
copiedCount++;
}
// Copy Architecture
if (analysis.archFile) {
const src = path.join(backupDocsPath, analysis.archFile);
const dest = path.join(newDocsPath, analysis.archFile);
await fs.copyFile(src, dest);
console.log(
chalk.green(`✓ Copied Architecture to docs/${analysis.archFile}`)
);
const source = path.join(backupDocsPath, analysis.archFile);
const destination = path.join(newDocsPath, analysis.archFile);
await fs.copyFile(source, destination);
console.log(chalk.green(`✓ Copied Architecture to docs/${analysis.archFile}`));
copiedCount++;
}
// Copy Front-end Architecture if exists
if (analysis.frontEndArchFile) {
const src = path.join(backupDocsPath, analysis.frontEndArchFile);
const dest = path.join(newDocsPath, analysis.frontEndArchFile);
await fs.copyFile(src, dest);
const source = path.join(backupDocsPath, analysis.frontEndArchFile);
const destination = path.join(newDocsPath, analysis.frontEndArchFile);
await fs.copyFile(source, destination);
console.log(
chalk.green(
`✓ Copied Front-end Architecture to docs/${analysis.frontEndArchFile}`
)
chalk.green(`✓ Copied Front-end Architecture to docs/${analysis.frontEndArchFile}`),
);
console.log(
chalk.yellow(
"Note: V4 uses a single full-stack-architecture.md - use doc-migration-task to merge"
)
'Note: V4 uses a single full-stack-architecture.md - use doc-migration-task to merge',
),
);
copiedCount++;
}
// Copy UX/UI Spec if exists
if (analysis.uxSpecFile) {
const src = path.join(backupDocsPath, analysis.uxSpecFile);
const dest = path.join(newDocsPath, analysis.uxSpecFile);
await fs.copyFile(src, dest);
console.log(
chalk.green(`✓ Copied UX/UI Spec to docs/${analysis.uxSpecFile}`)
);
const source = path.join(backupDocsPath, analysis.uxSpecFile);
const destination = path.join(newDocsPath, analysis.uxSpecFile);
await fs.copyFile(source, destination);
console.log(chalk.green(`✓ Copied UX/UI Spec to docs/${analysis.uxSpecFile}`));
copiedCount++;
}
// Copy UX/Design Prompt if exists
if (analysis.uxPromptFile) {
const src = path.join(backupDocsPath, analysis.uxPromptFile);
const dest = path.join(newDocsPath, analysis.uxPromptFile);
await fs.copyFile(src, dest);
console.log(
chalk.green(
`✓ Copied UX/Design Prompt to docs/${analysis.uxPromptFile}`
)
);
const source = path.join(backupDocsPath, analysis.uxPromptFile);
const destination = path.join(newDocsPath, analysis.uxPromptFile);
await fs.copyFile(source, destination);
console.log(chalk.green(`✓ Copied UX/Design Prompt to docs/${analysis.uxPromptFile}`));
copiedCount++;
}
// Copy stories
if (analysis.storyFiles.length > 0) {
const storiesDir = path.join(newDocsPath, "stories");
const storiesDir = path.join(newDocsPath, 'stories');
await fs.mkdir(storiesDir, { recursive: true });
for (const storyFile of analysis.storyFiles) {
const src = path.join(backupDocsPath, "stories", storyFile);
const dest = path.join(storiesDir, storyFile);
await fs.copyFile(src, dest);
const source = path.join(backupDocsPath, 'stories', storyFile);
const destination = path.join(storiesDir, storyFile);
await fs.copyFile(source, destination);
}
console.log(
chalk.green(
`✓ Copied ${analysis.storyFiles.length} story files to docs/stories/`
)
chalk.green(`✓ Copied ${analysis.storyFiles.length} story files to docs/stories/`),
);
copiedCount += analysis.storyFiles.length;
}
// Copy epic files to prd subfolder
if (analysis.epicFiles.length > 0) {
const prdDir = path.join(newDocsPath, "prd");
const prdDir = path.join(newDocsPath, 'prd');
await fs.mkdir(prdDir, { recursive: true });
for (const epicFile of analysis.epicFiles) {
const src = path.join(backupDocsPath, epicFile);
const dest = path.join(prdDir, epicFile);
await fs.copyFile(src, dest);
const source = path.join(backupDocsPath, epicFile);
const destination = path.join(prdDir, epicFile);
await fs.copyFile(source, destination);
}
console.log(
chalk.green(
`✓ Found and copied ${analysis.epicFiles.length} epic files to docs/prd/`
)
chalk.green(`✓ Found and copied ${analysis.epicFiles.length} epic files to docs/prd/`),
);
// Create index.md for the prd folder
await this.createPrdIndex(projectPath, analysis);
console.log(chalk.green("✓ Created index.md in docs/prd/"));
console.log(chalk.green('✓ Created index.md in docs/prd/'));
console.log(
chalk.green(
"\nNote: Epic files detected! These are compatible with V4 and have been copied."
)
);
console.log(
chalk.green(
"You won't need to shard the PRD since epics already exist."
)
'\nNote: Epic files detected! These are compatible with V4 and have been copied.',
),
);
console.log(chalk.green("You won't need to shard the PRD since epics already exist."));
copiedCount += analysis.epicFiles.length;
}
spinner.succeed(`Migrated ${copiedCount} documents successfully`);
} catch (error) {
spinner.fail("Document migration failed");
spinner.fail('Document migration failed');
throw error;
}
}
@@ -548,21 +481,21 @@ class V3ToV4Upgrader {
async setupIDE(projectPath, selectedIdes) {
// Use the IDE selections passed from the installer
if (!selectedIdes || selectedIdes.length === 0) {
console.log(chalk.dim("No IDE setup requested - skipping"));
console.log(chalk.dim('No IDE setup requested - skipping'));
return;
}
const ideSetup = require("../installer/lib/ide-setup");
const spinner = ora("Setting up IDE rules for all agents...").start();
const ideSetup = require('../installer/lib/ide-setup');
const spinner = ora('Setting up IDE rules for all agents...').start();
try {
const ideMessages = {
cursor: "Rules created in .cursor/rules/bmad/",
"claude-code": "Commands created in .claude/commands/BMad/",
windsurf: "Rules created in .windsurf/rules/",
trae: "Rules created in.trae/rules/",
roo: "Custom modes created in .roomodes",
cline: "Rules created in .clinerules/",
cursor: 'Rules created in .cursor/rules/bmad/',
'claude-code': 'Commands created in .claude/commands/BMad/',
windsurf: 'Rules created in .windsurf/workflows/',
trae: 'Rules created in.trae/rules/',
roo: 'Custom modes created in .roomodes',
cline: 'Rules created in .clinerules/',
};
// Setup each selected IDE
@@ -573,17 +506,15 @@ class V3ToV4Upgrader {
}
spinner.succeed(`IDE setup complete for ${selectedIdes.length} IDE(s)!`);
} catch (error) {
spinner.fail("IDE setup failed");
console.error(
chalk.yellow("IDE setup failed, but upgrade is complete.")
);
} catch {
spinner.fail('IDE setup failed');
console.error(chalk.yellow('IDE setup failed, but upgrade is complete.'));
}
}
showCompletionReport(projectPath, analysis) {
console.log(chalk.bold.green("\n✓ Upgrade Complete!\n"));
console.log(chalk.bold("Summary:"));
console.log(chalk.bold.green('\n✓ Upgrade Complete!\n'));
console.log(chalk.bold('Summary:'));
console.log(`- V3 files backed up to: .bmad-v3-backup/`);
console.log(`- V4 structure installed: .bmad-core/ (fresh from V4)`);
@@ -596,50 +527,36 @@ class V3ToV4Upgrader {
analysis.storyFiles.length;
console.log(
`- Documents migrated: ${totalDocs} files${
analysis.epicFiles.length > 0
? ` + ${analysis.epicFiles.length} epics`
: ""
}`
analysis.epicFiles.length > 0 ? ` + ${analysis.epicFiles.length} epics` : ''
}`,
);
console.log(chalk.bold("\nImportant Changes:"));
console.log(
"- The V4 agents (sm, dev, etc.) expect different file structures than V3"
);
console.log(
"- Your V3 bmad-agent content was NOT migrated (it's incompatible)"
);
console.log(chalk.bold('\nImportant Changes:'));
console.log('- The V4 agents (sm, dev, etc.) expect different file structures than V3');
console.log("- Your V3 bmad-agent content was NOT migrated (it's incompatible)");
if (analysis.epicFiles.length > 0) {
console.log(
"- Epic files were found and copied - no PRD sharding needed!"
);
console.log('- Epic files were found and copied - no PRD sharding needed!');
}
if (analysis.frontEndArchFile) {
console.log(
"- Front-end architecture found - V4 uses full-stack-architecture.md, migration needed"
'- Front-end architecture found - V4 uses full-stack-architecture.md, migration needed',
);
}
if (analysis.uxSpecFile || analysis.uxPromptFile) {
console.log(
"- UX/UI design files found and copied - ready for use with V4"
);
console.log('- UX/UI design files found and copied - ready for use with V4');
}
console.log(chalk.bold("\nNext Steps:"));
console.log("1. Review your documents in the new docs/ folder");
console.log(chalk.bold('\nNext Steps:'));
console.log('1. Review your documents in the new docs/ folder');
console.log(
"2. Use @bmad-master agent to run the doc-migration-task to align your documents with V4 templates"
'2. Use @bmad-master agent to run the doc-migration-task to align your documents with V4 templates',
);
if (analysis.epicFiles.length === 0) {
console.log(
"3. Use @bmad-master agent to shard the PRD to create epic files"
);
console.log('3. Use @bmad-master agent to shard the PRD to create epic files');
}
console.log(
chalk.dim(
"\nYour V3 backup is preserved in .bmad-v3-backup/ and can be restored if needed."
)
chalk.dim('\nYour V3 backup is preserved in .bmad-v3-backup/ and can be restored if needed.'),
);
}
@@ -652,67 +569,61 @@ class V3ToV4Upgrader {
}
}
async copyDirectory(src, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
async copyDirectory(source, destination) {
await fs.mkdir(destination, { recursive: true });
const entries = await fs.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
const sourcePath = path.join(source, entry.name);
const destinationPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
await (entry.isDirectory()
? this.copyDirectory(sourcePath, destinationPath)
: fs.copyFile(sourcePath, destinationPath));
}
}
async createPrdIndex(projectPath, analysis) {
const prdIndexPath = path.join(projectPath, "docs", "prd", "index.md");
const prdPath = path.join(
projectPath,
"docs",
analysis.prdFile || "prd.md"
);
const prdIndexPath = path.join(projectPath, 'docs', 'prd', 'index.md');
const prdPath = path.join(projectPath, 'docs', analysis.prdFile || 'prd.md');
let indexContent = "# Product Requirements Document\n\n";
let indexContent = '# Product Requirements Document\n\n';
// Try to read the PRD to get the title and intro content
if (analysis.prdFile && (await this.pathExists(prdPath))) {
try {
const prdContent = await fs.readFile(prdPath, "utf8");
const lines = prdContent.split("\n");
const prdContent = await fs.readFile(prdPath, 'utf8');
const lines = prdContent.split('\n');
// Find the first heading
const titleMatch = lines.find((line) => line.startsWith("# "));
const titleMatch = lines.find((line) => line.startsWith('# '));
if (titleMatch) {
indexContent = titleMatch + "\n\n";
indexContent = titleMatch + '\n\n';
}
// Get any content before the first ## section
let introContent = "";
let introContent = '';
let foundFirstSection = false;
for (const line of lines) {
if (line.startsWith("## ")) {
if (line.startsWith('## ')) {
foundFirstSection = true;
break;
}
if (!line.startsWith("# ")) {
introContent += line + "\n";
if (!line.startsWith('# ')) {
introContent += line + '\n';
}
}
if (introContent.trim()) {
indexContent += introContent.trim() + "\n\n";
indexContent += introContent.trim() + '\n\n';
}
} catch (error) {
} catch {
// If we can't read the PRD, just use default content
}
}
// Add sections list
indexContent += "## Sections\n\n";
indexContent += '## Sections\n\n';
// Sort epic files for consistent ordering
const sortedEpics = [...analysis.epicFiles].sort();
@@ -720,38 +631,36 @@ class V3ToV4Upgrader {
for (const epicFile of sortedEpics) {
// Extract epic name from filename
const epicName = epicFile
.replace(/\.md$/, "")
.replace(/^epic-?/i, "")
.replace(/-/g, " ")
.replace(/^\d+\s*/, "") // Remove leading numbers
.replace(/\.md$/, '')
.replace(/^epic-?/i, '')
.replaceAll('-', ' ')
.replace(/^\d+\s*/, '') // Remove leading numbers
.trim();
const displayName = epicName.charAt(0).toUpperCase() + epicName.slice(1);
indexContent += `- [${
displayName || epicFile.replace(".md", "")
}](./${epicFile})\n`;
indexContent += `- [${displayName || epicFile.replace('.md', '')}](./${epicFile})\n`;
}
await fs.writeFile(prdIndexPath, indexContent);
}
async createInstallManifest(projectPath) {
const fileManager = require("../installer/lib/file-manager");
const { glob } = require("glob");
const fileManager = require('../installer/lib/file-manager');
const { glob } = require('glob');
// Get all files in .bmad-core for the manifest
const bmadCorePath = path.join(projectPath, ".bmad-core");
const files = await glob("**/*", {
const bmadCorePath = path.join(projectPath, '.bmad-core');
const files = await glob('**/*', {
cwd: bmadCorePath,
nodir: true,
ignore: ["**/.git/**", "**/node_modules/**"],
ignore: ['**/.git/**', '**/node_modules/**'],
});
// Prepend .bmad-core/ to file paths for manifest
const manifestFiles = files.map((file) => path.join(".bmad-core", file));
const manifestFiles = files.map((file) => path.join('.bmad-core', file));
const config = {
installType: "full",
installType: 'full',
agent: null,
ide: null, // Will be set if IDE setup is done later
};

View File

@@ -1,8 +1,6 @@
#!/usr/bin/env node
const fs = require('fs');
const { execSync } = require('child_process');
const path = require('path');
const fs = require('node:fs');
const { execSync } = require('node:child_process');
const path = require('node:path');
// Dynamic import for ES module
let chalk;
@@ -26,7 +24,7 @@ function getCurrentVersion() {
async function bumpVersion(type = 'patch') {
await initializeModules();
const validTypes = ['patch', 'minor', 'major'];
if (!validTypes.includes(type)) {
console.error(chalk.red(`Invalid version type: ${type}. Use: ${validTypes.join(', ')}`));
@@ -43,37 +41,37 @@ async function bumpVersion(type = 'patch') {
console.log('');
console.log(chalk.dim('Example: git commit -m "feat: add new installer features"'));
console.log(chalk.dim('Then push to main branch to trigger automatic release.'));
return null;
}
async function main() {
await initializeModules();
const type = process.argv[2] || 'patch';
const currentVersion = getCurrentVersion();
console.log(chalk.blue(`Current version: ${currentVersion}`));
// Check if working directory is clean
try {
execSync('git diff-index --quiet HEAD --');
} catch (error) {
} catch {
console.error(chalk.red('❌ Working directory is not clean. Commit your changes first.'));
process.exit(1);
}
const newVersion = await bumpVersion(type);
console.log(chalk.green(`\n🎉 Version bump complete!`));
console.log(chalk.blue(`📦 ${currentVersion}${newVersion}`));
}
if (require.main === module) {
main().catch(error => {
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
}
module.exports = { bumpVersion, getCurrentVersion };
module.exports = { bumpVersion, getCurrentVersion };

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const { execSync } = require('child_process');
const { execSync } = require('node:child_process');
// Dynamic import for ES module
let chalk;
@@ -26,43 +24,50 @@ async function formatYamlContent(content, filename) {
// First try to fix common YAML issues
let fixedContent = content
// Fix "commands :" -> "commands:"
.replace(/^(\s*)(\w+)\s+:/gm, '$1$2:')
.replaceAll(/^(\s*)(\w+)\s+:/gm, '$1$2:')
// Fix inconsistent list indentation
.replace(/^(\s*)-\s{3,}/gm, '$1- ');
.replaceAll(/^(\s*)-\s{3,}/gm, '$1- ');
// Skip auto-fixing for .roomodes files - they have special nested structure
if (!filename.includes('.roomodes')) {
fixedContent = fixedContent
// Fix unquoted list items that contain special characters or multiple parts
.replace(/^(\s*)-\s+(.*)$/gm, (match, indent, content) => {
.replaceAll(/^(\s*)-\s+(.*)$/gm, (match, indent, content) => {
// Skip if already quoted
if (content.startsWith('"') && content.endsWith('"')) {
return match;
}
// If the content contains special YAML characters or looks complex, quote it
// BUT skip if it looks like a proper YAML key-value pair (like "key: value")
if ((content.includes(':') || content.includes('-') || content.includes('{') || content.includes('}')) &&
!content.match(/^\w+:\s/)) {
if (
(content.includes(':') ||
content.includes('-') ||
content.includes('{') ||
content.includes('}')) &&
!/^\w+:\s/.test(content)
) {
// Remove any existing quotes first, escape internal quotes, then add proper quotes
const cleanContent = content.replace(/^["']|["']$/g, '').replace(/"/g, '\\"');
const cleanContent = content
.replaceAll(/^["']|["']$/g, '')
.replaceAll('"', String.raw`\"`);
return `${indent}- "${cleanContent}"`;
}
return match;
});
}
// Debug: show what we're trying to parse
if (fixedContent !== content) {
console.log(chalk.blue(`🔧 Applied YAML fixes to ${filename}`));
}
// Parse and re-dump YAML to format it
const parsed = yaml.load(fixedContent);
const formatted = yaml.dump(parsed, {
indent: 2,
lineWidth: -1, // Disable line wrapping
noRefs: true,
sortKeys: false // Preserve key order
sortKeys: false, // Preserve key order
});
return formatted;
} catch (error) {
@@ -80,7 +85,7 @@ async function processMarkdownFile(filePath) {
// Fix untyped code blocks by adding 'text' type
// Match ``` at start of line followed by newline, but only if it's an opening fence
newContent = newContent.replace(/^```\n([\s\S]*?)\n```$/gm, '```text\n$1\n```');
newContent = newContent.replaceAll(/^```\n([\s\S]*?)\n```$/gm, '```text\n$1\n```');
if (newContent !== content) {
modified = true;
console.log(chalk.blue(`🔧 Added 'text' type to untyped code blocks in ${filePath}`));
@@ -90,30 +95,30 @@ async function processMarkdownFile(filePath) {
const yamlBlockRegex = /```ya?ml\n([\s\S]*?)\n```/g;
let match;
const replacements = [];
while ((match = yamlBlockRegex.exec(newContent)) !== null) {
const [fullMatch, yamlContent] = match;
const formatted = await formatYamlContent(yamlContent, filePath);
if (formatted !== null) {
// Remove trailing newline that js-yaml adds
const trimmedFormatted = formatted.replace(/\n$/, '');
if (trimmedFormatted !== yamlContent) {
modified = true;
console.log(chalk.green(`✓ Formatted YAML in ${filePath}`));
}
replacements.push({
start: match.index,
end: match.index + fullMatch.length,
replacement: `\`\`\`yaml\n${trimmedFormatted}\n\`\`\``
replacement: `\`\`\`yaml\n${trimmedFormatted}\n\`\`\``,
});
}
}
// Apply replacements in reverse order to maintain indices
for (let i = replacements.length - 1; i >= 0; i--) {
const { start, end, replacement } = replacements[i];
for (let index = replacements.length - 1; index >= 0; index--) {
const { start, end, replacement } = replacements[index];
newContent = newContent.slice(0, start) + replacement + newContent.slice(end);
}
@@ -128,11 +133,11 @@ async function processYamlFile(filePath) {
await initializeModules();
const content = fs.readFileSync(filePath, 'utf8');
const formatted = await formatYamlContent(content, filePath);
if (formatted === null) {
return false; // Syntax error
}
if (formatted !== content) {
fs.writeFileSync(filePath, formatted);
return true;
@@ -155,10 +160,10 @@ async function lintYamlFile(filePath) {
async function main() {
await initializeModules();
const args = process.argv.slice(2);
const arguments_ = process.argv.slice(2);
const glob = require('glob');
if (args.length === 0) {
if (arguments_.length === 0) {
console.error('Usage: node yaml-format.js <file1> [file2] ...');
process.exit(1);
}
@@ -169,38 +174,44 @@ async function main() {
// Expand glob patterns and collect all files
const allFiles = [];
for (const arg of args) {
if (arg.includes('*')) {
for (const argument of arguments_) {
if (argument.includes('*')) {
// It's a glob pattern
const matches = glob.sync(arg);
const matches = glob.sync(argument);
allFiles.push(...matches);
} else {
// It's a direct file path
allFiles.push(arg);
allFiles.push(argument);
}
}
for (const filePath of allFiles) {
if (!fs.existsSync(filePath)) {
// Skip silently for glob patterns that don't match anything
if (!args.some(arg => arg.includes('*') && filePath === arg)) {
if (!arguments_.some((argument) => argument.includes('*') && filePath === argument)) {
console.error(chalk.red(`❌ File not found: ${filePath}`));
hasErrors = true;
}
continue;
}
const ext = path.extname(filePath).toLowerCase();
const extension = path.extname(filePath).toLowerCase();
const basename = path.basename(filePath).toLowerCase();
try {
let changed = false;
if (ext === '.md') {
if (extension === '.md') {
changed = await processMarkdownFile(filePath);
} else if (ext === '.yaml' || ext === '.yml' || basename.includes('roomodes') || basename.includes('.yaml') || basename.includes('.yml')) {
} else if (
extension === '.yaml' ||
extension === '.yml' ||
basename.includes('roomodes') ||
basename.includes('.yaml') ||
basename.includes('.yml')
) {
// Handle YAML files and special cases like .roomodes
changed = await processYamlFile(filePath);
// Also run linting
const lintPassed = await lintYamlFile(filePath);
if (!lintPassed) hasErrors = true;
@@ -208,7 +219,7 @@ async function main() {
// Skip silently for unsupported files
continue;
}
if (changed) {
hasChanges = true;
filesProcessed.push(filePath);
@@ -220,8 +231,10 @@ async function main() {
}
if (hasChanges) {
console.log(chalk.green(`\n✨ YAML formatting completed! Modified ${filesProcessed.length} files:`));
filesProcessed.forEach(file => console.log(chalk.blue(` 📝 ${file}`)));
console.log(
chalk.green(`\n✨ YAML formatting completed! Modified ${filesProcessed.length} files:`),
);
for (const file of filesProcessed) console.log(chalk.blue(` 📝 ${file}`));
}
if (hasErrors) {
@@ -231,10 +244,10 @@ async function main() {
}
if (require.main === module) {
main().catch(error => {
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
}
module.exports = { formatYamlContent, processMarkdownFile, processYamlFile };
module.exports = { formatYamlContent, processMarkdownFile, processYamlFile };