This commit is contained in:
bm 2026-05-08 07:37:46 -03:00 committed by GitHub
commit c11d8decd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 115 additions and 0 deletions

View File

@ -18,6 +18,7 @@ const { Installer } = require('../tools/installer/core/installer');
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
const { OfficialModules } = require('../tools/installer/modules/official-modules');
const { IdeManager } = require('../tools/installer/ide/manager');
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
// ANSI colors
@ -85,6 +86,28 @@ 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, '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: Automator skill', '---', '', 'Automator body.'].join('\n'),
);
}
const storySkillDir = path.join(sourceRoot, 'bmad-story-automator');
await fs.ensureDir(path.join(storySkillDir, 'scripts'));
await fs.writeFile(path.join(storySkillDir, 'scripts', 'story-automator'), '#!/usr/bin/env bash\n');
await fs.ensureDir(path.join(storySkillDir, 'src', 'story_automator'));
await fs.writeFile(path.join(storySkillDir, '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');
@ -3520,6 +3543,73 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 45: Automator External Skill-Only Module
// ============================================================
console.log(`${colors.yellow}Test Suite 45: Automator External Skill-Only Module${colors.reset}\n`);
let automatorSourceFixture42;
let runtimeTargetRoot42;
try {
const yaml42 = require('yaml');
const fallbackConfig42 = yaml42.parse(
await fs.readFile(path.join(__dirname, '..', 'tools', 'installer', 'modules', 'registry-fallback.yaml'), 'utf8'),
);
const automatorEntry42 = fallbackConfig42.modules['bmad-automator'];
assert(automatorEntry42?.code === 'baut', 'BMad Automator fallback registry code is baut');
assert(automatorEntry42?.['source-root'] === 'skills', 'BMad Automator fallback registry points at root skills');
const externalManager42 = new ExternalModuleManager();
const automatorInfo42 = externalManager42._normalizeModule(automatorEntry42);
assert(automatorInfo42.type === 'experimental', 'BMad Automator is marked experimental');
assert(automatorInfo42.sourceRoot === 'skills', 'BMad Automator uses root skills source-root for pure skill payload');
assert(automatorInfo42.defaultChannel === 'next', 'BMad Automator defaults to next for latest payload compatibility fixes');
automatorSourceFixture42 = await createAutomatorSourceRootFixture();
runtimeTargetRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-runtime-target-'));
const runtimeBmadDir42 = path.join(runtimeTargetRoot42, '_bmad');
const officialModules42 = new OfficialModules();
officialModules42.findModuleSource = async () => automatorSourceFixture42.sourceRoot;
await officialModules42.install('baut', runtimeBmadDir42, null, { skipModuleInstaller: true, silent: true });
assert(
await fs.pathExists(path.join(runtimeBmadDir42, 'baut', 'bmad-story-automator', 'scripts', 'story-automator')),
'BMad Automator self-contained skill install includes runtime helper',
);
assert(
await fs.pathExists(path.join(runtimeBmadDir42, 'baut', 'bmad-story-automator', 'src', 'story_automator', 'cli.py')),
'BMad Automator self-contained skill install includes Python runtime source',
);
await fs.remove(runtimeTargetRoot42).catch(() => {});
runtimeTargetRoot42 = null;
const escapeRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
const escapeRepo42 = path.join(escapeRoot42, 'repo');
await fs.ensureDir(escapeRepo42);
const escapeManager42 = new ExternalModuleManager();
escapeManager42.getModuleByCode = async () => ({
code: 'escape',
builtIn: false,
sourceRoot: '../outside',
});
escapeManager42.cloneExternalModule = async () => escapeRepo42;
let rejectedEscapingSourceRoot42 = false;
try {
await escapeManager42.findExternalModuleSource('escape');
} catch (error) {
rejectedEscapingSourceRoot42 = error.message.includes('source-root escapes repository');
} finally {
await fs.remove(escapeRoot42).catch(() => {});
}
assert(rejectedEscapingSourceRoot42, 'External module source-root cannot escape cloned repository');
} catch (error) {
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
} finally {
if (automatorSourceFixture42) await fs.remove(automatorSourceFixture42.repoRoot).catch(() => {});
if (runtimeTargetRoot42) await fs.remove(runtimeTargetRoot42).catch(() => {});
}
console.log('');
// ============================================================
// Summary
// ============================================================

View File

@ -124,6 +124,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'] || mod.sourceRoot || null,
code: mod.code,
name: mod.display_name || mod.name,
description: mod.description || '',
@ -489,6 +490,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

@ -50,3 +50,14 @@ modules:
type: bmad-org
npmPackage: bmad-method-test-architecture-enterprise
default_channel: stable
bmad-automator:
url: https://github.com/bmad-code-org/bmad-automator
source-root: skills
code: baut
name: "BMad Automator (Experimental)"
description: "Experimental pure-skill story automation. Runs only from Claude Code; supports Claude Code and Codex worker sessions; requires tmux on macOS."
defaultSelected: false
type: experimental
npmPackage: bmad-story-automator
default_channel: next