fix(installer): restore automator install compatibility
This commit is contained in:
parent
0eae7c4352
commit
01979aa0aa
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue