fix(installer): restore automator install compatibility

This commit is contained in:
Dicky Moore 2026-05-18 08:39:13 +01:00
parent 0eae7c4352
commit 01979aa0aa
5 changed files with 196 additions and 13 deletions

View File

@ -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

View File

@ -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
// ============================================================

View File

@ -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'

View File

@ -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.

View File

@ -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)