fix(installer): resolve url-source custom modules from custom-modules cache (#2323)

* fix(installer): resolve url-source custom modules from custom-modules cache

resolveInstalledModuleYaml previously only searched ~/.bmad/cache/external-modules/,
so modules installed via --custom-source <git-url> (cached at
~/.bmad/cache/custom-modules/<host>/<owner>/<repo>/) could not be located on
re-install runs. This caused warnings during npx bmad-method install:

  [warn] collectAgentsFromModuleYaml: could not locate module.yaml for '<name>'
  [warn] writeCentralConfig: could not locate module.yaml for '<name>'

Adds a fallback that walks the custom-modules cache via _findCacheRepoRoots
(identifying repo roots by .bmad-source.json or .claude-plugin/, not
marketplace.json, so direct-mode modules are also covered), reuses the same
searchRoot candidate-path logic, and matches by the discovered yaml's code
or name field.

Works without needing _resolutionCache to be populated, which fixes the
re-install scenario where no --custom-source flag is passed.

Closes #2312

* 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 2026-04-26 15:53:36 -05:00 committed by GitHub
parent be85e5b4a0
commit 350688df67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 54 additions and 9 deletions

View File

@ -1,5 +1,6 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const yaml = require('yaml');
const fs = require('./fs-native'); const fs = require('./fs-native');
/** /**
@ -86,8 +87,11 @@ function getExternalModuleCachePath(moduleName, ...segments) {
* Built-in modules (core, bmm) live under <src>. External official modules are * Built-in modules (core, bmm) live under <src>. External official modules are
* cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal * cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal
* layouts (some at src/module.yaml, some at skills/module.yaml, some nested). * layouts (some at src/module.yaml, some at skills/module.yaml, some nested).
* Local custom-source modules are not cached; their path is read from the * Url-source custom modules are cloned into ~/.bmad/cache/custom-modules/<host>/<owner>/<repo>/
* CustomModuleManager resolution cache set during the same install run. * and are resolved by walking the cache and matching `code` or `name` from the
* discovered module.yaml. Local custom-source modules are not cached; their
* path is read from the CustomModuleManager resolution cache set during the
* same install run.
* This mirrors the candidate-path search in * This mirrors the candidate-path search in
* ExternalModuleManager.findExternalModuleSource but performs no git/network * ExternalModuleManager.findExternalModuleSource but performs no git/network
* work, which keeps it safe to call during manifest writing. * work, which keeps it safe to call during manifest writing.
@ -99,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)) {
@ -111,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);
} }
} }
} }
@ -121,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);
@ -150,6 +164,37 @@ async function resolveInstalledModuleYaml(moduleName) {
// Resolution cache unavailable — continue // Resolution cache unavailable — continue
} }
// Fallback: url-source custom modules cloned to ~/.bmad/cache/custom-modules/.
// 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
// _resolutionCache is empty and covers both discovery-mode (with marketplace.json)
// and direct-mode modules, since we identify repo roots by .bmad-source.json
// (written by cloneRepo) or .claude-plugin/ rather than by marketplace.json.
try {
const customCacheDir = path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
if (await fs.pathExists(customCacheDir)) {
const { CustomModuleManager } = require('./modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const repoRoots = await customMgr._findCacheRepoRoots(customCacheDir);
for (const { repoPath } of repoRoots) {
const candidates = await searchRootAll(repoPath);
for (const candidate of candidates) {
try {
const parsed = yaml.parse(await fs.readFile(candidate, 'utf8'));
if (parsed && (parsed.code === moduleName || parsed.name === moduleName)) {
return candidate;
}
} catch {
// Malformed yaml — skip
}
}
}
}
} catch {
// Custom-modules cache walk failed — continue
}
return null; return null;
} }