diff --git a/bmad-modules.yaml b/bmad-modules.yaml index f89293ef2..aff336d1a 100644 --- a/bmad-modules.yaml +++ b/bmad-modules.yaml @@ -33,10 +33,10 @@ modules: bmad-automator: url: https://github.com/bmad-code-org/bmad-automator - module-definition: skills/module.yaml - code: automator - name: "BMad Automator Epic Builder Experimental" - description: "EXPERIMENTAL: only supports claude and codex currently" + source-root: payload/.claude/skills + code: baut + name: "BMad Automator (Experimental)" + description: "BMAD story automation skills for create/dev/QA/review/retro orchestration" defaultSelected: false type: experimental npmPackage: bmad-story-automator diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 808ee6faa..4b332ab18 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -85,6 +85,27 @@ async function createTestBmadFixture() { return fixtureDir; } +async function createAutomatorSourceRootFixture() { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-source-')); + const sourceRoot = path.join(repoRoot, 'payload', '.claude', 'skills'); + + for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) { + const skillDir = path.join(sourceRoot, skillName); + await fs.ensureDir(skillDir); + await fs.writeFile( + path.join(skillDir, 'SKILL.md'), + ['---', `name: ${skillName}`, `description: ${skillName} description`, '---', '', `${skillName} body`].join('\n'), + ); + } + + await fs.ensureDir(path.join(repoRoot, 'source', 'scripts')); + await fs.writeFile(path.join(repoRoot, 'source', 'scripts', 'story-automator'), '#!/usr/bin/env bash\n'); + await fs.ensureDir(path.join(repoRoot, 'source', 'src', 'story_automator')); + await fs.writeFile(path.join(repoRoot, 'source', 'src', 'story_automator', 'cli.py'), 'def main():\n return 0\n'); + + return { repoRoot, sourceRoot }; +} + async function createSkillCollisionFixture() { const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-')); const fixtureDir = path.join(fixtureRoot, '_bmad'); @@ -164,6 +185,76 @@ async function runTests() { console.log(''); + // ============================================================ + // Test 4b: Automator source-root install compatibility + // ============================================================ + console.log(`${colors.yellow}Test Suite 4b: Automator Source Root${colors.reset}\n`); + + let automatorSourceFixture; + let runtimeTargetRoot; + try { + const externalManager = new (require('../tools/installer/modules/external-manager').ExternalModuleManager)(); + const automatorInfo = externalManager._normalizeModule({ + name: 'bmad-automator', + code: 'baut', + repository: 'https://github.com/bmad-code-org/bmad-automator', + source_root: 'payload/.claude/skills', + type: 'experimental', + }); + assert(automatorInfo.sourceRoot === 'payload/.claude/skills', 'External module normalization preserves source_root'); + + automatorSourceFixture = await createAutomatorSourceRootFixture(); + runtimeTargetRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-runtime-target-')); + const runtimeBmadDir = path.join(runtimeTargetRoot, '_bmad'); + const officialModules = new OfficialModules(); + officialModules.findModuleSource = async () => automatorSourceFixture.sourceRoot; + await officialModules.install('baut', runtimeBmadDir, null, { skipModuleInstaller: true, silent: true }); + assert( + await fs.pathExists(path.join(runtimeBmadDir, 'baut', 'bmad-story-automator', 'scripts', 'story-automator')), + 'Automator source-root install includes runtime helper script', + ); + assert( + await fs.pathExists(path.join(runtimeBmadDir, 'baut', 'bmad-story-automator', 'src', 'story_automator', 'cli.py')), + 'Automator source-root install includes Python runtime source', + ); + + const escapeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-')); + const escapeRepo = path.join(escapeRoot, 'repo'); + await fs.ensureDir(escapeRepo); + const escapeManager = new (require('../tools/installer/modules/external-manager').ExternalModuleManager)(); + escapeManager.getModuleByCode = async () => ({ + code: 'escape', + builtIn: false, + sourceRoot: '../outside', + }); + escapeManager.cloneExternalModule = async () => escapeRepo; + let rejectedEscapingSourceRoot = false; + try { + await escapeManager.findExternalModuleSource('escape'); + } catch (error) { + rejectedEscapingSourceRoot = error.message.includes('source-root escapes repository'); + } finally { + await fs.remove(escapeRoot).catch(() => {}); + } + assert(rejectedEscapingSourceRoot, 'External module source-root cannot escape cloned repository'); + + const ui = new (require('../tools/installer/ui').UI)(); + const normalizedModules = ui._normalizeModuleTokens(['automator', 'baut', 'bmad-automator']); + assert(normalizedModules.length === 1 && normalizedModules[0] === 'baut', 'CLI module aliases normalize automator requests to baut'); + const aliasChannelOptions = { nextSet: new Set(['automator']), pins: new Map([['bmad-automator', 'v1.2.3']]) }; + ui._applyModuleAliasesToChannelOptions(aliasChannelOptions); + assert(aliasChannelOptions.nextSet.has('baut'), 'CLI channel aliases normalize --next automator to baut'); + assert(aliasChannelOptions.pins.has('baut'), 'CLI channel aliases normalize --pin bmad-automator=TAG to baut'); + assert(ui._hasExplicitUpdateIntent({ modules: 'automator', yes: true }) === true, 'Explicit module flags force full update path'); + } catch (error) { + assert(false, 'Automator source-root compatibility test succeeds', error.message); + } finally { + if (automatorSourceFixture) await fs.remove(automatorSourceFixture.repoRoot).catch(() => {}); + if (runtimeTargetRoot) await fs.remove(runtimeTargetRoot).catch(() => {}); + } + + console.log(''); + // ============================================================ // Test 5: Kiro Native Skills Install // ============================================================ diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index a581e256a..9b0d99ee4 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -105,6 +105,7 @@ class ExternalModuleManager { key: key || mod.name, url: mod.repository || mod.url, moduleDefinition: mod.module_definition || mod['module-definition'], + sourceRoot: mod.source_root || mod['source-root'] || null, code: mod.code, name: mod.display_name || mod.name, description: mod.description || '', @@ -471,6 +472,19 @@ class ExternalModuleManager { // Clone the external module repo const cloneDir = await this.cloneExternalModule(moduleCode, options); + if (moduleInfo.sourceRoot) { + const repoRoot = path.resolve(cloneDir); + const sourceRoot = path.resolve(repoRoot, moduleInfo.sourceRoot); + const relativeSourceRoot = path.relative(repoRoot, sourceRoot); + if (relativeSourceRoot === '..' || relativeSourceRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSourceRoot)) { + throw new Error(`External module '${moduleCode}' source-root escapes repository: ${moduleInfo.sourceRoot}`); + } + if (!(await fs.pathExists(sourceRoot))) { + throw new Error(`External module '${moduleCode}' source-root not found: ${moduleInfo.sourceRoot}`); + } + return sourceRoot; + } + // The module-definition specifies the path to module.yaml relative to repo root // We need to return the directory containing module.yaml const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml' diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index e80b0a56e..c546d9288 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -278,6 +278,7 @@ class OfficialModules { } await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); + await this.copyAutomatorRuntimeIfNeeded(moduleName, sourcePath, targetPath, fileTrackingCallback); if (!options.skipModuleInstaller) { await this.createModuleDirectories(moduleName, bmadDir, options); @@ -303,6 +304,29 @@ class OfficialModules { return { success: true, module: moduleName, path: targetPath, versionInfo }; } + async copyAutomatorRuntimeIfNeeded(moduleName, sourcePath, targetPath, fileTrackingCallback = null) { + if (moduleName !== 'baut') return; + + const storyTarget = path.join(targetPath, 'bmad-story-automator'); + if (!(await fs.pathExists(path.join(storyTarget, 'SKILL.md')))) return; + + const repoRoot = path.resolve(sourcePath, '..', '..', '..'); + const runtimeRoot = path.join(repoRoot, 'source'); + const runtimeParts = [ + ['scripts', 'scripts'], + ['src', 'src'], + ]; + + for (const [sourceRel, targetRel] of runtimeParts) { + const sourceDir = path.join(runtimeRoot, sourceRel); + const targetDir = path.join(storyTarget, targetRel); + if (!(await fs.pathExists(sourceDir))) { + throw new Error(`BMad Automator runtime source missing: source/${sourceRel}`); + } + await this.copyModuleWithFiltering(sourceDir, targetDir, fileTrackingCallback); + } + } + /** * Install a module from a PluginResolver resolution result. * Copies specific skill directories and places module-help.csv at the target root. diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 618a2145b..f25da2139 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -19,6 +19,10 @@ const prompts = require('./prompts'); const { parseSetEntries } = require('./set-overrides'); const manifest = new Manifest(); +const MODULE_CODE_ALIASES = new Map([ + ['automator', 'baut'], + ['bmad-automator', 'baut'], +]); /** * Format a resolved version for display in installer labels. @@ -110,6 +114,51 @@ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault = * UI utilities for the installer */ class UI { + _normalizeModuleTokens(tokens = []) { + const normalized = []; + const seen = new Set(); + for (const token of tokens) { + const trimmed = typeof token === 'string' ? token.trim() : ''; + if (!trimmed) continue; + const canonical = MODULE_CODE_ALIASES.get(trimmed) || trimmed; + if (seen.has(canonical)) continue; + seen.add(canonical); + normalized.push(canonical); + } + return normalized; + } + + _applyModuleAliasesToChannelOptions(channelOptions) { + if (!channelOptions) return channelOptions; + + const nextSet = new Set(); + for (const code of channelOptions.nextSet || []) { + nextSet.add(MODULE_CODE_ALIASES.get(code) || code); + } + channelOptions.nextSet = nextSet; + + const pins = new Map(); + for (const [code, tag] of channelOptions.pins || []) { + pins.set(MODULE_CODE_ALIASES.get(code) || code, tag); + } + channelOptions.pins = pins; + + return channelOptions; + } + + _hasExplicitUpdateIntent(options = {}) { + return !!( + options.customSource || + options.modules || + options.tools !== undefined || + options.channel || + options.allStable || + options.allNext || + (options.next && options.next.length > 0) || + (options.pin && options.pin.length > 0) + ); + } + /** * Prompt for installation configuration * @param {Object} options - Command-line options from install command @@ -126,6 +175,7 @@ class UI { // Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings // are surfaced immediately so the user sees them before any git ops run. const channelOptions = parseChannelOptions(options); + this._applyModuleAliasesToChannelOptions(channelOptions); for (const warning of channelOptions.warnings) { await prompts.log.warn(warning); } @@ -208,7 +258,7 @@ class UI { throw new Error('No valid actions available for this installation'); } const hasQuickUpdate = choices.some((c) => c.value === 'quick-update'); - const needsFullUpdate = !!options.customSource; + const needsFullUpdate = this._hasExplicitUpdateIntent(options); actionType = hasQuickUpdate && !needsFullUpdate ? 'quick-update' : (choices.find((c) => c.value === 'update') || choices[0]).value; await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`); } else { @@ -240,10 +290,12 @@ class UI { let selectedModules; if (options.modules) { // Use modules from command-line - selectedModules = options.modules - .split(',') - .map((m) => m.trim()) - .filter(Boolean); + selectedModules = this._normalizeModuleTokens( + options.modules + .split(',') + .map((m) => m.trim()) + .filter(Boolean), + ); await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`); } else if (options.customSource && !options.yes) { // Custom source without --modules or --yes: start with empty list @@ -328,10 +380,12 @@ class UI { let selectedModules; if (options.modules) { // Use modules from command-line - selectedModules = options.modules - .split(',') - .map((m) => m.trim()) - .filter(Boolean); + selectedModules = this._normalizeModuleTokens( + options.modules + .split(',') + .map((m) => m.trim()) + .filter(Boolean), + ); await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`); } else if (options.customSource) { // Custom source without --modules: start with empty list (core added below)