fix(installer): resolve external-module agents from cache during manifest write (#2295)
External official modules (bmb, cis, gds, tea, wds) are cloned to ~/.bmad/cache/external-modules/<name>/ and never copied into src/modules/, so collectAgentsFromModuleYaml silently skipped them and their agents never reached config.toml. Swap the hardcoded src/modules lookup for a resolveInstalledModuleYaml() helper that also searches the external cache (handling src/, skills/, nested, and root layouts) and warns instead of silently skipping when a module.yaml can't be found.
This commit is contained in:
parent
16c9976d7e
commit
914c4edd6b
|
|
@ -2256,6 +2256,105 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 38: External-Module Agent Resolution
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 38: External-Module Agent Resolution${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Scenario: external official modules (bmb, cis, gds, ...) are cloned into
|
||||||
|
// ~/.bmad/cache/external-modules/<name>/ — NOT copied into src/modules/.
|
||||||
|
// collectAgentsFromModuleYaml must resolve them from the cache or their
|
||||||
|
// agent roster silently vanishes from config.toml.
|
||||||
|
const tempCacheDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ext-cache-'));
|
||||||
|
const tempBmadDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ext-install-'));
|
||||||
|
const priorCacheEnv = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir38;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Seed a fake external module with agents at cache/<mod>/src/module.yaml —
|
||||||
|
// matches the real CIS layout.
|
||||||
|
const extSrcDir = path.join(tempCacheDir38, 'fake-ext', 'src');
|
||||||
|
await fs.ensureDir(extSrcDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(extSrcDir, 'module.yaml'),
|
||||||
|
[
|
||||||
|
'code: fake-ext',
|
||||||
|
'name: "Fake External Module"',
|
||||||
|
'agents:',
|
||||||
|
' - code: bmad-fake-ext-agent-one',
|
||||||
|
' name: Ext-One',
|
||||||
|
' title: External Agent One',
|
||||||
|
' icon: "🧪"',
|
||||||
|
' team: fake',
|
||||||
|
' description: "First fake external agent."',
|
||||||
|
' - code: bmad-fake-ext-agent-two',
|
||||||
|
' name: Ext-Two',
|
||||||
|
' title: External Agent Two',
|
||||||
|
' icon: "🧬"',
|
||||||
|
' team: fake',
|
||||||
|
' description: "Second fake external agent."',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second fake module at cache/<mod>/skills/module.yaml — matches bmb layout.
|
||||||
|
const extSkillsDir = path.join(tempCacheDir38, 'fake-skills', 'skills');
|
||||||
|
await fs.ensureDir(extSkillsDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(extSkillsDir, 'module.yaml'),
|
||||||
|
[
|
||||||
|
'code: fake-skills',
|
||||||
|
'name: "Fake Skills-Layout Module"',
|
||||||
|
'agents:',
|
||||||
|
' - code: bmad-fake-skills-agent',
|
||||||
|
' name: SkillsHero',
|
||||||
|
' title: Skills Layout Agent',
|
||||||
|
' icon: "🛠️"',
|
||||||
|
' team: fake-skills',
|
||||||
|
' description: "Lives under skills/ not src/."',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const generator38 = new ManifestGenerator();
|
||||||
|
generator38.bmadDir = tempBmadDir38;
|
||||||
|
generator38.bmadFolderName = path.basename(tempBmadDir38);
|
||||||
|
generator38.updatedModules = ['core', 'bmm', 'fake-ext', 'fake-skills'];
|
||||||
|
|
||||||
|
await generator38.collectAgentsFromModuleYaml();
|
||||||
|
|
||||||
|
const byCode = new Map(generator38.agents.map((a) => [a.code, a]));
|
||||||
|
assert(byCode.has('bmad-fake-ext-agent-one'), 'external module at cache/<name>/src resolves and contributes agent one');
|
||||||
|
assert(byCode.has('bmad-fake-ext-agent-two'), 'external module at cache/<name>/src resolves and contributes agent two');
|
||||||
|
assert(byCode.has('bmad-fake-skills-agent'), 'external module at cache/<name>/skills layout also resolves');
|
||||||
|
assert(byCode.get('bmad-fake-ext-agent-one').module === 'fake-ext', 'agent.module matches the owning external module name');
|
||||||
|
assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved');
|
||||||
|
|
||||||
|
await generator38.writeCentralConfig(tempBmadDir38, {
|
||||||
|
core: {},
|
||||||
|
bmm: {},
|
||||||
|
'fake-ext': {},
|
||||||
|
'fake-skills': {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
|
||||||
|
assert(teamContent.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in config.toml [agents.*] section');
|
||||||
|
assert(teamContent.includes('[agents.bmad-fake-skills-agent]'), 'skills-layout external module agents also land in config.toml');
|
||||||
|
assert(teamContent.includes('First fake external agent.'), 'agent description from external module.yaml is written');
|
||||||
|
} finally {
|
||||||
|
if (priorCacheEnv === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv;
|
||||||
|
}
|
||||||
|
await fs.remove(tempCacheDir38).catch(() => {});
|
||||||
|
await fs.remove(tempBmadDir38).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const path = require('node:path');
|
||||||
const fs = require('../fs-native');
|
const fs = require('../fs-native');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { getModulePath } = require('../project-root');
|
const { resolveInstalledModuleYaml } = require('../project-root');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
// Load package.json for version info
|
// Load package.json for version info
|
||||||
|
|
@ -244,8 +244,17 @@ class ManifestGenerator {
|
||||||
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
|
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
|
||||||
|
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = path.join(getModulePath(moduleName), 'module.yaml');
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!(await fs.pathExists(moduleYamlPath))) continue;
|
if (!moduleYamlPath) {
|
||||||
|
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
||||||
|
// Warn rather than silently skip so missing agent rosters don't vanish
|
||||||
|
// from config.toml without notice.
|
||||||
|
console.warn(
|
||||||
|
`[warn] collectAgentsFromModuleYaml: could not locate module.yaml for '${moduleName}'. ` +
|
||||||
|
`Agents declared by this module will not be written to config.toml.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let moduleDef;
|
let moduleDef;
|
||||||
try {
|
try {
|
||||||
|
|
@ -271,7 +280,9 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
console.log(`[DEBUG] collectAgentsFromModuleYaml: ${moduleName} contributed ${moduleDef.agents.length} agents`);
|
console.log(
|
||||||
|
`[DEBUG] collectAgentsFromModuleYaml: ${moduleName} contributed ${moduleDef.agents.length} agents from ${moduleYamlPath}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,8 +421,14 @@ class ManifestGenerator {
|
||||||
// team config, so the operator should notice.
|
// team config, so the operator should notice.
|
||||||
const scopeByModuleKey = {};
|
const scopeByModuleKey = {};
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = path.join(getModulePath(moduleName), 'module.yaml');
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!(await fs.pathExists(moduleYamlPath))) continue;
|
if (!moduleYamlPath) {
|
||||||
|
console.warn(
|
||||||
|
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
|
||||||
|
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
||||||
if (!parsed || typeof parsed !== 'object') continue;
|
if (!parsed || typeof parsed !== 'object') continue;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
const fs = require('./fs-native');
|
const fs = require('./fs-native');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -69,9 +70,62 @@ function getModulePath(moduleName, ...segments) {
|
||||||
return getSourcePath('modules', moduleName, ...segments);
|
return getSourcePath('modules', moduleName, ...segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the local external-module clone cache.
|
||||||
|
* External official modules (bmb, cis, gds, tea, wds, etc.) are cloned here
|
||||||
|
* by ExternalModuleManager during install and are not copied into <src>/modules/.
|
||||||
|
*/
|
||||||
|
function getExternalModuleCachePath(moduleName, ...segments) {
|
||||||
|
const base = process.env.BMAD_EXTERNAL_MODULES_CACHE || path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
|
||||||
|
return path.join(base, moduleName, ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate an installed module's `module.yaml` by filesystem lookup only.
|
||||||
|
*
|
||||||
|
* Built-in modules (core, bmm) live under <src>. External official modules are
|
||||||
|
* cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal
|
||||||
|
* layouts (some at src/module.yaml, some at skills/module.yaml, some nested).
|
||||||
|
* This mirrors the candidate-path search in
|
||||||
|
* ExternalModuleManager.findExternalModuleSource but performs no git/network
|
||||||
|
* work, which keeps it safe to call during manifest writing.
|
||||||
|
*
|
||||||
|
* @param {string} moduleName
|
||||||
|
* @returns {Promise<string|null>} Absolute path to module.yaml, or null if not found.
|
||||||
|
*/
|
||||||
|
async function resolveInstalledModuleYaml(moduleName) {
|
||||||
|
const builtIn = path.join(getModulePath(moduleName), 'module.yaml');
|
||||||
|
if (await fs.pathExists(builtIn)) return builtIn;
|
||||||
|
|
||||||
|
const cacheRoot = getExternalModuleCachePath(moduleName);
|
||||||
|
if (!(await fs.pathExists(cacheRoot))) return null;
|
||||||
|
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const direct = path.join(cacheRoot, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(direct)) return direct;
|
||||||
|
|
||||||
|
const dirPath = path.join(cacheRoot, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const nested = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(nested)) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const atRoot = path.join(cacheRoot, 'module.yaml');
|
||||||
|
if (await fs.pathExists(atRoot)) return atRoot;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getProjectRoot,
|
getProjectRoot,
|
||||||
getSourcePath,
|
getSourcePath,
|
||||||
getModulePath,
|
getModulePath,
|
||||||
|
getExternalModuleCachePath,
|
||||||
|
resolveInstalledModuleYaml,
|
||||||
findProjectRoot,
|
findProjectRoot,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue