BMAD-METHOD/src/core-skills/bmad-module/scripts/lib/legacy-resolver.mjs

385 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml } from './vendor/yaml.mjs';
import { valid as semverValid } from './semver-lite.mjs';
import { safePathInsideRoot } from './fs-safe.mjs';
import { MODULE_HELP_CSV_HEADER } from './help-catalog.mjs';
import { EXIT, BmadModuleError } from './exit.mjs';
// Resolve a LEGACY BMAD module (marketplace.json + module.yaml) into a synthetic
// manifest of the same shape readAndValidateManifest produces, so the install
// pipeline (validateDeclaredPaths → buildCopyPlan → rewriteManifestPaths → …)
// handles it with no special-casing. This is a self-contained port of the full
// installer's PluginResolver (tools/installer/modules/plugin-resolver.js)
// strategies 15; the skill must not import from tools/installer (it ships
// standalone under .claude/skills/).
//
// Strategies, tried per marketplace plugin in order:
// 1. Module files (module.yaml + module-help.csv) at the skills' common parent
// or any directory between there and the repo root.
// 2. A `*-setup` skill with assets/module.yaml + assets/module-help.csv.
// 3. A single standalone skill with both files in its assets/.
// 4. Multiple standalone skills, each with both files → one module each.
// 5. Fallback: synthesize module.yaml + module-help.csv from marketplace.json
// metadata and SKILL.md frontmatter.
//
// Returns null when there is no marketplace.json (caller emits the normal
// BAD_MANIFEST). Returns { manifest, synthesized } on success, where
// `synthesized` is { 'module.yaml': string|null, 'module-help.csv': string|null }
// (non-null only for strategy 5, which the caller writes into the temp source
// dir before buildCopyPlan reads it). Throws BmadModuleError(BAD_MANIFEST) on an
// unparseable marketplace.json, when nothing resolves, or on multi-module
// ambiguity that `selector` does not disambiguate.
export async function resolveLegacyModule(sourceDir, { selector = null } = {}) {
const mpPath = path.join(sourceDir, '.claude-plugin', 'marketplace.json');
let raw;
try {
raw = await fs.readFile(mpPath, 'utf8');
} catch {
return null;
}
let mp;
try {
mp = JSON.parse(raw);
} catch (e) {
throw new BmadModuleError(EXIT.BAD_MANIFEST, `.claude-plugin/marketplace.json failed to parse: ${e.message}`);
}
const plugins = Array.isArray(mp.plugins) ? mp.plugins : [];
if (plugins.length === 0) {
throw new BmadModuleError(EXIT.BAD_MANIFEST, `marketplace.json declares no plugins`);
}
const candidates = [];
for (const plugin of plugins) {
if (!plugin || typeof plugin !== 'object') continue;
const skillPaths = await resolveSkillPaths(sourceDir, plugin.skills || []);
if (skillPaths.length === 0) continue; // plugin contributes no installable skills
const resolved =
(await tryRootModuleFiles(sourceDir, plugin, skillPaths)) ||
(await trySetupSkill(sourceDir, plugin, skillPaths)) ||
(await trySingleStandalone(sourceDir, plugin, skillPaths)) ||
(await tryMultipleStandalone(sourceDir, plugin, skillPaths)) ||
(await synthesizeFallback(sourceDir, plugin, skillPaths));
candidates.push(...resolved);
}
if (candidates.length === 0) {
throw new BmadModuleError(EXIT.BAD_MANIFEST, `marketplace.json resolved no installable module (no skills found on disk)`);
}
const pick = selectModule(candidates, selector);
return toSyntheticManifest(pick, sourceDir);
}
// ─── Skill-path resolution ───────────────────────────────────────────────────
// Map a plugin's skills[] (repo-relative, ./-prefixed) to source-relative POSIX
// paths that exist on disk and stay inside the source root. Mirrors
// plugin-resolver.js:60-71.
async function resolveSkillPaths(sourceDir, skillRel) {
const out = [];
for (const rel of skillRel) {
if (typeof rel !== 'string') continue;
const normalized = rel.replace(/^\.\//, '');
const abs = safePathInsideRoot(sourceDir, normalized);
if (!abs) continue; // traversal / absolute path — skip
if (await exists(abs)) out.push(toRel(sourceDir, abs));
}
return out;
}
// ─── Strategy 1: root module files (walk up from skills' common parent) ───────
async function tryRootModuleFiles(sourceDir, plugin, skillRelPaths) {
const commonParentAbs = computeCommonParent(skillRelPaths.map((r) => path.resolve(sourceDir, r)));
const candidates = await findModuleFilesUpward(commonParentAbs, sourceDir);
if (candidates.length === 0) return null;
// Deepest candidate (closest to the skills) is the safe default; a CLI has no
// interactive picker so we don't prompt between chain candidates.
const { moduleYamlAbs, moduleHelpAbs } = candidates[0];
const data = await readModuleYaml(moduleYamlAbs);
if (!data) return null;
return [
makeCandidate(plugin, data, skillRelPaths, {
moduleYamlRel: toRel(sourceDir, moduleYamlAbs),
moduleHelpCsvRel: toRel(sourceDir, moduleHelpAbs),
}),
];
}
// ─── Strategy 2: -setup skill with assets/module.yaml ─────────────────────────
async function trySetupSkill(sourceDir, plugin, skillRelPaths) {
for (const skillRel of skillRelPaths) {
if (!path.posix.basename(skillRel).endsWith('-setup')) continue;
const found = await skillAssets(sourceDir, skillRel);
if (!found) continue;
const data = await readModuleYaml(path.resolve(sourceDir, found.moduleYamlRel));
if (!data) continue;
return [makeCandidate(plugin, data, skillRelPaths, found)];
}
return null;
}
// ─── Strategy 3: single standalone skill ──────────────────────────────────────
async function trySingleStandalone(sourceDir, plugin, skillRelPaths) {
if (skillRelPaths.length !== 1) return null;
const found = await skillAssets(sourceDir, skillRelPaths[0]);
if (!found) return null;
const data = await readModuleYaml(path.resolve(sourceDir, found.moduleYamlRel));
if (!data) return null;
return [makeCandidate(plugin, data, skillRelPaths, found)];
}
// ─── Strategy 4: multiple standalone skills, each its own module ───────────────
async function tryMultipleStandalone(sourceDir, plugin, skillRelPaths) {
if (skillRelPaths.length < 2) return null;
const resolved = [];
for (const skillRel of skillRelPaths) {
const found = await skillAssets(sourceDir, skillRel);
if (!found) continue;
const data = await readModuleYaml(path.resolve(sourceDir, found.moduleYamlRel));
if (!data) continue;
resolved.push(
makeCandidate({ ...plugin }, data, [skillRel], found, {
fallbackCode: path.posix.basename(skillRel),
}),
);
}
// Only use strategy 4 if EVERY skill carries module files; otherwise fall
// through to the synthesizer (mirrors plugin-resolver.js:349-355).
return resolved.length === skillRelPaths.length ? resolved : null;
}
// ─── Strategy 5: synthesize from marketplace.json + SKILL.md frontmatter ──────
async function synthesizeFallback(sourceDir, plugin, skillRelPaths) {
const skillInfos = [];
for (const skillRel of skillRelPaths) {
const fm = await parseSkillFrontmatter(path.resolve(sourceDir, skillRel));
skillInfos.push({
dirName: path.posix.basename(skillRel),
name: fm.name || path.posix.basename(skillRel),
description: fm.description || '',
});
}
const code = plugin.name || path.posix.basename(skillRelPaths[0]);
const moduleName = formatDisplayName(code);
const synthesizedYaml =
`code: ${code}\n` +
`name: ${JSON.stringify(moduleName)}\n` +
`description: ${JSON.stringify(plugin.description || '')}\n` +
`module_version: ${plugin.version || '1.0.0'}\n`;
const synthesizedCsv = buildSynthesizedHelpCsv(moduleName, skillInfos);
return [
{
code,
name: moduleName,
version: plugin.version || null,
description: plugin.description || '',
pluginName: plugin.name,
skillRelPaths,
moduleYamlRel: 'module.yaml',
moduleHelpCsvRel: 'module-help.csv',
synthesizedYaml,
synthesizedCsv,
},
];
}
// ─── Candidate selection ──────────────────────────────────────────────────────
function selectModule(candidates, selector) {
if (candidates.length === 1) return candidates[0];
const codes = candidates.map((c) => c.code);
if (selector) {
const matches = candidates.filter((c) => c.code === selector);
if (matches.length === 1) return matches[0];
throw new BmadModuleError(EXIT.BAD_MANIFEST, `no module with code "${selector}" in this repo. Available: ${codes.join(', ')}.`);
}
throw new BmadModuleError(EXIT.BAD_MANIFEST, `this repo defines multiple modules: ${codes.join(', ')}. Re-run with --module <code>.`);
}
// ─── Synthetic manifest builder ───────────────────────────────────────────────
function toSyntheticManifest(pick, _sourceDir) {
const name = sanitizeName(pick.pluginName || pick.code);
const version = semverValid(pick.version) ? pick.version : '0.0.0';
const manifest = {
name,
version,
description: pick.description || '',
skills: pick.skillRelPaths.map((p) => `./${p}`),
bmad: {
specVersion: '1.0.0',
code: pick.code,
compatibility: { bmadMethod: '>=6.0.0' },
moduleVersion: pick.version || version,
moduleDefinition: `./${pick.moduleYamlRel}`,
moduleHelpCsv: `./${pick.moduleHelpCsvRel}`,
},
};
return {
manifest,
synthesized: {
'module.yaml': pick.synthesizedYaml || null,
'module-help.csv': pick.synthesizedCsv || null,
},
};
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
// Normalize a module.yaml + plugin pair into the intermediate candidate shape.
function makeCandidate(plugin, data, skillRelPaths, files, { fallbackCode } = {}) {
return {
code: data.code || fallbackCode || plugin.name,
name: data.name || plugin.name,
version: plugin.version || data.module_version || null,
description: data.description || plugin.description || '',
pluginName: plugin.name,
skillRelPaths,
moduleYamlRel: files.moduleYamlRel,
moduleHelpCsvRel: files.moduleHelpCsvRel,
synthesizedYaml: null,
synthesizedCsv: null,
};
}
// Return assets/module.yaml + assets/module-help.csv under a skill dir when both
// exist, as source-relative POSIX paths; else null.
async function skillAssets(sourceDir, skillRel) {
const moduleYamlRel = path.posix.join(skillRel, 'assets', 'module.yaml');
const moduleHelpCsvRel = path.posix.join(skillRel, 'assets', 'module-help.csv');
if (!(await exists(path.resolve(sourceDir, moduleYamlRel)))) return null;
if (!(await exists(path.resolve(sourceDir, moduleHelpCsvRel)))) return null;
return { moduleYamlRel, moduleHelpCsvRel };
}
// Walk from startDirAbs up to the source root, collecting dirs that contain BOTH
// module.yaml and module-help.csv. Deepest-first; bounded by sourceDir.
async function findModuleFilesUpward(startDirAbs, sourceDir) {
const root = path.resolve(sourceDir);
let dir = path.resolve(startDirAbs);
if (dir !== root && !dir.startsWith(root + path.sep)) dir = root;
const out = [];
while (true) {
const moduleYamlAbs = path.join(dir, 'module.yaml');
const moduleHelpAbs = path.join(dir, 'module-help.csv');
if ((await exists(moduleYamlAbs)) && (await exists(moduleHelpAbs))) {
out.push({ moduleYamlAbs, moduleHelpAbs });
}
if (dir === root) break;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return out;
}
// Deepest common ancestor of absolute paths. Single path → its dirname.
function computeCommonParent(absPaths) {
if (absPaths.length === 0) return '/';
if (absPaths.length === 1) return path.dirname(absPaths[0]);
const segments = absPaths.map((p) => p.split(path.sep));
const minLen = Math.min(...segments.map((s) => s.length));
const common = [];
for (let i = 0; i < minLen; i++) {
const seg = segments[0][i];
if (segments.every((s) => s[i] === seg)) common.push(seg);
else break;
}
return common.join(path.sep) || '/';
}
async function readModuleYaml(yamlAbs) {
try {
return parseYaml(await fs.readFile(yamlAbs, 'utf8'));
} catch {
return null;
}
}
async function parseSkillFrontmatter(skillDirAbs) {
try {
const content = await fs.readFile(path.join(skillDirAbs, 'SKILL.md'), 'utf8');
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return { name: '', description: '' };
const parsed = parseYaml(match[1]) || {};
return { name: parsed.name || '', description: parsed.description || '' };
} catch {
return { name: '', description: '' };
}
}
function buildSynthesizedHelpCsv(moduleName, skillInfos) {
const rows = [MODULE_HELP_CSV_HEADER];
for (const info of skillInfos) {
const displayName = formatDisplayName(info.name || info.dirName);
const menuCode = generateMenuCode(info.name || info.dirName);
const description = escapeCsvField(info.description);
rows.push(`${moduleName},${info.dirName},${displayName},${menuCode},${description},activate,,anytime,,,false,,`);
}
return rows.join('\n') + '\n';
}
function formatDisplayName(name) {
const cleaned = String(name || '')
.replace(/^bmad-agent-/, '')
.replace(/^bmad-/, '');
return cleaned
.split(/[-_]/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function generateMenuCode(name) {
const cleaned = String(name || '')
.replace(/^bmad-agent-/, '')
.replace(/^bmad-/, '');
return cleaned
.split(/[-_]/)
.filter((w) => w.length > 0)
.map((w) => w.charAt(0).toUpperCase())
.join('')
.slice(0, 3);
}
function escapeCsvField(value) {
if (!value) return '';
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replaceAll('"', '""')}"`;
}
return value;
}
// Coerce an arbitrary plugin/module name into a manifest `name` that passes
// NAME_REGEX (/^[a-z][a-z0-9-]+$/, 364 chars): lowercase, non-[a-z0-9-] → '-',
// collapse and trim dashes, ensure it starts with a letter and is ≥3 chars.
function sanitizeName(raw) {
let s = String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
if (!/^[a-z]/.test(s)) s = `bmad-${s}`.replace(/-+/g, '-').replace(/^-+|-+$/g, '');
if (s.length < 3) s = `bmad-module-${s}`.replace(/-+$/g, '');
return s.slice(0, 64).replace(/-+$/g, '');
}
function toRel(sourceDir, abs) {
return path.relative(sourceDir, abs).split(path.sep).join('/');
}
async function exists(abs) {
try {
await fs.access(abs);
return true;
} catch {
return false;
}
}