feat(bmad-module): manifest-driven copy plan with canonical path rewriting

Replace the flat copy-list staging with a manifest-driven copy plan:

- buildCopyPlan maps declared skills/agents/commands and string-typed
  Claude-Code surfaces into canonical install slots, copies conventional
  top-level metadata, and drops anything not covered (no more leaking of
  tools/, website/, .github/, etc.).
- rewriteManifestPaths emits a plugin.json whose paths point at the
  canonical post-install locations, keeping the on-disk manifest
  self-consistent inside _bmad/<code>/.
- stageCopyPlan stages the plan plus synthesized files (rewritten
  plugin.json) into the tmp dir for the atomic swap.

install.mjs and update.mjs switch to the new plan/skillDestDirs flow and
drop the now-unused copy-list helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pbean 2026-05-28 19:20:35 -07:00
parent 1fb1cf5ee6
commit 6d49cc505b
4 changed files with 301 additions and 46 deletions

View File

@ -1,11 +1,10 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { EXIT, BmadModuleError } from './lib/exit.mjs';
import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs';
import { parseSource, materializeSource } from './lib/source.mjs';
import { readAndValidateManifest } from './lib/plugin-json.mjs';
import { readUserIgnores, buildIgnoreMatcher, buildCopyList, validateDeclaredPaths } from './lib/install-plan.mjs';
import { copyDir, atomicSwapDir } from './lib/fs-safe.mjs';
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
import { stageCopyPlan, atomicSwapDir } from './lib/fs-safe.mjs';
import { readManifestYaml, addModuleToManifest, appendSkillManifestRows, appendFilesManifestRows } from './lib/manifest-ops.mjs';
// Run the install verb. `opts` shape:
@ -62,19 +61,25 @@ export async function runInstall(opts) {
validateDeclaredPaths(materialized.dir, manifest);
const userIgnores = await readUserIgnores(materialized.dir, manifest);
const matchIgnore = buildIgnoreMatcher(userIgnores);
const copyList = await buildCopyList(materialized.dir, matchIgnore);
const { plan, skillDestDirs } = await buildCopyPlan(materialized.dir, manifest, matchIgnore);
const rewrittenManifestJson = rewriteManifestPaths(manifest);
if (opts.dryRun) {
process.stdout.write(`[bmad-module] dry-run: would install ${code} (${manifest.name} ${manifest.version})\n`);
process.stdout.write(`[bmad-module] target: ${path.join(bmadDir, code)}\n`);
process.stdout.write(`[bmad-module] files (${copyList.length}):\n`);
for (const rel of copyList) process.stdout.write(` ${rel}\n`);
process.stdout.write(`[bmad-module] files (${plan.length + 1}):\n`);
process.stdout.write(` .claude-plugin/plugin.json (rewritten to canonical paths)\n`);
for (const { srcRel, destRel } of plan) {
process.stdout.write(srcRel === destRel ? ` ${destRel}\n` : ` ${destRel} (from ${srcRel})\n`);
}
return;
}
// §6. Stage to tmp/staged-out, then atomic swap.
const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out');
await copyDir(materialized.dir, stagedDir, (rel) => !copyList.includes(rel) && !isAncestorOfAny(rel, copyList));
await stageCopyPlan(materialized.dir, stagedDir, plan, {
'.claude-plugin/plugin.json': rewrittenManifestJson,
});
const targetDir = path.join(bmadDir, code);
try {
await atomicSwapDir(stagedDir, targetDir);
@ -93,9 +98,9 @@ export async function runInstall(opts) {
moduleName: manifest.name,
});
const skillDirs = Array.isArray(manifest.skills) ? manifest.skills.map((s) => normalizeSkillDirRelToCode(s)) : [];
await appendSkillManifestRows(bmadDir, code, skillDirs);
await appendFilesManifestRows(bmadDir, code, copyList);
const destPaths = ['.claude-plugin/plugin.json', ...plan.map((p) => p.destRel)];
await appendSkillManifestRows(bmadDir, code, skillDestDirs);
await appendFilesManifestRows(bmadDir, code, destPaths);
// §8. Warn about Claude-only surfaces.
const claudeOnly = [];
@ -108,7 +113,7 @@ export async function runInstall(opts) {
process.stdout.write(
`[bmad-module] installed ${code} (${manifest.name} ${manifest.version})${materialized.sha ? ` @ ${materialized.sha.slice(0, 7)}` : ''}\n`,
);
process.stdout.write(`[bmad-module] copied ${copyList.length} file(s) to ${path.relative(projectDir, targetDir)}\n`);
process.stdout.write(`[bmad-module] copied ${destPaths.length} file(s) to ${path.relative(projectDir, targetDir)}\n`);
if (claudeOnly.length) {
process.stdout.write(
`[bmad-module] note: ${claudeOnly.join(', ')} were copied but NOT auto-activated. ` +
@ -122,22 +127,3 @@ export async function runInstall(opts) {
await materialized.cleanup();
}
}
// Strip leading `./` and split the declared skill path. Modules use paths
// relative to the module root (e.g. `./skills/bmad-devlog-write`). Within
// `_bmad/<code>/` the same relative layout is preserved, so we just strip
// the dot-slash.
function normalizeSkillDirRelToCode(skillPath) {
let p = String(skillPath);
if (p.startsWith('./')) p = p.slice(2);
return p;
}
// During staging we filter the source tree to just the files we plan to copy.
// `copyList` is a list of files; we also need to allow their ancestor dirs to
// be walked. This returns true iff `rel` is a prefix of some path in list.
function isAncestorOfAny(rel, list) {
const prefix = rel + '/';
for (const p of list) if (p.startsWith(prefix)) return true;
return false;
}

View File

@ -64,6 +64,29 @@ export async function copyDir(srcDir, destDir, shouldSkip = () => false) {
return copied;
}
// Stage a copy plan into `destDir`: each plan entry copies one file from
// `srcRoot/srcRel` to `destDir/destRel`. `extras` is an optional map of
// `destRel → string content` for synthesized files (e.g. a rewritten plugin.json)
// that have no source-tree counterpart. Returns the union of destRels written.
export async function stageCopyPlan(srcRoot, destDir, plan, extras = {}) {
await fsp.mkdir(destDir, { recursive: true });
const written = [];
for (const { srcRel, destRel } of plan) {
const absSrc = path.join(srcRoot, srcRel);
const absDest = path.join(destDir, destRel);
await fsp.mkdir(path.dirname(absDest), { recursive: true });
await fsp.copyFile(absSrc, absDest);
written.push(destRel);
}
for (const [destRel, content] of Object.entries(extras)) {
const absDest = path.join(destDir, destRel);
await fsp.mkdir(path.dirname(absDest), { recursive: true });
await fsp.writeFile(absDest, content, 'utf8');
written.push(destRel);
}
return written;
}
// Atomically replace `targetDir` with `stagedDir` contents. Best effort —
// not truly atomic, but minimizes the inconsistent window.
//

View File

@ -119,3 +119,253 @@ export async function buildCopyList(sourceDir, ignoreMatch) {
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.
const ALWAYS_TOPLEVEL = new Set([
'README.md',
'README',
'README.rst',
'CHANGELOG.md',
'CHANGELOG',
'LICENSE',
'LICENSE.md',
'LICENSE.txt',
'LICENCE',
'LICENCE.md',
'NOTICE',
'NOTICE.md',
]);
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/<basename>/...` 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();
const addFile = (srcRel, destRel) => {
if (!srcRel || !destRel) return;
if (claimedSrc.has(srcRel)) return;
if (claimedDest.has(destRel)) return;
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) => {
if (!srcRel) return;
if (ignoreMatch && ignoreMatch(srcRel)) return;
try {
const stat = await fs.stat(path.join(sourceDir, srcRel));
if (stat.isFile()) addFile(srcRel, destRel);
} 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)}`;
await addDirRecursive(srcRel, destRel);
if (destPrefix === 'skills') skillDestDirs.push(destRel);
}
}
// moduleDefinition / moduleHelpCsv — flatten to canonical names at root.
if (typeof manifest.bmad?.moduleDefinition === 'string') {
await queueFileIfExists(stripDotSlash(manifest.bmad.moduleDefinition), 'module.yaml');
}
if (typeof manifest.bmad?.moduleHelpCsv === 'string') {
await queueFileIfExists(stripDotSlash(manifest.bmad.moduleHelpCsv), 'module-help.csv');
}
// 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;
// If the declared path is a directory, copy it under its basename.
try {
const stat = await fs.stat(path.join(sourceDir, srcRel));
if (stat.isDirectory()) {
await addDirRecursive(srcRel, path.posix.basename(srcRel));
} else 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/<code>/`). 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 its skill dir; the skill dir
// itself is remapped to `skills/<basename>`, so the schema's new path is
// `./skills/<skill-basename>/<file>`.
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 parts = srcRel.split('/');
// Heuristic: last two segments are `<skill-name>/<filename>`.
if (parts.length >= 2) {
const file = parts.at(-1);
const skill = parts.at(-2);
return `./skills/${skill}/${file}`;
}
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';
}

View File

@ -1,11 +1,10 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { EXIT, BmadModuleError } from './lib/exit.mjs';
import { findBmadDir } from './lib/bmad-dir.mjs';
import { parseSource, materializeSource } from './lib/source.mjs';
import { readAndValidateManifest } from './lib/plugin-json.mjs';
import { readUserIgnores, buildIgnoreMatcher, buildCopyList, validateDeclaredPaths } from './lib/install-plan.mjs';
import { copyDir, atomicSwapDir, sha256File, pruneEmptyDirs } from './lib/fs-safe.mjs';
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
import { stageCopyPlan, atomicSwapDir, sha256File, pruneEmptyDirs } from './lib/fs-safe.mjs';
import {
readManifestYaml,
addModuleToManifest,
@ -88,14 +87,17 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
);
}
// Build new copy list, stage, swap.
// Build new copy plan, stage, swap.
validateDeclaredPaths(materialized.dir, manifest);
const userIgnores = await readUserIgnores(materialized.dir, manifest);
const matchIgnore = buildIgnoreMatcher(userIgnores);
const copyList = await buildCopyList(materialized.dir, matchIgnore);
const { plan, skillDestDirs } = await buildCopyPlan(materialized.dir, manifest, matchIgnore);
const rewrittenManifestJson = rewriteManifestPaths(manifest);
const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out');
await copyDir(materialized.dir, stagedDir, (rel) => !copyList.includes(rel) && !isAncestorOfAny(rel, copyList));
await stageCopyPlan(materialized.dir, stagedDir, plan, {
'.claude-plugin/plugin.json': rewrittenManifestJson,
});
const targetDir = path.join(bmadDir, code);
try {
await atomicSwapDir(stagedDir, targetDir);
@ -115,9 +117,9 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
rawSource: descriptor.rawInput,
moduleName: manifest.name,
});
const skillDirs = Array.isArray(manifest.skills) ? manifest.skills.map((s) => (s.startsWith('./') ? s.slice(2) : s)) : [];
await appendSkillManifestRows(bmadDir, code, skillDirs);
await appendFilesManifestRows(bmadDir, code, copyList);
const destPaths = ['.claude-plugin/plugin.json', ...plan.map((p) => p.destRel)];
await appendSkillManifestRows(bmadDir, code, skillDestDirs);
await appendFilesManifestRows(bmadDir, code, destPaths);
// Prune empty dirs left behind from removed files. (The atomic swap of
// the module root already replaced everything; this is a no-op guard for
@ -127,14 +129,8 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
process.stdout.write(
`[bmad-module] updated ${code} (${manifest.name} ${manifest.version})${materialized.sha ? ` @ ${materialized.sha.slice(0, 7)}` : ''}\n`,
);
process.stdout.write(`[bmad-module] previous ${oldEntries.length} file(s) → new ${copyList.length} file(s)\n`);
process.stdout.write(`[bmad-module] previous ${oldEntries.length} file(s) → new ${destPaths.length} file(s)\n`);
} finally {
await materialized.cleanup();
}
}
function isAncestorOfAny(rel, list) {
const prefix = rel + '/';
for (const p of list) if (p.startsWith(prefix)) return true;
return false;
}