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

202 lines
9.9 KiB
JavaScript

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, 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';
import { distributeToIdes } from './lib/ide-sync.mjs';
import { installModuleDeps } from './lib/npm-deps.mjs';
import { regenerateCentralConfig, readModuleConfigValues, resolveSectionKey } from './lib/config-gen.mjs';
import { createModuleDirectories } from './lib/module-dirs.mjs';
import { regenerateHelpCatalog } from './lib/help-catalog.mjs';
// Run the install verb. `opts` shape:
// { source, ref, sha, channel, dryRun, projectDir }
// Returns nothing; throws BmadModuleError on failure.
export async function runInstall(opts) {
const projectDir = opts.projectDir || process.cwd();
// §1. Resolve _bmad/ first — fail fast if BMAD is not installed.
const bmadDir = await findBmadDir(projectDir);
if (!bmadDir) {
throw new BmadModuleError(EXIT.NO_BMAD_DIR, `no _bmad/ found in ${projectDir}. Run \`bmad install\` first.`);
}
await ensureConfigDir(bmadDir);
// §2. Normalize + materialize source.
const descriptor = parseSource(opts.source);
const materialized = await materializeSource(descriptor, { ref: opts.ref || null });
try {
// §3. Read + validate plugin.json.
const manifest = await readAndValidateManifest(materialized.dir);
const code = manifest.bmad.code;
// §4. Collision check against installed manifest.
const existing = await readManifestYaml(bmadDir);
const existingEntry = existing?.modules?.find((m) => m && m.name === code);
if (existingEntry) {
const sameSource =
(existingEntry.rawSource && existingEntry.rawSource === descriptor.rawInput) ||
(existingEntry.repoUrl && descriptor.kind === 'git' && existingEntry.repoUrl === descriptor.url);
const sameSha = materialized.sha && existingEntry.sha === materialized.sha;
if (sameSource && sameSha) {
process.stdout.write(`[bmad-module] ${code} ${existingEntry.version} already installed at this sha — no-op.\n`);
return;
}
if (existingEntry.source === 'community' && sameSource) {
// Same module, different sha — user should use `update`.
throw new BmadModuleError(
EXIT.PREFIX_COLLISION,
`${code} already installed from this source at sha ${existingEntry.sha || '?'}. ` +
`Run \`bmad-module update ${code}\` to change version.`,
);
}
throw new BmadModuleError(
EXIT.PREFIX_COLLISION,
`code "${code}" already used by ${existingEntry.source} module ` +
`${existingEntry.repoUrl || existingEntry.rawSource || existingEntry.npmPackage || '(local)'}. ` +
`Module authors should pick a unique bmad.code.`,
);
}
// §5. Build install plan.
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);
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 (${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 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}`);
}
// §7. Register in manifests.
await addModuleToManifest(bmadDir, code, {
version: manifest.bmad.moduleVersion || manifest.version,
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
sha: materialized.sha,
ref: materialized.ref,
channel: opts.channel || (opts.ref ? 'pinned' : 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);
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 ${destPaths.length} file(s) to ${path.relative(projectDir, targetDir)}\n`);
// §7.5. Complete the install the way the full installer does for custom
// modules: install JS deps, generate central config + agent roster, create
// declared working directories, and rebuild the merged help catalog. All are
// non-fatal — the module is already committed to _bmad/<code>/.
await finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides: opts.setOverrides });
// §8. Distribute the module's skills to the coding assistants the user chose
// at `bmad install` time (read from _bmad/_config/manifest.yaml). This is the
// same distribution the full installer performs; without it the skills would
// sit in _bmad/ and never reach Claude Code / Cursor / Copilot / etc.
const ideResult = await distributeToIdes({ projectDir, bmadDir });
if (ideResult.skipped) {
process.stdout.write(
`[bmad-module] note: no coding assistants are configured in _bmad/_config/manifest.yaml — ` +
`skills are in _bmad/${code}/ only. Run \`bmad install\` to choose your IDEs.\n`,
);
} else if (!ideResult.ok) {
process.stderr.write(`[bmad-module] warning: ${ideResult.hint}\n`);
}
// §9. Warn about Claude-plugin-only surfaces (not distributed as skills).
const claudeOnly = [];
if (manifest.hooks) claudeOnly.push('hooks');
if (manifest.mcpServers) claudeOnly.push('mcpServers');
if (manifest.lspServers) claudeOnly.push('lspServers');
if (Array.isArray(manifest.agents) && manifest.agents.length) claudeOnly.push('agents');
if (Array.isArray(manifest.commands) && manifest.commands.length) claudeOnly.push('commands');
if (claudeOnly.length) {
process.stdout.write(
`[bmad-module] note: ${claudeOnly.join(', ')} are Claude Code plugin surfaces and were copied but ` +
`NOT auto-activated. Use Claude Code's plugin manager to wire them up.\n`,
);
}
if (manifest.bmad?.install?.postInstallSkill) {
process.stdout.write(`[bmad-module] next: run the \`${manifest.bmad.install.postInstallSkill}\` skill to finish setup.\n`);
}
} finally {
await materialized.cleanup();
}
}
// Shared post-copy completion for install and update: install JS deps, generate
// the central config + agent roster, create declared working directories, and
// rebuild the merged help catalog. Mirrors what the full installer does for a
// custom module so a skill-driven install lands the same on-disk state. Every
// step is non-fatal — the module files are already committed under _bmad/<code>/.
export async function finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides }) {
// 1. npm deps (in place — see npm-deps.mjs for the design note).
const dep = await installModuleDeps(targetDir, manifest);
if (dep.ran && dep.ok) process.stdout.write(`[bmad-module] installed npm dependencies for ${code}\n`);
else if (dep.ran && !dep.ok) process.stderr.write(`[bmad-module] warning: npm install failed for ${code}: ${dep.error}\n`);
// 2. Capture prior config (for directory move-detection on update) before regen.
const sectionKey = await resolveSectionKey(bmadDir, code);
let existingConfig = {};
try {
existingConfig = await readModuleConfigValues(bmadDir, sectionKey);
} catch {
/* no prior config — fine */
}
// 3. Central config + agent roster.
let resolved = { values: {} };
try {
resolved = await regenerateCentralConfig(bmadDir, code, { setOverrides: setOverrides || {} });
} catch (e) {
process.stderr.write(`[bmad-module] warning: config generation failed for ${code}: ${e.message}\n`);
}
// 4. Declared working directories.
try {
const dirs = await createModuleDirectories(bmadDir, code, resolved.values, existingConfig);
const made = dirs.createdDirs.length;
const moved = dirs.movedDirs.length;
if (made) process.stdout.write(`[bmad-module] created ${made} working director${made === 1 ? 'y' : 'ies'} for ${code}\n`);
if (moved) process.stdout.write(`[bmad-module] moved ${moved} working director${moved === 1 ? 'y' : 'ies'} for ${code}\n`);
} catch (e) {
process.stderr.write(`[bmad-module] warning: directory creation failed for ${code}: ${e.message}\n`);
}
// 5. Merged help catalog.
try {
await regenerateHelpCatalog(bmadDir);
} catch (e) {
process.stderr.write(`[bmad-module] warning: help catalog rebuild failed: ${e.message}\n`);
}
}