From dd55226d4b8836327d55c590a8eca80527b8aed2 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 18 May 2026 10:05:23 +0100 Subject: [PATCH] fix(installer): support automator source-root installs --- test/test-installation-components.js | 81 +++++++++++++++++++++ tools/installer/modules/external-manager.js | 14 ++++ 2 files changed, 95 insertions(+) diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 808ee6faa..e4b5ae928 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -85,6 +85,23 @@ async function createTestBmadFixture() { return fixtureDir; } +async function createSourceRootModuleFixture() { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-module-')); + const sourceRoot = path.join(repoRoot, 'skills'); + const skillNames = ['bmad-story-automator', 'bmad-story-automator-review']; + + for (const skillName of skillNames) { + 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'), + ); + } + + return { repoRoot, sourceRoot, skillNames }; +} + async function createSkillCollisionFixture() { const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-')); const fixtureDir = path.join(fixtureRoot, '_bmad'); @@ -164,6 +181,70 @@ async function runTests() { console.log(''); + // ============================================================ + // Test 4b: Source-root external modules + // ============================================================ + console.log(`${colors.yellow}Test Suite 4b: Source-Root External Modules${colors.reset}\n`); + + { + let sourceRootFixture; + let tempProjectDir; + try { + const { ExternalModuleManager } = require('../tools/installer/modules/external-manager'); + const manager = new ExternalModuleManager(); + + const normalizedModule = manager._normalizeModule({ + name: 'bmad-automator', + code: 'baut', + repository: 'https://github.com/bmad-code-org/bmad-automator', + source_root: 'skills', + type: 'experimental', + }); + assert(normalizedModule.sourceRoot === 'skills', 'External module normalization preserves source_root'); + + sourceRootFixture = await createSourceRootModuleFixture(); + tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-project-')); + + manager.getModuleByCode = async () => normalizedModule; + manager.cloneExternalModule = async () => sourceRootFixture.repoRoot; + + const resolvedSourceRoot = await manager.findExternalModuleSource('baut'); + assert(resolvedSourceRoot === sourceRootFixture.sourceRoot, 'External module source_root resolves to the configured directory'); + + const officialModules = new OfficialModules(); + officialModules.findModuleSource = async () => sourceRootFixture.sourceRoot; + await officialModules.install('baut', path.join(tempProjectDir, '_bmad'), null, { + skipModuleInstaller: true, + silent: true, + }); + + for (const skillName of sourceRootFixture.skillNames) { + assert( + await fs.pathExists(path.join(tempProjectDir, '_bmad', 'baut', skillName, 'SKILL.md')), + `Source-root install copies ${skillName}`, + ); + } + + manager.getModuleByCode = async () => ({ + code: 'escape', + sourceRoot: '../outside', + builtIn: false, + }); + let rejectedEscapingSourceRoot = false; + try { + await manager.findExternalModuleSource('escape'); + } catch (error) { + rejectedEscapingSourceRoot = error.message.includes('source-root escapes repository'); + } + assert(rejectedEscapingSourceRoot, 'External module source-root cannot escape cloned repository'); + } finally { + if (sourceRootFixture) await fs.remove(sourceRootFixture.repoRoot).catch(() => {}); + if (tempProjectDir) await fs.remove(tempProjectDir).catch(() => {}); + } + } + + console.log(''); + // ============================================================ // Test 5: Kiro Native Skills Install // ============================================================ diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index a581e256a..9b0d99ee4 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -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'