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:
parent
1fb1cf5ee6
commit
6d49cc505b
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue