BMAD-METHOD/tools/upgraders/v3-to-v4-upgrader.js

764 lines
22 KiB
JavaScript

const fs = require('fs').promises
const path = require('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
}
class V3ToV4Upgrader {
constructor () {
// Constructor remains empty
}
async upgrade (options = {}) {
try {
// Initialize ES modules
await initializeModules()
// Keep readline open throughout the process
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')
// 2. Get project path
const projectPath = await this.getProjectPath(options.projectPath)
// 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."
)
return
}
// 4. Pre-flight check
const analysis = await this.analyzeProject(projectPath)
await this.showPreflightCheck(analysis, options)
if (!options.dryRun) {
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Continue with upgrade?',
default: true
}
])
if (!confirm) {
console.log('Upgrade cancelled.')
return
}
}
// 5. Create backup
if (options.backup !== false && !options.dryRun) {
await this.createBackup(projectPath)
}
// 6. Install V4 structure
if (!options.dryRun) {
await this.installV4Structure(projectPath)
}
// 7. Migrate documents
if (!options.dryRun) {
await this.migrateDocuments(projectPath, analysis)
}
// 8. Setup IDE
if (!options.dryRun) {
await this.setupIDE(projectPath, options.ides)
}
// 9. Show completion report
this.showCompletionReport(projectPath, analysis)
process.exit(0)
} catch (error) {
console.error(chalk.red('\nUpgrade error:'), error.message)
process.exit(1)
}
}
async getProjectPath (providedPath) {
if (providedPath) {
return path.resolve(providedPath)
}
const { projectPath } = await inquirer.prompt([
{
type: 'input',
name: 'projectPath',
message: 'Please enter the path to your V3 project:',
default: process.cwd()
}
])
return path.resolve(projectPath)
}
async validateV3Project (projectPath) {
const spinner = ora('Validating project structure...').start()
try {
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'))
}
if (hasDocs) {
console.log(chalk.green('✓ Found docs/ directory'))
}
const isValid = hasBmadAgent && hasDocs
if (isValid) {
spinner.succeed('This appears to be a valid V3 project')
} else {
spinner.fail('Invalid V3 project structure')
}
return { isValid, hasBmadAgent, hasDocs }
} catch (error) {
spinner.fail('Validation failed')
throw error
}
}
async analyzeProject (projectPath) {
const docsPath = path.join(projectPath, 'docs')
const bmadAgentPath = path.join(projectPath, 'bmad-agent')
// Find PRD
const prdCandidates = ['prd.md', 'PRD.md', 'product-requirements.md']
let prdFile = null
for (const candidate of prdCandidates) {
const candidatePath = path.join(docsPath, candidate)
if (await this.pathExists(candidatePath)) {
prdFile = candidate
break
}
}
// Find Architecture
const archCandidates = [
'architecture.md',
'Architecture.md',
'technical-architecture.md'
]
let archFile = null
for (const candidate of archCandidates) {
const candidatePath = path.join(docsPath, candidate)
if (await this.pathExists(candidatePath)) {
archFile = candidate
break
}
}
// Find Front-end Architecture (V3 specific)
const frontEndCandidates = [
'front-end-architecture.md',
'frontend-architecture.md',
'ui-architecture.md'
]
let frontEndArchFile = null
for (const candidate of frontEndCandidates) {
const candidatePath = path.join(docsPath, candidate)
if (await this.pathExists(candidatePath)) {
frontEndArchFile = candidate
break
}
}
// Find UX/UI spec
const uxSpecCandidates = [
'ux-ui-spec.md',
'ux-ui-specification.md',
'ui-spec.md',
'ux-spec.md'
]
let uxSpecFile = null
for (const candidate of uxSpecCandidates) {
const candidatePath = path.join(docsPath, candidate)
if (await this.pathExists(candidatePath)) {
uxSpecFile = candidate
break
}
}
// Find v0 prompt or UX prompt
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)
if (await this.pathExists(candidatePath)) {
uxPromptFile = candidate
break
}
}
// Find epic files
const epicFiles = await glob('epic*.md', { cwd: docsPath })
// Find story files
const storiesPath = path.join(docsPath, 'stories')
let storyFiles = []
if (await this.pathExists(storiesPath)) {
storyFiles = await glob('*.md', { cwd: storiesPath })
}
// Count custom files in bmad-agent
const bmadAgentFiles = await glob('**/*.md', {
cwd: bmadAgentPath,
ignore: ['node_modules/**']
})
return {
prdFile,
archFile,
frontEndArchFile,
uxSpecFile,
uxPromptFile,
epicFiles,
storyFiles,
customFileCount: bmadAgentFiles.length
}
}
async showPreflightCheck (analysis, options) {
console.log(chalk.bold('\nProject Analysis:'))
console.log(
`- PRD found: ${
analysis.prdFile
? `docs/${analysis.prdFile}`
: chalk.yellow('Not found')
}`
)
console.log(
`- Architecture found: ${
analysis.archFile
? `docs/${analysis.archFile}`
: chalk.yellow('Not found')
}`
)
if (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')
}`
)
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/`
)
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)')
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.'
)
)
console.log(
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()
try {
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')
}
// Create backup directory
await fs.mkdir(backupPath, { recursive: true })
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'))
// Move 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'))
spinner.succeed('Backup created successfully')
} catch (error) {
spinner.fail('Backup failed')
throw error
}
}
async installV4Structure (projectPath) {
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')
// 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')
)
// Create docs directory
const docsPath = path.join(projectPath, 'docs')
await fs.mkdir(docsPath, { recursive: true })
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.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.'
)
)
spinner.succeed('V4 structure installed successfully')
} catch (error) {
spinner.fail('V4 installation failed')
throw error
}
}
async migrateDocuments (projectPath, analysis) {
const spinner = ora('Migrating your project documents...').start()
try {
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)
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}`)
)
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)
console.log(
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'
)
)
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}`)
)
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}`
)
)
copiedCount++
}
// Copy stories
if (analysis.storyFiles.length > 0) {
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)
}
console.log(
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')
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)
}
console.log(
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(
'\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')
throw error
}
}
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'))
return
}
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/',
'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/'
}
// Setup each selected IDE
for (const ide of selectedIdes) {
spinner.text = `Setting up ${ide}...`
await ideSetup.setup(ide, projectPath)
console.log(chalk.green(`\n${ideMessages[ide]}`))
}
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.')
)
}
}
showCompletionReport (projectPath, analysis) {
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)')
const totalDocs =
(analysis.prdFile ? 1 : 0) +
(analysis.archFile ? 1 : 0) +
(analysis.frontEndArchFile ? 1 : 0) +
(analysis.uxSpecFile ? 1 : 0) +
(analysis.uxPromptFile ? 1 : 0) +
analysis.storyFiles.length
console.log(
`- Documents migrated: ${totalDocs} files${
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)"
)
if (analysis.epicFiles.length > 0) {
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'
)
}
if (analysis.uxSpecFile || analysis.uxPromptFile) {
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(
'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(
chalk.dim(
'\nYour V3 backup is preserved in .bmad-v3-backup/ and can be restored if needed.'
)
)
}
async pathExists (filePath) {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
async copyDirectory (src, dest) {
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath)
} else {
await fs.copyFile(srcPath, destPath)
}
}
}
async createPrdIndex (projectPath, analysis) {
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'
// 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')
// Find the first heading
const titleMatch = lines.find((line) => line.startsWith('# '))
if (titleMatch) {
indexContent = titleMatch + '\n\n'
}
// Get any content before the first ## section
let introContent = ''
let foundFirstSection = false
for (const line of lines) {
if (line.startsWith('## ')) {
foundFirstSection = true
break
}
if (!line.startsWith('# ')) {
introContent += line + '\n'
}
}
if (introContent.trim()) {
indexContent += introContent.trim() + '\n\n'
}
} catch (error) {
// If we can't read the PRD, just use default content
}
}
// Add sections list
indexContent += '## Sections\n\n'
// Sort epic files for consistent ordering
const sortedEpics = [...analysis.epicFiles].sort()
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
.trim()
const displayName = epicName.charAt(0).toUpperCase() + epicName.slice(1)
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')
// Get all files in .bmad-core for the manifest
const bmadCorePath = path.join(projectPath, '.bmad-core')
const files = await glob('**/*', {
cwd: bmadCorePath,
nodir: true,
ignore: ['**/.git/**', '**/node_modules/**']
})
// Prepend .bmad-core/ to file paths for manifest
const manifestFiles = files.map((file) => path.join('.bmad-core', file))
const config = {
installType: 'full',
agent: null,
ide: null // Will be set if IDE setup is done later
}
await fileManager.createManifest(projectPath, config, manifestFiles)
}
}
module.exports = V3ToV4Upgrader