fix(installer): enumerate all module.yamls when walking custom-modules cache

A url-source custom-modules repo can host multiple plugins in discovery
mode (e.g. skills/module-a/module.yaml and skills/module-b/module.yaml).
The previous walk used searchRoot which returned only the first match,
so asking for module-b would surface module-a's yaml, fail the code/name
check, and skip the repo entirely — never inspecting module-b.

Splits the candidate-path traversal into searchRootAll (returns every
module.yaml in priority order) and a thin searchRoot wrapper for the
existing single-module fallbacks. The custom-modules walk now iterates
every yaml per repo and matches each against code or name.
This commit is contained in:
Brian Madison 2026-04-26 13:36:56 -05:00
parent b695d1e1bd
commit c777989104
1 changed files with 28 additions and 16 deletions

View File

@ -103,11 +103,14 @@ async function resolveInstalledModuleYaml(moduleName) {
const builtIn = path.join(getModulePath(moduleName), 'module.yaml'); const builtIn = path.join(getModulePath(moduleName), 'module.yaml');
if (await fs.pathExists(builtIn)) return builtIn; if (await fs.pathExists(builtIn)) return builtIn;
// Search a resolved root directory using the same candidate-path pattern. // Collect every module.yaml under a root using the standard candidate paths.
async function searchRoot(root) { // Url-source repos can host multiple plugins (discovery mode), so we need all
// matches, not just the first. Returned in priority order.
async function searchRootAll(root) {
const results = [];
for (const dir of ['skills', 'src']) { for (const dir of ['skills', 'src']) {
const direct = path.join(root, dir, 'module.yaml'); const direct = path.join(root, dir, 'module.yaml');
if (await fs.pathExists(direct)) return direct; if (await fs.pathExists(direct)) results.push(direct);
const dirPath = path.join(root, dir); const dirPath = path.join(root, dir);
if (await fs.pathExists(dirPath)) { if (await fs.pathExists(dirPath)) {
@ -115,7 +118,7 @@ async function resolveInstalledModuleYaml(moduleName) {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const nested = path.join(dirPath, entry.name, 'module.yaml'); const nested = path.join(dirPath, entry.name, 'module.yaml');
if (await fs.pathExists(nested)) return nested; if (await fs.pathExists(nested)) results.push(nested);
} }
} }
} }
@ -125,12 +128,19 @@ async function resolveInstalledModuleYaml(moduleName) {
for (const entry of rootEntries) { for (const entry of rootEntries) {
if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue; if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml'); const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml');
if (await fs.pathExists(setupAssets)) return setupAssets; if (await fs.pathExists(setupAssets)) results.push(setupAssets);
} }
const atRoot = path.join(root, 'module.yaml'); const atRoot = path.join(root, 'module.yaml');
if (await fs.pathExists(atRoot)) return atRoot; if (await fs.pathExists(atRoot)) results.push(atRoot);
return null; return results;
}
// Backwards-compatible single-result variant for the existing external-cache
// and resolution-cache fallbacks (one module per root by construction).
async function searchRoot(root) {
const all = await searchRootAll(root);
return all.length > 0 ? all[0] : null;
} }
const cacheRoot = getExternalModuleCachePath(moduleName); const cacheRoot = getExternalModuleCachePath(moduleName);
@ -155,7 +165,8 @@ async function resolveInstalledModuleYaml(moduleName) {
} }
// Fallback: url-source custom modules cloned to ~/.bmad/cache/custom-modules/. // Fallback: url-source custom modules cloned to ~/.bmad/cache/custom-modules/.
// Walk every cached repo, locate its module.yaml via searchRoot, and match by // Walk every cached repo, enumerate ALL module.yaml files via searchRootAll
// (a single repo can host multiple plugins in discovery mode), and match by
// the yaml's `code` or `name` field. This works on re-install runs where // the yaml's `code` or `name` field. This works on re-install runs where
// _resolutionCache is empty and covers both discovery-mode (with marketplace.json) // _resolutionCache is empty and covers both discovery-mode (with marketplace.json)
// and direct-mode modules, since we identify repo roots by .bmad-source.json // and direct-mode modules, since we identify repo roots by .bmad-source.json
@ -167,15 +178,16 @@ async function resolveInstalledModuleYaml(moduleName) {
const customMgr = new CustomModuleManager(); const customMgr = new CustomModuleManager();
const repoRoots = await customMgr._findCacheRepoRoots(customCacheDir); const repoRoots = await customMgr._findCacheRepoRoots(customCacheDir);
for (const { repoPath } of repoRoots) { for (const { repoPath } of repoRoots) {
const candidate = await searchRoot(repoPath); const candidates = await searchRootAll(repoPath);
if (!candidate) continue; for (const candidate of candidates) {
try { try {
const parsed = yaml.parse(await fs.readFile(candidate, 'utf8')); const parsed = yaml.parse(await fs.readFile(candidate, 'utf8'));
if (parsed && (parsed.code === moduleName || parsed.name === moduleName)) { if (parsed && (parsed.code === moduleName || parsed.name === moduleName)) {
return candidate; return candidate;
}
} catch {
// Malformed yaml — skip
} }
} catch {
// Malformed yaml — skip
} }
} }
} }