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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions bmad-modules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions test/test-installation-components.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
// ============================================================
Expand Down
14 changes: 14 additions & 0 deletions tools/installer/modules/external-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand Down Expand Up @@ -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'
Expand Down
24 changes: 24 additions & 0 deletions tools/installer/modules/official-modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
Expand Down
72 changes: 63 additions & 9 deletions tools/installer/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading