import fs from 'node:fs/promises'; import path from 'node:path'; import { EXIT, BmadModuleError } from './exit.mjs'; import { safePathInsideRoot } from './fs-safe.mjs'; // Default ignore patterns always applied on top of user ignores. // `.claude-plugin/` is intentionally NOT ignored — the manifest is needed // post-install for `update` and `list` to re-resolve the module. const DEFAULT_IGNORES = ['.git/**', '.git', 'node_modules/**', 'node_modules', '.bmadignore', '.DS_Store', '**/.DS_Store']; // Compile one ignore pattern (gitignore-lite: supports `*`, `**`, `?`, and // trailing `/`; no negation, no leading `/` anchoring) into a RegExp matched // against a POSIX-style relative path. function compilePattern(pattern) { let p = pattern.trim(); if (!p || p.startsWith('#')) return null; // Treat trailing slash as "directory" — match the dir and its contents. const dirOnly = p.endsWith('/'); if (dirOnly) p = p.slice(0, -1); // Anchor by default (gitignore semantics): if no slash in pattern, match // basename anywhere; else anchor to root. const anchored = p.includes('/'); let body = p .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*\*/g, '\uFFFF') .replace(/\*/g, '[^/]*') .replace(/\uFFFF/g, '.*') .replace(/\?/g, '[^/]'); const re = anchored ? new RegExp(`^${body}(/.*)?$`) : new RegExp(`(^|/)${body}(/.*)?$`); return re; } export function buildIgnoreMatcher(userPatterns) { const patterns = [...DEFAULT_IGNORES, ...(userPatterns || [])]; const compiled = patterns.map(compilePattern).filter(Boolean); return (relPath) => { const posix = relPath.replaceAll('\\', '/'); return compiled.some((re) => re.test(posix)); }; } // Load user ignore patterns from manifest first, then .bmadignore. Declaring // both at once is disallowed — readUserIgnores enforces it. export async function readUserIgnores(sourceDir, manifest) { const fromManifest = manifest?.bmad?.install?.ignore; const ignoreFilePath = path.join(sourceDir, '.bmadignore'); let fromFile = null; try { const buf = await fs.readFile(ignoreFilePath, 'utf8'); fromFile = buf .split(/\r?\n/) .map((s) => s.trim()) .filter(Boolean); } catch { /* no .bmadignore — fine */ } if (Array.isArray(fromManifest) && fromFile) { throw new BmadModuleError(EXIT.BAD_MANIFEST, `both .bmadignore and bmad.install.ignore are present — pick one`); } if (Array.isArray(fromManifest)) return fromManifest; if (fromFile) return fromFile; return []; } // Validate that every declared path in the manifest exists inside the source // tree and resolves safely (no traversal, no symlink escape). Declared paths // double as documentation; they do NOT drive the copy list, but if they are // broken the install would land a non-functional module. export function validateDeclaredPaths(sourceDir, manifest) { const declared = []; const arr = (key, val) => Array.isArray(val) && val.forEach((v) => declared.push({ key, val: v })); const str = (key, val) => typeof val === 'string' && declared.push({ key, val }); arr('skills', manifest.skills); arr('agents', manifest.agents); arr('commands', manifest.commands); str('hooks', manifest.hooks); if (typeof manifest.mcpServers === 'string') str('mcpServers', manifest.mcpServers); str('lspServers', manifest.lspServers); str('settings', manifest.settings); str('bmad.moduleDefinition', manifest.bmad?.moduleDefinition); str('bmad.moduleHelpCsv', manifest.bmad?.moduleHelpCsv); arr('bmad.customize.schemas', manifest.bmad?.customize?.schemas); if (manifest.bmad?.docs) { str('bmad.docs.readme', manifest.bmad.docs.readme); str('bmad.docs.changelog', manifest.bmad.docs.changelog); if (typeof manifest.bmad.docs.homepage === 'string' && !/^https?:/.test(manifest.bmad.docs.homepage)) { str('bmad.docs.homepage', manifest.bmad.docs.homepage); } } for (const { key, val } of declared) { const safe = safePathInsideRoot(sourceDir, val); if (safe === null) { throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `manifest ${key}: "${val}" escapes module root`); } } // Existence is enforced by the validator pre-publish; at install time we // surface missing declared paths as PATH_TRAVERSAL-class problems too, so // the user gets a single failure mode for "manifest doesn't match tree". return declared; } // Walk the module source tree and return the list of POSIX-relative file // paths that should be copied into `_bmad//`. Honors ignore patterns // and skips symlinks (they're not preserved in the install). export async function buildCopyList(sourceDir, ignoreMatch) { const out = []; async function walk(rel) { const absDir = path.join(sourceDir, rel); const entries = await fs.readdir(absDir, { withFileTypes: true }); for (const entry of entries) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (ignoreMatch(childRel)) continue; if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) await walk(childRel); else if (entry.isFile()) out.push(childRel); } } await walk(''); out.sort(); return out; } // Top-level files we always copy if present (and not ignored). Authors don't // have to declare these — they're conventional repo metadata. package.json / // package-lock.json are included so a module that ships JS runtime deps can have // them installed in place post-copy (see npm-deps.mjs); node_modules itself is // never copied (it's in DEFAULT_IGNORES) and is regenerated by npm install. const ALWAYS_TOPLEVEL = new Set([ 'README.md', 'README', 'README.rst', 'CHANGELOG.md', 'CHANGELOG', 'LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE', 'LICENCE.md', 'NOTICE', 'NOTICE.md', 'package.json', 'package-lock.json', ]); function stripDotSlash(p) { if (typeof p !== 'string') return p; let s = p.replaceAll('\\', '/'); if (s.startsWith('./')) s = s.slice(2); return s; } // Recursively list files under `sourceDir/relDir`, returning POSIX paths // relative to `sourceDir`. Skips symlinks and ignore-matched entries. async function listFilesUnder(sourceDir, relDir, ignoreMatch) { const out = []; async function walk(rel) { const absDir = path.join(sourceDir, rel); let entries; try { entries = await fs.readdir(absDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (ignoreMatch && ignoreMatch(childRel)) continue; if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) await walk(childRel); else if (entry.isFile()) out.push(childRel); } } await walk(relDir); return out; } // Build a manifest-driven copy plan. Each entry is { srcRel, destRel } in // POSIX form, relative to the module's source root / install root respectively. // // The plan: // - Each declared `skills[]` / `agents[]` / `commands[]` dir is copied // recursively into the canonical slot (`skills//...` etc.), // regardless of where the author kept it in source (e.g. under `src/`). // - `bmad.moduleDefinition` → `module.yaml` // - `bmad.moduleHelpCsv` → `module-help.csv` // - `bmad.docs.readme` / `bmad.docs.changelog` / `bmad.docs.homepage` // (relative path) → preserved at module root with their basename. // - `hooks` / `mcpServers` / `lspServers` / `settings` (when string paths) → // canonical root slot (`hooks.json`, `.mcp.json`, etc.). // - `.claude-plugin/plugin.json` is always kept (callers rewrite paths in it // via `rewriteManifestPaths`). // - `.claude-plugin/marketplace.json` is preserved if present. // - Conventional top-level metadata files (README/CHANGELOG/LICENSE/NOTICE) // are copied if present at source root. // // Anything NOT covered by the above is dropped. This means `tools/`, `website/`, // `.github/`, `.trunk/`, etc. don't leak into the install even if the author // forgot to list them in `bmad.install.ignore`. // // Returns: { plan, skillDestDirs } where skillDestDirs is the list of canonical // skill paths (`skills/X`) for the skill-manifest writer. export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) { const plan = []; const claimedSrc = new Set(); const claimedDest = new Set(); // `allowDupSrc` lets a single source file be copied to a second destination // even if it was already claimed by an earlier (recursive) copy. Needed for // moduleDefinition / moduleHelpCsv, which commonly live INSIDE a declared // skill dir (e.g. `/assets/module.yaml`): they must also be // flattened to the canonical `_bmad//module.yaml` root slot that the // rewritten plugin.json points at and that config / help-catalog read. const addFile = (srcRel, destRel, allowDupSrc = false) => { if (!srcRel || !destRel) return; if (!allowDupSrc && claimedSrc.has(srcRel)) return; if (claimedDest.has(destRel)) return; if (!allowDupSrc) claimedSrc.add(srcRel); claimedDest.add(destRel); plan.push({ srcRel, destRel }); }; const addDirRecursive = async (srcRelDir, destRelDir) => { const files = await listFilesUnder(sourceDir, srcRelDir, ignoreMatch); for (const fileSrcRel of files) { const rest = fileSrcRel.slice(srcRelDir.length).replace(/^\//, ''); const destRel = rest ? `${destRelDir}/${rest}` : destRelDir; addFile(fileSrcRel, destRel); } }; // Helper: if `srcRel` exists as a file in source, queue it. const queueFileIfExists = async (srcRel, destRel, allowDupSrc = false) => { if (!srcRel) return; if (ignoreMatch && ignoreMatch(srcRel)) return; try { const stat = await fs.stat(path.join(sourceDir, srcRel)); if (stat.isFile()) addFile(srcRel, destRel, allowDupSrc); } catch { /* missing — silently skip; validateDeclaredPaths surfaces declared misses */ } }; // Plugin manifest itself — always kept. Path is rewritten by the caller // before staging; here we just reserve the slot so nothing else claims it. claimedDest.add('.claude-plugin/plugin.json'); // Optional marketplace.json — copy verbatim if present. await queueFileIfExists('.claude-plugin/marketplace.json', '.claude-plugin/marketplace.json'); // Skills / agents / commands. const arrCategories = [ ['skills', 'skills', manifest.skills], ['agents', 'agents', manifest.agents], ['commands', 'commands', manifest.commands], ]; const skillDestDirs = []; for (const [, destPrefix, arr] of arrCategories) { if (!Array.isArray(arr)) continue; for (const declared of arr) { const srcRel = stripDotSlash(declared); if (!srcRel) continue; const destRel = `${destPrefix}/${path.posix.basename(srcRel)}`; // Entries may be directories (skills, agent packs) or single files // (e.g. a subagent declared as `./agents/foo.md`). Stat to branch; // rewriteManifestPaths() remaps both to `/`. try { const stat = await fs.stat(path.join(sourceDir, srcRel)); if (stat.isDirectory()) { await addDirRecursive(srcRel, destRel); if (destPrefix === 'skills') skillDestDirs.push(destRel); } else if (stat.isFile() && (!ignoreMatch || !ignoreMatch(srcRel))) addFile(srcRel, destRel); } catch { /* missing — validateDeclaredPaths surfaces declared misses */ } } } // moduleDefinition / moduleHelpCsv — flatten to canonical names at root. // allowDupSrc: these often live inside a declared skill dir, so the source // file may already be claimed by that skill's recursive copy; we still want // the canonical root copy that the rewritten manifest, config-gen, and the // help catalog all rely on. if (typeof manifest.bmad?.moduleDefinition === 'string') { await queueFileIfExists(stripDotSlash(manifest.bmad.moduleDefinition), 'module.yaml', true); } if (typeof manifest.bmad?.moduleHelpCsv === 'string') { await queueFileIfExists(stripDotSlash(manifest.bmad.moduleHelpCsv), 'module-help.csv', true); } // Top-level docs declared in the manifest — keep at root by basename. const docs = manifest.bmad?.docs; if (docs && typeof docs === 'object') { for (const key of ['readme', 'changelog', 'homepage']) { const v = docs[key]; if (typeof v !== 'string') continue; if (/^https?:/i.test(v)) continue; const srcRel = stripDotSlash(v); await queueFileIfExists(srcRel, path.posix.basename(srcRel)); } } // String-typed Claude-Code surfaces — canonical root slot. const stringSurfaces = [ ['hooks', 'hooks.json'], ['mcpServers', '.mcp.json'], ['lspServers', 'lsp-servers.json'], ['settings', 'settings.json'], ]; for (const [key, destName] of stringSurfaces) { const v = manifest[key]; if (typeof v !== 'string') continue; const srcRel = stripDotSlash(v); if (!srcRel) continue; // These surfaces are single JSON files installed at a fixed canonical name // (rewriteManifestPaths always points the manifest at `./hooks.json`, // `./.mcp.json`, etc.). A directory source would be copied under its // basename yet leave the manifest pointing at a file that was never written, // so only file sources are honored here. try { const stat = await fs.stat(path.join(sourceDir, srcRel)); if (stat.isFile()) addFile(srcRel, destName); } catch { /* missing — skip */ } } // Conventional top-level metadata files — copy if present. for (const name of ALWAYS_TOPLEVEL) { await queueFileIfExists(name, name); } // Stable order — dest-relative sort makes diffs and dry-run output readable. plan.sort((a, b) => a.destRel.localeCompare(b.destRel)); skillDestDirs.sort(); return { plan, skillDestDirs }; } // Produce a rewritten plugin.json where every declared path points at its // canonical post-install location (so the on-disk manifest stays self-consistent // inside `_bmad//`). Returns a JSON string. export function rewriteManifestPaths(manifest) { const out = structuredClone(manifest); const remapArr = (arr, destPrefix) => { if (!Array.isArray(arr)) return arr; return arr.map((entry) => { if (typeof entry !== 'string') return entry; const srcRel = stripDotSlash(entry); return `./${destPrefix}/${path.posix.basename(srcRel)}`; }); }; if (Array.isArray(out.skills)) out.skills = remapArr(out.skills, 'skills'); if (Array.isArray(out.agents)) out.agents = remapArr(out.agents, 'agents'); if (Array.isArray(out.commands)) out.commands = remapArr(out.commands, 'commands'); if (typeof out.hooks === 'string') out.hooks = './hooks.json'; if (typeof out.mcpServers === 'string') out.mcpServers = './.mcp.json'; if (typeof out.lspServers === 'string') out.lspServers = './lsp-servers.json'; if (typeof out.settings === 'string') out.settings = './settings.json'; if (out.bmad && typeof out.bmad === 'object') { if (typeof out.bmad.moduleDefinition === 'string') out.bmad.moduleDefinition = './module.yaml'; if (typeof out.bmad.moduleHelpCsv === 'string') out.bmad.moduleHelpCsv = './module-help.csv'; // customize.schemas — each entry lives inside a declared skill dir, which is // remapped to `skills/`. Anchor on the owning skill dir and keep // every segment after it, so nested schemas (e.g. `/schemas/x.yaml`) // land under the right skill instead of being collapsed to the last two // segments. const skillDirs = Array.isArray(manifest.skills) ? manifest.skills.filter((s) => typeof s === 'string').map((s) => stripDotSlash(s)) : []; const schemas = out.bmad.customize?.schemas; if (Array.isArray(schemas)) { out.bmad.customize.schemas = schemas.map((entry) => { if (typeof entry !== 'string') return entry; const srcRel = stripDotSlash(entry); const owner = skillDirs.find((sd) => sd && (srcRel === sd || srcRel.startsWith(sd + '/'))); if (owner) { const remainder = srcRel.slice(owner.length + 1); return `./skills/${path.posix.basename(owner)}/${remainder}`; } // Fallback when no declared skill owns the path: last two segments are // assumed to be `/`. const parts = srcRel.split('/'); if (parts.length >= 2) return `./skills/${parts.at(-2)}/${parts.at(-1)}`; return `./${srcRel}`; }); } if (out.bmad.docs && typeof out.bmad.docs === 'object') { for (const key of ['readme', 'changelog', 'homepage']) { const v = out.bmad.docs[key]; if (typeof v !== 'string') continue; if (/^https?:/i.test(v)) continue; out.bmad.docs[key] = `./${path.posix.basename(stripDotSlash(v))}`; } } } return JSON.stringify(out, null, 2) + '\n'; }