diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 0a5ebed5b..db715eadc 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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 // ============================================================ diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index 7d2add4fb..e666f8612 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -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' diff --git a/tools/installer/modules/registry-fallback.yaml b/tools/installer/modules/registry-fallback.yaml index 52bc4b4fc..139045de0 100644 --- a/tools/installer/modules/registry-fallback.yaml +++ b/tools/installer/modules/registry-fallback.yaml @@ -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