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 path from 'node:path';
|
||||||
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
||||||
import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs';
|
import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs';
|
||||||
import { parseSource, materializeSource } from './lib/source.mjs';
|
import { parseSource, materializeSource } from './lib/source.mjs';
|
||||||
import { readAndValidateManifest } from './lib/plugin-json.mjs';
|
import { readAndValidateManifest } from './lib/plugin-json.mjs';
|
||||||
import { readUserIgnores, buildIgnoreMatcher, buildCopyList, validateDeclaredPaths } from './lib/install-plan.mjs';
|
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
|
||||||
import { copyDir, atomicSwapDir } from './lib/fs-safe.mjs';
|
import { stageCopyPlan, atomicSwapDir } from './lib/fs-safe.mjs';
|
||||||
import { readManifestYaml, addModuleToManifest, appendSkillManifestRows, appendFilesManifestRows } from './lib/manifest-ops.mjs';
|
import { readManifestYaml, addModuleToManifest, appendSkillManifestRows, appendFilesManifestRows } from './lib/manifest-ops.mjs';
|
||||||
|
|
||||||
// Run the install verb. `opts` shape:
|
// Run the install verb. `opts` shape:
|
||||||
|
|
@ -62,19 +61,25 @@ export async function runInstall(opts) {
|
||||||
validateDeclaredPaths(materialized.dir, manifest);
|
validateDeclaredPaths(materialized.dir, manifest);
|
||||||
const userIgnores = await readUserIgnores(materialized.dir, manifest);
|
const userIgnores = await readUserIgnores(materialized.dir, manifest);
|
||||||
const matchIgnore = buildIgnoreMatcher(userIgnores);
|
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) {
|
if (opts.dryRun) {
|
||||||
process.stdout.write(`[bmad-module] dry-run: would install ${code} (${manifest.name} ${manifest.version})\n`);
|
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] target: ${path.join(bmadDir, code)}\n`);
|
||||||
process.stdout.write(`[bmad-module] files (${copyList.length}):\n`);
|
process.stdout.write(`[bmad-module] files (${plan.length + 1}):\n`);
|
||||||
for (const rel of copyList) process.stdout.write(` ${rel}\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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// §6. Stage to tmp/staged-out, then atomic swap.
|
// §6. Stage to tmp/staged-out, then atomic swap.
|
||||||
const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out');
|
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);
|
const targetDir = path.join(bmadDir, code);
|
||||||
try {
|
try {
|
||||||
await atomicSwapDir(stagedDir, targetDir);
|
await atomicSwapDir(stagedDir, targetDir);
|
||||||
|
|
@ -93,9 +98,9 @@ export async function runInstall(opts) {
|
||||||
moduleName: manifest.name,
|
moduleName: manifest.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
const skillDirs = Array.isArray(manifest.skills) ? manifest.skills.map((s) => normalizeSkillDirRelToCode(s)) : [];
|
const destPaths = ['.claude-plugin/plugin.json', ...plan.map((p) => p.destRel)];
|
||||||
await appendSkillManifestRows(bmadDir, code, skillDirs);
|
await appendSkillManifestRows(bmadDir, code, skillDestDirs);
|
||||||
await appendFilesManifestRows(bmadDir, code, copyList);
|
await appendFilesManifestRows(bmadDir, code, destPaths);
|
||||||
|
|
||||||
// §8. Warn about Claude-only surfaces.
|
// §8. Warn about Claude-only surfaces.
|
||||||
const claudeOnly = [];
|
const claudeOnly = [];
|
||||||
|
|
@ -108,7 +113,7 @@ export async function runInstall(opts) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`[bmad-module] installed ${code} (${manifest.name} ${manifest.version})${materialized.sha ? ` @ ${materialized.sha.slice(0, 7)}` : ''}\n`,
|
`[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) {
|
if (claudeOnly.length) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`[bmad-module] note: ${claudeOnly.join(', ')} were copied but NOT auto-activated. ` +
|
`[bmad-module] note: ${claudeOnly.join(', ')} were copied but NOT auto-activated. ` +
|
||||||
|
|
@ -122,22 +127,3 @@ export async function runInstall(opts) {
|
||||||
await materialized.cleanup();
|
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;
|
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 —
|
// Atomically replace `targetDir` with `stagedDir` contents. Best effort —
|
||||||
// not truly atomic, but minimizes the inconsistent window.
|
// not truly atomic, but minimizes the inconsistent window.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,253 @@ export async function buildCopyList(sourceDir, ignoreMatch) {
|
||||||
out.sort();
|
out.sort();
|
||||||
return out;
|
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 path from 'node:path';
|
||||||
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
||||||
import { findBmadDir } from './lib/bmad-dir.mjs';
|
import { findBmadDir } from './lib/bmad-dir.mjs';
|
||||||
import { parseSource, materializeSource } from './lib/source.mjs';
|
import { parseSource, materializeSource } from './lib/source.mjs';
|
||||||
import { readAndValidateManifest } from './lib/plugin-json.mjs';
|
import { readAndValidateManifest } from './lib/plugin-json.mjs';
|
||||||
import { readUserIgnores, buildIgnoreMatcher, buildCopyList, validateDeclaredPaths } from './lib/install-plan.mjs';
|
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
|
||||||
import { copyDir, atomicSwapDir, sha256File, pruneEmptyDirs } from './lib/fs-safe.mjs';
|
import { stageCopyPlan, atomicSwapDir, sha256File, pruneEmptyDirs } from './lib/fs-safe.mjs';
|
||||||
import {
|
import {
|
||||||
readManifestYaml,
|
readManifestYaml,
|
||||||
addModuleToManifest,
|
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);
|
validateDeclaredPaths(materialized.dir, manifest);
|
||||||
const userIgnores = await readUserIgnores(materialized.dir, manifest);
|
const userIgnores = await readUserIgnores(materialized.dir, manifest);
|
||||||
const matchIgnore = buildIgnoreMatcher(userIgnores);
|
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');
|
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);
|
const targetDir = path.join(bmadDir, code);
|
||||||
try {
|
try {
|
||||||
await atomicSwapDir(stagedDir, targetDir);
|
await atomicSwapDir(stagedDir, targetDir);
|
||||||
|
|
@ -115,9 +117,9 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
|
||||||
rawSource: descriptor.rawInput,
|
rawSource: descriptor.rawInput,
|
||||||
moduleName: manifest.name,
|
moduleName: manifest.name,
|
||||||
});
|
});
|
||||||
const skillDirs = Array.isArray(manifest.skills) ? manifest.skills.map((s) => (s.startsWith('./') ? s.slice(2) : s)) : [];
|
const destPaths = ['.claude-plugin/plugin.json', ...plan.map((p) => p.destRel)];
|
||||||
await appendSkillManifestRows(bmadDir, code, skillDirs);
|
await appendSkillManifestRows(bmadDir, code, skillDestDirs);
|
||||||
await appendFilesManifestRows(bmadDir, code, copyList);
|
await appendFilesManifestRows(bmadDir, code, destPaths);
|
||||||
|
|
||||||
// Prune empty dirs left behind from removed files. (The atomic swap of
|
// 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
|
// 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(
|
process.stdout.write(
|
||||||
`[bmad-module] updated ${code} (${manifest.name} ${manifest.version})${materialized.sha ? ` @ ${materialized.sha.slice(0, 7)}` : ''}\n`,
|
`[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 {
|
} finally {
|
||||||
await materialized.cleanup();
|
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