fix(installer): support local custom-source modules in resolveInstalledModuleYaml and TOML key (#2316)

- resolveInstalledModuleYaml: fall back to CustomModuleManager._resolutionCache for local
  custom-source modules (external cache path doesn't exist for these); refactor candidate-path
  search into shared searchRoot() helper; add *-setup/assets/module.yaml BMB standard path
- manifest-generator: use module code field (not display name) as TOML section key [modules.X]

Co-authored-by: cidemaxio <cidemaxio@users.noreply.github.com>
This commit is contained in:
Curtis Ide 2026-04-26 11:55:56 -06:00 committed by GitHub
parent 04cfde1454
commit be85e5b4a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 56 additions and 17 deletions

View File

@ -435,6 +435,9 @@ class ManifestGenerator {
// this means user-scoped keys (e.g. user_name) could mis-file into the // this means user-scoped keys (e.g. user_name) could mis-file into the
// team config, so the operator should notice. // team config, so the operator should notice.
const scopeByModuleKey = {}; const scopeByModuleKey = {};
// Maps installer moduleName (may be full display name) → module code field
// from module.yaml, so TOML sections use [modules.<code>] not [modules.<name>].
const codeByModuleName = {};
for (const moduleName of this.updatedModules) { for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName); const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) { if (!moduleYamlPath) {
@ -447,6 +450,7 @@ class ManifestGenerator {
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;
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
scopeByModuleKey[moduleName] = {}; scopeByModuleKey[moduleName] = {};
for (const [key, value] of Object.entries(parsed)) { for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) { if (value && typeof value === 'object' && 'prompt' in value) {
@ -545,6 +549,9 @@ class ManifestGenerator {
if (moduleName === 'core') continue; if (moduleName === 'core') continue;
const cfg = moduleConfigs[moduleName]; const cfg = moduleConfigs[moduleName];
if (!cfg || Object.keys(cfg).length === 0) continue; if (!cfg || Object.keys(cfg).length === 0) continue;
// Use the module's code field from module.yaml as the TOML key so the
// section is [modules.mdo] not [modules.MDO: Maxio DevOps Operations].
const sectionKey = codeByModuleName[moduleName] || moduleName;
// Only filter out spread-from-core pollution when we actually know // Only filter out spread-from-core pollution when we actually know
// this module's prompt schema. For external/marketplace modules whose // this module's prompt schema. For external/marketplace modules whose
// module.yaml isn't in the src tree, fall through as all-team so we // module.yaml isn't in the src tree, fall through as all-team so we
@ -552,14 +559,14 @@ class ManifestGenerator {
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0; const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema); const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema);
if (Object.keys(modTeam).length > 0) { if (Object.keys(modTeam).length > 0) {
teamLines.push(`[modules.${moduleName}]`); teamLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modTeam)) { for (const [key, value] of Object.entries(modTeam)) {
teamLines.push(`${key} = ${formatTomlValue(value)}`); teamLines.push(`${key} = ${formatTomlValue(value)}`);
} }
teamLines.push(''); teamLines.push('');
} }
if (Object.keys(modUser).length > 0) { if (Object.keys(modUser).length > 0) {
userLines.push(`[modules.${moduleName}]`); userLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modUser)) { for (const [key, value] of Object.entries(modUser)) {
userLines.push(`${key} = ${formatTomlValue(value)}`); userLines.push(`${key} = ${formatTomlValue(value)}`);
} }

View File

@ -86,6 +86,8 @@ 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
* 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.
@ -97,26 +99,56 @@ 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;
const cacheRoot = getExternalModuleCachePath(moduleName); // Search a resolved root directory using the same candidate-path pattern.
if (!(await fs.pathExists(cacheRoot))) return null; async function searchRoot(root) {
for (const dir of ['skills', 'src']) {
const direct = path.join(root, dir, 'module.yaml');
if (await fs.pathExists(direct)) return direct;
for (const dir of ['skills', 'src']) { const dirPath = path.join(root, dir);
const direct = path.join(cacheRoot, dir, 'module.yaml'); if (await fs.pathExists(dirPath)) {
if (await fs.pathExists(direct)) return direct; const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const dirPath = path.join(cacheRoot, dir); if (!entry.isDirectory()) continue;
if (await fs.pathExists(dirPath)) { const nested = path.join(dirPath, entry.name, 'module.yaml');
const entries = await fs.readdir(dirPath, { withFileTypes: true }); if (await fs.pathExists(nested)) return nested;
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;
} }
} }
// BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory)
const rootEntries = await fs.readdir(root, { withFileTypes: true });
for (const entry of rootEntries) {
if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml');
if (await fs.pathExists(setupAssets)) return setupAssets;
}
const atRoot = path.join(root, 'module.yaml');
if (await fs.pathExists(atRoot)) return atRoot;
return null;
} }
const atRoot = path.join(cacheRoot, 'module.yaml'); const cacheRoot = getExternalModuleCachePath(moduleName);
if (await fs.pathExists(atRoot)) return atRoot; if (await fs.pathExists(cacheRoot)) {
const found = await searchRoot(cacheRoot);
if (found) return found;
}
// Fallback: local custom-source modules store their source path in the
// CustomModuleManager resolution cache populated during the same install run.
// Match by code OR name since callers may use either form.
try {
const { CustomModuleManager } = require('./modules/custom-module-manager');
for (const [, mod] of CustomModuleManager._resolutionCache) {
if ((mod.code === moduleName || mod.name === moduleName) && mod.localPath) {
const found = await searchRoot(mod.localPath);
if (found) return found;
}
}
} catch {
// Resolution cache unavailable — continue
}
return null; return null;
} }