diff --git a/src/core-skills/bmad-module/scripts/install.mjs b/src/core-skills/bmad-module/scripts/install.mjs index 4d9c2cb26..750c2822e 100644 --- a/src/core-skills/bmad-module/scripts/install.mjs +++ b/src/core-skills/bmad-module/scripts/install.mjs @@ -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//` 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; -} diff --git a/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs b/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs index 7f787216d..fe8380844 100644 --- a/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs +++ b/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs @@ -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. // diff --git a/src/core-skills/bmad-module/scripts/lib/install-plan.mjs b/src/core-skills/bmad-module/scripts/lib/install-plan.mjs index 69ec7bfc3..66f9a18f4 100644 --- a/src/core-skills/bmad-module/scripts/lib/install-plan.mjs +++ b/src/core-skills/bmad-module/scripts/lib/install-plan.mjs @@ -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//...` 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//`). 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/`, so the schema's new path is + // `./skills//`. + 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 `/`. + 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'; +} diff --git a/src/core-skills/bmad-module/scripts/update.mjs b/src/core-skills/bmad-module/scripts/update.mjs index 1eb6cc398..d343767a0 100644 --- a/src/core-skills/bmad-module/scripts/update.mjs +++ b/src/core-skills/bmad-module/scripts/update.mjs @@ -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; -}