BMAD-METHOD/src/core-skills/bmad-module/scripts/update.mjs

156 lines
6.8 KiB
JavaScript

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, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
import { stageCopyPlan, atomicSwapDir, sha256File, pruneEmptyDirs } from './lib/fs-safe.mjs';
import {
readManifestYaml,
addModuleToManifest,
appendSkillManifestRows,
appendFilesManifestRows,
removeSkillManifestRows,
removeFilesManifestRows,
readFileEntriesForModule,
readSkillCanonicalIdsForModule,
} from './lib/manifest-ops.mjs';
import { distributeToIdes } from './lib/ide-sync.mjs';
import { finishModuleInstall } from './install.mjs';
// Update one installed module (or all when opts.all is true). v1 semantics:
// - Re-resolves the original source (or new --ref) and re-clones.
// - Same sha → no-op.
// - Different sha → diff files-manifest rows; abort if any tracked file has
// been modified locally; otherwise install-over-top and prune removed.
export async function runUpdate(opts) {
const projectDir = opts.projectDir || process.cwd();
const bmadDir = await findBmadDir(projectDir);
if (!bmadDir) {
throw new BmadModuleError(EXIT.NO_BMAD_DIR, `no _bmad/ found in ${projectDir}`);
}
const manifest = await readManifestYaml(bmadDir);
const allModules = (manifest?.modules || []).filter((m) => m && m.source === 'community');
let targets;
if (opts.all) {
targets = allModules;
} else {
if (!opts.code) throw new BmadModuleError(EXIT.USAGE, `bmad-module update <code|--all> is required`);
const t = allModules.find((m) => m.name === opts.code);
if (!t) throw new BmadModuleError(EXIT.NOT_INSTALLED, `no module "${opts.code}" in manifest.yaml`);
targets = [t];
}
for (const entry of targets) {
await updateOne(bmadDir, projectDir, entry, opts);
}
}
async function updateOne(bmadDir, projectDir, entry, opts) {
const code = entry.name;
if (!entry.rawSource) {
throw new BmadModuleError(EXIT.BAD_MANIFEST, `module ${code} has no rawSource in manifest.yaml — cannot re-resolve`);
}
const descriptor = parseSource(entry.rawSource);
const materialized = await materializeSource(descriptor, { ref: opts.ref || entry.ref || null });
try {
// No-op fast path.
if (materialized.sha && materialized.sha === entry.sha) {
process.stdout.write(`[bmad-module] ${code} already at ${materialized.sha.slice(0, 7)} — no-op.\n`);
return;
}
const manifest = await readAndValidateManifest(materialized.dir);
if (manifest.bmad.code !== code) {
throw new BmadModuleError(
EXIT.PREFIX_COLLISION,
`source manifest declares bmad.code "${manifest.bmad.code}" but installed code is "${code}"`,
);
}
// Capture the currently-distributed skill ids before we rewrite the
// manifest, so any skill dropped between versions is pruned from the IDE
// directories (and re-distributed ones are refreshed).
const oldSkillIds = await readSkillCanonicalIdsForModule(bmadDir, code);
// Modified-file check: any tracked file whose on-disk hash diverges from
// the recorded one is treated as user-modified. Abort rather than clobber.
const oldEntries = await readFileEntriesForModule(bmadDir, code);
const modified = [];
for (const fe of oldEntries) {
const abs = path.join(bmadDir, fe.path);
const current = await sha256File(abs);
if (current === null) continue;
if (fe.hash && current !== fe.hash) modified.push(fe.path);
}
if (modified.length) {
throw new BmadModuleError(
EXIT.MODIFIED_FILES,
`update would overwrite ${modified.length} locally-modified file(s):\n ` +
modified.join('\n ') +
`\nMove your changes into _bmad/custom/${code}/ and re-run.`,
);
}
// Build new copy plan, stage, swap.
validateDeclaredPaths(materialized.dir, manifest);
const userIgnores = await readUserIgnores(materialized.dir, manifest);
const matchIgnore = buildIgnoreMatcher(userIgnores);
const { plan, skillDestDirs } = await buildCopyPlan(materialized.dir, manifest, matchIgnore);
const rewrittenManifestJson = rewriteManifestPaths(manifest);
const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out');
await stageCopyPlan(materialized.dir, stagedDir, plan, {
'.claude-plugin/plugin.json': rewrittenManifestJson,
});
const targetDir = path.join(bmadDir, code);
try {
await atomicSwapDir(stagedDir, targetDir);
} catch (e) {
throw new BmadModuleError(EXIT.COMMIT_FAILURE, `failed to swap into ${targetDir}: ${e.message}`);
}
// Manifest rewrites: remove old rows for this code, then re-append.
await removeSkillManifestRows(bmadDir, code);
await removeFilesManifestRows(bmadDir, code);
await addModuleToManifest(bmadDir, code, {
version: manifest.bmad.moduleVersion || manifest.version,
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
sha: materialized.sha,
ref: opts.ref || entry.ref,
channel: opts.channel || (opts.ref ? 'pinned' : entry.channel || (descriptor.kind === 'git' ? 'next' : null)),
rawSource: descriptor.rawInput,
moduleName: manifest.name,
});
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
// the edge case where rm-then-mkdir leaves stale parents.)
await pruneEmptyDirs(targetDir, bmadDir);
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 ${destPaths.length} file(s)\n`);
// Re-run the same post-copy completion as install: deps, config + agent
// roster, working directories (moves if a path changed), and help catalog.
await finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides: opts.setOverrides });
// Re-distribute to the configured coding assistants: prune skills that no
// longer exist in this version, refresh the rest.
const ideResult = await distributeToIdes({ projectDir, bmadDir, prune: oldSkillIds });
if (!ideResult.skipped && !ideResult.ok) {
process.stderr.write(`[bmad-module] warning: ${ideResult.hint}\n`);
}
} finally {
await materialized.cleanup();
}
}