feat/bmad-module: community module manager skill created
This commit is contained in:
parent
a0a573bd0a
commit
e96f16bf31
|
|
@ -117,6 +117,29 @@ export default [
|
|||
},
|
||||
},
|
||||
|
||||
// bmad-module core skill: self-contained ESM CLI support scripts.
|
||||
// Same internal-script relaxations as tools/** and src/scripts/** above,
|
||||
// plus a few cosmetic rules. The code is reviewed and integration-tested
|
||||
// as-is (the exit-code contract relies on process.exit).
|
||||
{
|
||||
files: ['src/core-skills/bmad-module/scripts/**/*.mjs', 'src/core-skills/bmad-module/scripts/**/*.js'],
|
||||
rules: {
|
||||
'n/hashbang': 'off',
|
||||
'n/no-process-exit': 'off',
|
||||
'unicorn/no-process-exit': 'off',
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'unicorn/no-array-reduce': 'off',
|
||||
'unicorn/no-array-callback-reference': 'off',
|
||||
'unicorn/no-array-for-each': 'off',
|
||||
'unicorn/catch-error-name': 'off',
|
||||
'unicorn/switch-case-braces': 'off',
|
||||
'unicorn/explicit-length-check': 'off',
|
||||
'unicorn/prefer-string-replace-all': 'off',
|
||||
'unicorn/prefer-string-raw': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ESLint config file should not be checked for publish-related Node rules
|
||||
{
|
||||
files: ['eslint.config.mjs'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
# bmad-module
|
||||
|
||||
The core BMAD skill for installing, updating, removing, and listing community
|
||||
BMAD modules. Modules are standalone GitHub repos that conform to the BMAD
|
||||
Module Manifest Spec (see `docs/spec.md` in `bmad-marketplace`).
|
||||
|
||||
## How it fits
|
||||
|
||||
- **Authors** publish a single repo with `.claude-plugin/plugin.json` that
|
||||
works in both Claude Code's plugin marketplace and BMAD-METHOD.
|
||||
- **Users** install via this skill — no CLI required. Modules land in
|
||||
`_bmad/<bmad.code>/` alongside the official modules.
|
||||
- **BMAD-METHOD** treats community-installed modules as a new `source: 'community'`
|
||||
row in `manifest.yaml`; re-running `bmad install` preserves them (with the
|
||||
paired `manifest-generator.js` patch).
|
||||
|
||||
## Verbs
|
||||
|
||||
```
|
||||
bmad-module install <source> [--ref <r>] [--channel <c>] [--dry-run]
|
||||
bmad-module update <code|--all> [--ref <r>] [--channel <c>]
|
||||
bmad-module remove <code> [--purge]
|
||||
bmad-module list [--json]
|
||||
```
|
||||
|
||||
`<source>` accepts `owner/repo`, a full git URL, or a local path.
|
||||
|
||||
## Behavior notes
|
||||
|
||||
- **Source of truth** for what was installed is `_bmad/_config/files-manifest.csv`
|
||||
(per-file hashes) and `_bmad/_config/skill-manifest.csv` (one row per
|
||||
shipped skill). `manifest.yaml` carries the source/version/sha tuple.
|
||||
- **`update`** refuses to overwrite locally-modified files (hash mismatch
|
||||
against the recorded hash). Move overrides into `_bmad/custom/<code>/`
|
||||
and retry.
|
||||
- **`remove`** without `--purge` preserves `_bmad/custom/<code>/` so a
|
||||
re-install picks the customizations back up. `--purge` deletes them.
|
||||
- **Hooks / MCP / LSP / Claude subagents** declared in the module manifest
|
||||
are *copied* but NOT auto-activated by this skill. Use Claude Code's
|
||||
plugin manager to wire them up.
|
||||
|
||||
## Implementation
|
||||
|
||||
The skill itself is a thin verb router (`SKILL.md`). All filesystem work
|
||||
happens in `scripts/bmad-module.mjs` and the `lib/` modules, which are
|
||||
self-contained (only `yaml` and `semver` as runtime deps). They re-use no
|
||||
BMAD-METHOD internal modules — the same code runs during development inside
|
||||
`bmad-marketplace` and after the skill is PR'd into BMAD-METHOD core.
|
||||
|
||||
## Exit codes
|
||||
|
||||
See `SKILL.md` for the full table. The script's stderr always names the
|
||||
condition; the codes are stable so tooling can branch.
|
||||
|
||||
## Tests
|
||||
|
||||
Integration tests live in `tests/integration.test.sh` and run end-to-end on
|
||||
a fresh BMAD install. Fixtures for negative cases (collisions, path
|
||||
traversal, reserved codes) are under `tests/fixtures/`.
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
---
|
||||
name: bmad-module
|
||||
description: Install, update, remove, or list community BMAD modules. Use when the user says "install module <X>", "install bmad module", "update module", "remove module", "uninstall module", or "list modules".
|
||||
---
|
||||
|
||||
# bmad-module
|
||||
|
||||
Manage community BMAD modules — installable packages of skills, agents, and
|
||||
supporting assets that ship as standalone GitHub repos. Modules land in
|
||||
`_bmad/<bmad.code>/` alongside official modules and are tracked in the
|
||||
existing manifests. The same artifact is also loadable as a Claude Code
|
||||
plugin via its `.claude-plugin/plugin.json` manifest.
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
- NEVER write directly to files under `_bmad/`. All filesystem changes go
|
||||
through the Node script at `scripts/bmad-module.mjs` — it handles staging,
|
||||
atomic swaps, manifest updates, and rollback on failure.
|
||||
- HALT and report cleanly if `_bmad/` is not present in the current working
|
||||
directory (exit code 10 from the script).
|
||||
- DO NOT execute hooks, MCP server commands, or any code shipped inside the
|
||||
module during install. The install copies files; activation is a separate
|
||||
step the user opts into via Claude Code's plugin manager.
|
||||
- If the script exits non-zero, report the exit code and stderr verbatim and
|
||||
stop. Do NOT retry, do NOT try a different verb.
|
||||
|
||||
## EXECUTION
|
||||
|
||||
### Step 1 — Identify the verb
|
||||
|
||||
The user's request maps to exactly one of:
|
||||
|
||||
| Verb | Phrasing |
|
||||
|---|---|
|
||||
| `install` | "install module X", "add the X module", "set up X" |
|
||||
| `update` | "update module X", "upgrade X", "pull the latest X" |
|
||||
| `remove` | "remove module X", "uninstall X", "delete X module" |
|
||||
| `list` | "list modules", "what modules are installed", "show installed modules" |
|
||||
|
||||
If the verb is ambiguous (e.g. the user says "manage modules"), ASK which
|
||||
verb they want before continuing.
|
||||
|
||||
### Step 2 — Parse the args
|
||||
|
||||
- **install:** the user supplies `<source>` — `owner/repo` (GitHub short),
|
||||
a full git URL (`https://…` or `git@…`), or a local path. Optional flags:
|
||||
`--ref <branch-tag-or-sha>`, `--channel <stable|next|pinned>`, `--dry-run`.
|
||||
- **update:** the user supplies `<code>` (the `_bmad/<code>/` folder name)
|
||||
or asks for "all"; in that case use `--all`. Optional `--ref`.
|
||||
- **remove:** the user supplies `<code>`. Use `--purge` only if they
|
||||
explicitly say "also remove customizations" or "purge".
|
||||
- **list:** no args. Use `--json` if the user asks for machine-readable.
|
||||
|
||||
If anything is missing or ambiguous, ASK before invoking.
|
||||
|
||||
### Step 3 — Confirm before destructive verbs
|
||||
|
||||
For `install`, `update`, and `remove`, summarize what will happen and confirm
|
||||
once with the user:
|
||||
|
||||
> About to **install** `acme/acme-devlog` (will create `_bmad/devlog/`).
|
||||
> Proceed? [y/N]
|
||||
|
||||
For `install` you may run a dry-run first (`--dry-run`) and show the file
|
||||
plan; that counts as the summary — still confirm before the real run.
|
||||
|
||||
Skip the confirmation step only if the user has already pre-authorized in
|
||||
this turn (e.g. "go ahead and install acme-md-lint without asking").
|
||||
|
||||
### Step 4 — Invoke the Node script
|
||||
|
||||
Run from the project root (the dir containing `_bmad/`):
|
||||
|
||||
```
|
||||
node <skill-dir>/scripts/bmad-module.mjs <verb> [args...]
|
||||
```
|
||||
|
||||
`<skill-dir>` is wherever the skill files live in the current install. After
|
||||
this skill ships into BMAD-METHOD that's `_bmad/core/skills/bmad-module/`;
|
||||
during development it's this repo's `src/core-skills/bmad-module/`.
|
||||
|
||||
Stream stdout and stderr verbatim. Do NOT silence or rewrite them — the
|
||||
script's own messages are designed for end-user consumption.
|
||||
|
||||
### Step 5 — Report
|
||||
|
||||
On exit 0: paraphrase the script's final line(s) and note any next-step hint
|
||||
(e.g. "next: run the `bmad-devlog-setup` skill to finish setup").
|
||||
|
||||
On non-zero exit: print the exit code, the stderr message, and stop. Do not
|
||||
suggest workarounds beyond what the script's message itself suggests
|
||||
(e.g. "use `update` instead", "move changes into `_bmad/custom/<code>/`").
|
||||
|
||||
## EXIT CODES
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | success |
|
||||
| 2 | usage error (bad/missing args or flags) |
|
||||
| 10 | no `_bmad/` directory in project — run `bmad install` first |
|
||||
| 20 | missing or invalid `.claude-plugin/plugin.json` in source |
|
||||
| 21 | module uses a reserved `bmad.code` |
|
||||
| 30 | prefix collision with an already-installed module |
|
||||
| 40 | module would write outside its `_bmad/<code>/` root |
|
||||
| 50 | filesystem commit (atomic swap) failed |
|
||||
| 60 | network or `git clone` failed |
|
||||
| 70 | path traversal detected in manifest |
|
||||
| 80 | update aborted: locally modified files would be overwritten |
|
||||
| 90 | no such installed module (for `update`/`remove`) |
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
User: "Install the devlog module from acme/acme-devlog"
|
||||
→ Confirm, then run:
|
||||
`node …/scripts/bmad-module.mjs install acme/acme-devlog`
|
||||
|
||||
User: "Try installing examples/minimal/acme-md-lint first as a dry-run"
|
||||
→ Run with `--dry-run`, show the plan, then ask whether to proceed for real.
|
||||
|
||||
User: "What modules do I have installed?"
|
||||
→ Run `… list`. No confirmation needed (read-only).
|
||||
|
||||
User: "Update the devlog module to v0.5.0"
|
||||
→ Confirm, then run `… update devlog --ref v0.5.0`.
|
||||
|
||||
User: "Remove the mdlint module and wipe its customizations too"
|
||||
→ Confirm, then run `… remove mdlint --purge`.
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
#!/usr/bin/env node
|
||||
// bmad-module — verb dispatcher.
|
||||
//
|
||||
// Usage:
|
||||
// node bmad-module.mjs install <source> [--ref <r>] [--channel <c>] [--dry-run] [--project-dir <p>]
|
||||
// node bmad-module.mjs update <code|--all> [--ref <r>] [--channel <c>] [--project-dir <p>]
|
||||
// node bmad-module.mjs remove <code> [--purge] [--project-dir <p>]
|
||||
// node bmad-module.mjs list [--json] [--project-dir <p>]
|
||||
//
|
||||
// Exit codes — see SKILL.md / lib/exit.js. 0 = ok; everything ≥10 = structured error.
|
||||
|
||||
import { runInstall } from './install.mjs';
|
||||
import { runUpdate } from './update.mjs';
|
||||
import { runRemove } from './remove.mjs';
|
||||
import { runList } from './list.mjs';
|
||||
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
||||
|
||||
const VERBS = new Set(['install', 'update', 'remove', 'list']);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [], flags: {} };
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
// boolean flags
|
||||
if (['dry-run', 'purge', 'all', 'json'].includes(key)) {
|
||||
out.flags[key] = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// value flags
|
||||
const val = argv[i + 1];
|
||||
if (val === undefined || val.startsWith('--')) {
|
||||
throw new BmadModuleError(EXIT.USAGE, `flag --${key} requires a value`);
|
||||
}
|
||||
out.flags[key] = val;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
out._.push(a);
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||
printUsage();
|
||||
process.exit(EXIT.USAGE);
|
||||
}
|
||||
const verb = argv[0];
|
||||
if (!VERBS.has(verb)) {
|
||||
process.stderr.write(`[bmad-module] unknown verb "${verb}". Valid: install, update, remove, list.\n`);
|
||||
process.exit(EXIT.USAGE);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseArgs(argv.slice(1));
|
||||
} catch (e) {
|
||||
if (e instanceof BmadModuleError) {
|
||||
process.stderr.write(`[bmad-module] ${e.message}\n`);
|
||||
process.exit(e.code);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const projectDir = parsed.flags['project-dir'] || process.cwd();
|
||||
|
||||
try {
|
||||
switch (verb) {
|
||||
case 'install':
|
||||
await runInstall({
|
||||
source: parsed._[0],
|
||||
ref: parsed.flags['ref'] || null,
|
||||
channel: parsed.flags['channel'] || null,
|
||||
dryRun: !!parsed.flags['dry-run'],
|
||||
projectDir,
|
||||
});
|
||||
break;
|
||||
case 'update':
|
||||
await runUpdate({
|
||||
code: parsed._[0] || null,
|
||||
all: !!parsed.flags['all'],
|
||||
ref: parsed.flags['ref'] || null,
|
||||
channel: parsed.flags['channel'] || null,
|
||||
projectDir,
|
||||
});
|
||||
break;
|
||||
case 'remove':
|
||||
await runRemove({
|
||||
code: parsed._[0],
|
||||
purge: !!parsed.flags['purge'],
|
||||
projectDir,
|
||||
});
|
||||
break;
|
||||
case 'list':
|
||||
await runList({
|
||||
json: !!parsed.flags['json'],
|
||||
projectDir,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BmadModuleError) {
|
||||
process.stderr.write(`[bmad-module] ${e.message}\n`);
|
||||
process.exit(e.code);
|
||||
}
|
||||
process.stderr.write(`[bmad-module] unexpected error: ${e.stack || e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stderr.write(`bmad-module — install, update, remove, or list BMAD community modules.
|
||||
|
||||
USAGE
|
||||
bmad-module install <source> [--ref <ref>] [--channel <c>] [--dry-run]
|
||||
bmad-module update <code|--all> [--ref <ref>] [--channel <c>]
|
||||
bmad-module remove <code> [--purge]
|
||||
bmad-module list [--json]
|
||||
|
||||
GLOBAL FLAGS
|
||||
--project-dir <path> Project root containing _bmad/ (default: cwd)
|
||||
|
||||
EXAMPLES
|
||||
bmad-module install acme/acme-devlog
|
||||
bmad-module install ./examples/minimal/acme-md-lint
|
||||
bmad-module install https://github.com/acme/acme-devlog --ref v0.4.0
|
||||
bmad-module list
|
||||
bmad-module update devlog
|
||||
bmad-module remove mdlint --purge
|
||||
|
||||
EXIT CODES
|
||||
0 success
|
||||
2 usage error
|
||||
10 no _bmad/ in project
|
||||
20 missing or invalid plugin.json
|
||||
21 reserved bmad.code
|
||||
30 prefix collision with existing module
|
||||
40 file overlap outside the module root
|
||||
50 filesystem commit failed
|
||||
60 network/git clone failed
|
||||
70 path traversal in manifest
|
||||
80 update aborted: locally modified files
|
||||
90 no such installed module
|
||||
`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
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 { readManifestYaml, addModuleToManifest, appendSkillManifestRows, appendFilesManifestRows } from './lib/manifest-ops.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 (spec §7.1).`,
|
||||
);
|
||||
}
|
||||
|
||||
// §5. Build install plan.
|
||||
validateDeclaredPaths(materialized.dir, manifest);
|
||||
const userIgnores = await readUserIgnores(materialized.dir, manifest);
|
||||
const matchIgnore = buildIgnoreMatcher(userIgnores);
|
||||
const copyList = await buildCopyList(materialized.dir, matchIgnore);
|
||||
|
||||
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`);
|
||||
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));
|
||||
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 skillDirs = Array.isArray(manifest.skills) ? manifest.skills.map((s) => normalizeSkillDirRelToCode(s)) : [];
|
||||
await appendSkillManifestRows(bmadDir, code, skillDirs);
|
||||
await appendFilesManifestRows(bmadDir, code, copyList);
|
||||
|
||||
// §8. Warn about Claude-only surfaces.
|
||||
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');
|
||||
|
||||
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`);
|
||||
if (claudeOnly.length) {
|
||||
process.stdout.write(
|
||||
`[bmad-module] note: ${claudeOnly.join(', ')} 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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Locate the _bmad/ directory for a project. Matches BMAD-METHOD's
|
||||
// Installer.findBmadDir semantics exactly: no upward search — always
|
||||
// `<projectDir>/_bmad`. Returns absolute path or null if absent.
|
||||
export async function findBmadDir(projectDir) {
|
||||
const candidate = path.join(path.resolve(projectDir), '_bmad');
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
return stat.isDirectory() ? candidate : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a writable _config dir, ensuring it exists. Modules always
|
||||
// register into <bmadDir>/_config/.
|
||||
export async function ensureConfigDir(bmadDir) {
|
||||
const cfgDir = path.join(bmadDir, '_config');
|
||||
await fs.mkdir(cfgDir, { recursive: true });
|
||||
return cfgDir;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Exit codes for bmad-module verbs. Documented in SKILL.md and README.md so
|
||||
// callers (Claude, CI, humans) can branch on them programmatically.
|
||||
export const EXIT = {
|
||||
OK: 0,
|
||||
USAGE: 2,
|
||||
NO_BMAD_DIR: 10,
|
||||
BAD_MANIFEST: 20,
|
||||
RESERVED_PREFIX: 21,
|
||||
PREFIX_COLLISION: 30,
|
||||
FILE_OVERLAP: 40,
|
||||
COMMIT_FAILURE: 50,
|
||||
NETWORK_FAILURE: 60,
|
||||
PATH_TRAVERSAL: 70,
|
||||
MODIFIED_FILES: 80,
|
||||
NOT_INSTALLED: 90,
|
||||
};
|
||||
|
||||
export class BmadModuleError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function die(code, message) {
|
||||
process.stderr.write(`[bmad-module] ${message}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
// Parse YAML frontmatter from a markdown string. Returns the parsed object,
|
||||
// or null if no frontmatter block is present / it failed to parse.
|
||||
export function parseFrontmatter(content) {
|
||||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return parseYaml(m[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Resolve `declared` as a path inside `rootAbs`. Rejects absolute paths,
|
||||
// `..` segments, and symlink escapes. Returns the absolute path or null.
|
||||
export function safePathInsideRoot(rootAbs, declared) {
|
||||
if (typeof declared !== 'string' || declared === '') return null;
|
||||
if (path.isAbsolute(declared)) return null;
|
||||
if (declared.split(/[\\/]/).includes('..')) return null;
|
||||
const resolved = path.resolve(rootAbs, declared);
|
||||
if (resolved !== rootAbs && !resolved.startsWith(rootAbs + path.sep)) return null;
|
||||
if (fs.existsSync(resolved)) {
|
||||
try {
|
||||
const real = fs.realpathSync(resolved);
|
||||
const realRoot = fs.realpathSync(rootAbs);
|
||||
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// SHA-256 of a file's bytes, returned as a hex string. Returns null on
|
||||
// I/O failure — callers should treat a null hash as "file unreadable".
|
||||
export async function sha256File(filePath) {
|
||||
try {
|
||||
const buf = await fsp.readFile(filePath);
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively copy `srcDir` into `destDir`, creating destDir first.
|
||||
// Returns an array of relative file paths (POSIX-style) actually copied.
|
||||
// Skips entries matched by `shouldSkip(relPath)` which receives a POSIX
|
||||
// path relative to srcDir.
|
||||
export async function copyDir(srcDir, destDir, shouldSkip = () => false) {
|
||||
await fsp.mkdir(destDir, { recursive: true });
|
||||
const copied = [];
|
||||
async function walk(rel) {
|
||||
const absSrc = path.join(srcDir, rel);
|
||||
const entries = await fsp.readdir(absSrc, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (shouldSkip(childRel)) continue;
|
||||
const childSrc = path.join(srcDir, childRel);
|
||||
const childDest = path.join(destDir, childRel);
|
||||
if (entry.isDirectory()) {
|
||||
await fsp.mkdir(childDest, { recursive: true });
|
||||
await walk(childRel);
|
||||
} else if (entry.isFile()) {
|
||||
await fsp.mkdir(path.dirname(childDest), { recursive: true });
|
||||
await fsp.copyFile(childSrc, childDest);
|
||||
copied.push(childRel);
|
||||
}
|
||||
// Symlinks are skipped — install staging never preserves them.
|
||||
}
|
||||
}
|
||||
await walk('');
|
||||
return copied;
|
||||
}
|
||||
|
||||
// Atomically replace `targetDir` with `stagedDir` contents. If `targetDir`
|
||||
// exists it's removed first; then `stagedDir` is renamed in. Best effort —
|
||||
// not truly atomic across filesystems but minimizes the inconsistent window.
|
||||
export async function atomicSwapDir(stagedDir, targetDir) {
|
||||
await fsp.rm(targetDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(path.dirname(targetDir), { recursive: true });
|
||||
await fsp.rename(stagedDir, targetDir);
|
||||
}
|
||||
|
||||
// Remove empty parent directories upward until a non-empty one is hit,
|
||||
// stopping at `stopAt` (exclusive). Used after file deletion.
|
||||
export async function pruneEmptyDirs(startDir, stopAt) {
|
||||
let dir = path.resolve(startDir);
|
||||
const stop = path.resolve(stopAt);
|
||||
while (dir !== stop && dir.startsWith(stop + path.sep)) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (entries.length > 0) return;
|
||||
await fsp.rmdir(dir);
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { EXIT, BmadModuleError } from './exit.mjs';
|
||||
import { safePathInsideRoot } from './fs-safe.mjs';
|
||||
|
||||
// Default ignore patterns always applied on top of user ignores.
|
||||
// `.claude-plugin/` is intentionally NOT ignored — the manifest is needed
|
||||
// post-install for `update` and `list` to re-resolve the module.
|
||||
const DEFAULT_IGNORES = ['.git/**', '.git', 'node_modules/**', 'node_modules', '.bmadignore', '.DS_Store', '**/.DS_Store'];
|
||||
|
||||
// Compile one ignore pattern (gitignore-lite: supports `*`, `**`, `?`, and
|
||||
// trailing `/`; no negation, no leading `/` anchoring) into a RegExp matched
|
||||
// against a POSIX-style relative path.
|
||||
function compilePattern(pattern) {
|
||||
let p = pattern.trim();
|
||||
if (!p || p.startsWith('#')) return null;
|
||||
// Treat trailing slash as "directory" — match the dir and its contents.
|
||||
const dirOnly = p.endsWith('/');
|
||||
if (dirOnly) p = p.slice(0, -1);
|
||||
// Anchor by default (gitignore semantics): if no slash in pattern, match
|
||||
// basename anywhere; else anchor to root.
|
||||
const anchored = p.includes('/');
|
||||
let body = p
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*\*/g, '\uFFFF')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/\uFFFF/g, '.*')
|
||||
.replace(/\?/g, '[^/]');
|
||||
const re = anchored ? new RegExp(`^${body}(/.*)?$`) : new RegExp(`(^|/)${body}(/.*)?$`);
|
||||
return re;
|
||||
}
|
||||
|
||||
export function buildIgnoreMatcher(userPatterns) {
|
||||
const patterns = [...DEFAULT_IGNORES, ...(userPatterns || [])];
|
||||
const compiled = patterns.map(compilePattern).filter(Boolean);
|
||||
return (relPath) => {
|
||||
const posix = relPath.replaceAll('\\', '/');
|
||||
return compiled.some((re) => re.test(posix));
|
||||
};
|
||||
}
|
||||
|
||||
// Load user ignore patterns from manifest first, then .bmadignore. Spec §15
|
||||
// disallows both at once — readUserIgnores enforces it.
|
||||
export async function readUserIgnores(sourceDir, manifest) {
|
||||
const fromManifest = manifest?.bmad?.install?.ignore;
|
||||
const ignoreFilePath = path.join(sourceDir, '.bmadignore');
|
||||
let fromFile = null;
|
||||
try {
|
||||
const buf = await fs.readFile(ignoreFilePath, 'utf8');
|
||||
fromFile = buf
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
/* no .bmadignore — fine */
|
||||
}
|
||||
if (Array.isArray(fromManifest) && fromFile) {
|
||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `both .bmadignore and bmad.install.ignore are present — pick one`);
|
||||
}
|
||||
if (Array.isArray(fromManifest)) return fromManifest;
|
||||
if (fromFile) return fromFile;
|
||||
return [];
|
||||
}
|
||||
|
||||
// Validate that every declared path in the manifest exists inside the source
|
||||
// tree and resolves safely (no traversal, no symlink escape). Declared paths
|
||||
// double as documentation; they do NOT drive the copy list, but if they are
|
||||
// broken the install would land a non-functional module.
|
||||
export function validateDeclaredPaths(sourceDir, manifest) {
|
||||
const declared = [];
|
||||
const arr = (key, val) => Array.isArray(val) && val.forEach((v) => declared.push({ key, val: v }));
|
||||
const str = (key, val) => typeof val === 'string' && declared.push({ key, val });
|
||||
arr('skills', manifest.skills);
|
||||
arr('agents', manifest.agents);
|
||||
arr('commands', manifest.commands);
|
||||
str('hooks', manifest.hooks);
|
||||
if (typeof manifest.mcpServers === 'string') str('mcpServers', manifest.mcpServers);
|
||||
str('lspServers', manifest.lspServers);
|
||||
str('settings', manifest.settings);
|
||||
str('bmad.moduleDefinition', manifest.bmad?.moduleDefinition);
|
||||
str('bmad.moduleHelpCsv', manifest.bmad?.moduleHelpCsv);
|
||||
arr('bmad.customize.schemas', manifest.bmad?.customize?.schemas);
|
||||
if (manifest.bmad?.docs) {
|
||||
str('bmad.docs.readme', manifest.bmad.docs.readme);
|
||||
str('bmad.docs.changelog', manifest.bmad.docs.changelog);
|
||||
if (typeof manifest.bmad.docs.homepage === 'string' && !/^https?:/.test(manifest.bmad.docs.homepage)) {
|
||||
str('bmad.docs.homepage', manifest.bmad.docs.homepage);
|
||||
}
|
||||
}
|
||||
for (const { key, val } of declared) {
|
||||
const safe = safePathInsideRoot(sourceDir, val);
|
||||
if (safe === null) {
|
||||
throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `manifest ${key}: "${val}" escapes module root`);
|
||||
}
|
||||
}
|
||||
// Existence is enforced by the validator pre-publish; at install time we
|
||||
// surface missing declared paths as PATH_TRAVERSAL-class problems too, so
|
||||
// the user gets a single failure mode for "manifest doesn't match tree".
|
||||
return declared;
|
||||
}
|
||||
|
||||
// Walk the module source tree and return the list of POSIX-relative file
|
||||
// paths that should be copied into `_bmad/<code>/`. Honors ignore patterns
|
||||
// and skips symlinks (they're not preserved in the install).
|
||||
export async function buildCopyList(sourceDir, ignoreMatch) {
|
||||
const out = [];
|
||||
async function walk(rel) {
|
||||
const absDir = path.join(sourceDir, rel);
|
||||
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (ignoreMatch(childRel)) continue;
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) await walk(childRel);
|
||||
else if (entry.isFile()) out.push(childRel);
|
||||
}
|
||||
}
|
||||
await walk('');
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { parseFrontmatter } from './frontmatter.mjs';
|
||||
import { sha256File } from './fs-safe.mjs';
|
||||
import { EXIT, BmadModuleError } from './exit.mjs';
|
||||
|
||||
// =============================================================================
|
||||
// manifest.yaml — read/write/addModule/removeModule
|
||||
// =============================================================================
|
||||
|
||||
const MANIFEST_YAML_OPTS = { indent: 2, lineWidth: 0, sortKeys: false };
|
||||
|
||||
export async function readManifestYaml(bmadDir) {
|
||||
const yamlPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
||||
try {
|
||||
const content = await fs.readFile(yamlPath, 'utf8');
|
||||
return parseYaml(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifestYaml(bmadDir, data) {
|
||||
const yamlPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
||||
await fs.mkdir(path.dirname(yamlPath), { recursive: true });
|
||||
const yamlContent = stringifyYaml(structuredClone(data), MANIFEST_YAML_OPTS);
|
||||
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
||||
await fs.writeFile(yamlPath, content, 'utf8');
|
||||
}
|
||||
|
||||
// Add or update a community module entry in manifest.yaml. Mirrors BMAD-METHOD's
|
||||
// Manifest.addModule() entry shape exactly (source: 'community') so the
|
||||
// upstream installer recognizes community rows during regeneration.
|
||||
export async function addModuleToManifest(bmadDir, code, options) {
|
||||
let manifest = await readManifestYaml(bmadDir);
|
||||
if (!manifest) {
|
||||
throw new BmadModuleError(EXIT.NO_BMAD_DIR, `manifest.yaml not found — _bmad/_config/ missing. Run \`bmad install\` first.`);
|
||||
}
|
||||
if (!Array.isArray(manifest.modules)) manifest.modules = [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const idx = manifest.modules.findIndex((m) => m && m.name === code);
|
||||
if (idx === -1) {
|
||||
const entry = {
|
||||
name: code,
|
||||
version: options.version || null,
|
||||
installDate: now,
|
||||
lastUpdated: now,
|
||||
source: 'community',
|
||||
npmPackage: null,
|
||||
repoUrl: options.repoUrl || null,
|
||||
};
|
||||
if (options.channel) entry.channel = options.channel;
|
||||
if (options.sha) entry.sha = options.sha;
|
||||
if (options.ref) entry.ref = options.ref;
|
||||
if (options.rawSource) entry.rawSource = options.rawSource;
|
||||
if (options.moduleName) entry.moduleName = options.moduleName;
|
||||
manifest.modules.push(entry);
|
||||
} else {
|
||||
const existing = manifest.modules[idx];
|
||||
manifest.modules[idx] = {
|
||||
...existing,
|
||||
version: options.version ?? existing.version,
|
||||
source: 'community',
|
||||
repoUrl: options.repoUrl ?? existing.repoUrl,
|
||||
channel: options.channel ?? existing.channel,
|
||||
sha: options.sha ?? existing.sha,
|
||||
ref: options.ref ?? existing.ref,
|
||||
rawSource: options.rawSource ?? existing.rawSource,
|
||||
moduleName: options.moduleName ?? existing.moduleName,
|
||||
lastUpdated: now,
|
||||
};
|
||||
}
|
||||
|
||||
await writeManifestYaml(bmadDir, manifest);
|
||||
}
|
||||
|
||||
export async function removeModuleFromManifest(bmadDir, code) {
|
||||
const manifest = await readManifestYaml(bmadDir);
|
||||
if (!manifest || !Array.isArray(manifest.modules)) return false;
|
||||
const before = manifest.modules.length;
|
||||
manifest.modules = manifest.modules.filter((m) => !(m && m.name === code));
|
||||
if (manifest.modules.length === before) return false;
|
||||
await writeManifestYaml(bmadDir, manifest);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function listModuleEntries(bmadDir) {
|
||||
const manifest = await readManifestYaml(bmadDir);
|
||||
if (!manifest || !Array.isArray(manifest.modules)) return [];
|
||||
return manifest.modules.filter((m) => m && m.source === 'community');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSV helpers — used by both skill-manifest.csv and files-manifest.csv
|
||||
// =============================================================================
|
||||
|
||||
function escapeCsv(value) {
|
||||
return `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
// Tiny CSV parser sufficient for the shapes BMAD-METHOD writes: header line +
|
||||
// records with `"…"` fields, quotes escaped as `""`. No commas-in-fields
|
||||
// outside quotes. Returns array of arrays.
|
||||
function parseCsv(text) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let i = 0;
|
||||
let inQuotes = false;
|
||||
while (i < text.length) {
|
||||
const c = text[i];
|
||||
if (inQuotes) {
|
||||
if (c === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += c;
|
||||
i++;
|
||||
} else {
|
||||
if (c === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ',') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '\n' || c === '\r') {
|
||||
if (field !== '' || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
row = [];
|
||||
field = '';
|
||||
if (c === '\r' && text[i + 1] === '\n') i += 2;
|
||||
else i++;
|
||||
continue;
|
||||
}
|
||||
field += c;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (field !== '' || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function readCsvRows(filePath) {
|
||||
try {
|
||||
const text = await fs.readFile(filePath, 'utf8');
|
||||
return parseCsv(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rowsToCsv(header, rows) {
|
||||
let csv = header.join(',') + '\n';
|
||||
for (const r of rows) {
|
||||
csv += r.map(escapeCsv).join(',') + '\n';
|
||||
}
|
||||
return csv;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// skill-manifest.csv — header: canonicalId,name,description,module,path
|
||||
// =============================================================================
|
||||
|
||||
const SKILL_HEADER = ['canonicalId', 'name', 'description', 'module', 'path'];
|
||||
|
||||
// Append rows for a module's skills, parsed from each SKILL.md's frontmatter.
|
||||
// `skillDirs` is an array of POSIX-relative dirs inside `_bmad/<code>/` (e.g.
|
||||
// `["bmad-devlog-write", "bmad-devlog-summarize"]`).
|
||||
export async function appendSkillManifestRows(bmadDir, code, skillDirs) {
|
||||
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||
const existingRaw = await readCsvRows(csvPath);
|
||||
const rows = existingRaw && existingRaw.length > 0 ? existingRaw.slice(1) : [];
|
||||
|
||||
for (const skillRel of skillDirs) {
|
||||
const skillMdPath = path.join(bmadDir, code, skillRel, 'SKILL.md');
|
||||
let canonicalId = path.basename(skillRel);
|
||||
let name = canonicalId;
|
||||
let description = '';
|
||||
try {
|
||||
const md = await fs.readFile(skillMdPath, 'utf8');
|
||||
const fm = parseFrontmatter(md);
|
||||
if (fm) {
|
||||
if (typeof fm.name === 'string') {
|
||||
canonicalId = fm.name;
|
||||
name = fm.name;
|
||||
}
|
||||
if (typeof fm.description === 'string') {
|
||||
description = fm.description.replaceAll(/\s+/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* SKILL.md unreadable — degrade gracefully with basename */
|
||||
}
|
||||
rows.push([canonicalId, name, description, code, `_bmad/${code}/${skillRel}/SKILL.md`]);
|
||||
}
|
||||
|
||||
// Sort by (module, canonicalId) for stable diffs. Don't sort the header.
|
||||
rows.sort((a, b) => {
|
||||
if (a[3] !== b[3]) return a[3].localeCompare(b[3]);
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
await fs.mkdir(path.dirname(csvPath), { recursive: true });
|
||||
await fs.writeFile(csvPath, rowsToCsv(SKILL_HEADER, rows), 'utf8');
|
||||
}
|
||||
|
||||
export async function removeSkillManifestRows(bmadDir, code) {
|
||||
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||
const existingRaw = await readCsvRows(csvPath);
|
||||
if (!existingRaw || existingRaw.length < 1) return;
|
||||
const rows = existingRaw.slice(1).filter((r) => r[3] !== code);
|
||||
await fs.writeFile(csvPath, rowsToCsv(SKILL_HEADER, rows), 'utf8');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// files-manifest.csv — header: type,name,module,path,hash
|
||||
// =============================================================================
|
||||
|
||||
const FILES_HEADER = ['type', 'name', 'module', 'path', 'hash'];
|
||||
|
||||
// Append rows for every file copied during install. `copiedRelPaths` is the
|
||||
// POSIX-relative path list returned by buildCopyList, paths relative to the
|
||||
// staged module root (which == _bmad/<code>/ after commit).
|
||||
export async function appendFilesManifestRows(bmadDir, code, copiedRelPaths) {
|
||||
const csvPath = path.join(bmadDir, '_config', 'files-manifest.csv');
|
||||
const existingRaw = await readCsvRows(csvPath);
|
||||
const rows = existingRaw && existingRaw.length > 0 ? existingRaw.slice(1) : [];
|
||||
|
||||
const newRows = [];
|
||||
for (const rel of copiedRelPaths) {
|
||||
const absPath = path.join(bmadDir, code, rel);
|
||||
const ext = path.extname(rel).slice(1).toLowerCase();
|
||||
const base = path.basename(rel, path.extname(rel));
|
||||
const hash = await sha256File(absPath);
|
||||
newRows.push([ext || 'file', base, code, `${code}/${rel}`, hash || '']);
|
||||
}
|
||||
|
||||
const merged = [...rows, ...newRows];
|
||||
merged.sort((a, b) => {
|
||||
if (a[2] !== b[2]) return a[2].localeCompare(b[2]);
|
||||
if (a[0] !== b[0]) return a[0].localeCompare(b[0]);
|
||||
return a[1].localeCompare(b[1]);
|
||||
});
|
||||
|
||||
await fs.mkdir(path.dirname(csvPath), { recursive: true });
|
||||
await fs.writeFile(csvPath, rowsToCsv(FILES_HEADER, merged), 'utf8');
|
||||
}
|
||||
|
||||
// Return the existing rows for this module code as { path, hash } pairs.
|
||||
// Used by update to diff old-vs-new and by remove to know what to delete.
|
||||
export async function readFileEntriesForModule(bmadDir, code) {
|
||||
const csvPath = path.join(bmadDir, '_config', 'files-manifest.csv');
|
||||
const rows = await readCsvRows(csvPath);
|
||||
if (!rows || rows.length < 2) return [];
|
||||
return rows
|
||||
.slice(1)
|
||||
.filter((r) => r[2] === code)
|
||||
.map((r) => ({ type: r[0], name: r[1], module: r[2], path: r[3], hash: r[4] }));
|
||||
}
|
||||
|
||||
export async function removeFilesManifestRows(bmadDir, code) {
|
||||
const csvPath = path.join(bmadDir, '_config', 'files-manifest.csv');
|
||||
const rows = await readCsvRows(csvPath);
|
||||
if (!rows || rows.length < 1) return;
|
||||
const kept = rows.slice(1).filter((r) => r[2] !== code);
|
||||
await fs.writeFile(csvPath, rowsToCsv(FILES_HEADER, kept), 'utf8');
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { EXIT, BmadModuleError } from './exit.mjs';
|
||||
|
||||
// Reserved bmad.code values — must match docs/spec.md §7.1 and the
|
||||
// validator's RESERVED_CODES set. Single source of truth for the runtime.
|
||||
export const RESERVED_CODES = new Set([
|
||||
'core',
|
||||
'bmm',
|
||||
'bmb',
|
||||
'cis',
|
||||
'gds',
|
||||
'tea',
|
||||
'wds',
|
||||
'automator',
|
||||
'_config',
|
||||
'_memory',
|
||||
'custom',
|
||||
'agents',
|
||||
'hooks',
|
||||
'config',
|
||||
'commands',
|
||||
'skills',
|
||||
]);
|
||||
|
||||
export const CODE_REGEX = /^[a-z][a-z0-9-]{1,31}$/;
|
||||
export const NAME_REGEX = /^[a-z][a-z0-9-]+$/;
|
||||
|
||||
// Read and install-time-validate a module manifest. Install-time checks are
|
||||
// intentionally narrower than the author validator (scripts/validate-module.mjs)
|
||||
// — we only block things that would corrupt _bmad/ or cause data loss.
|
||||
export async function readAndValidateManifest(sourceDir) {
|
||||
const manifestPath = path.join(sourceDir, '.claude-plugin', 'plugin.json');
|
||||
let raw;
|
||||
try {
|
||||
raw = await fs.readFile(manifestPath, 'utf8');
|
||||
} catch {
|
||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `missing .claude-plugin/plugin.json at ${sourceDir}`);
|
||||
}
|
||||
let m;
|
||||
try {
|
||||
m = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json failed to parse: ${e.message}`);
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
if (typeof m.name !== 'string') missing.push('name');
|
||||
if (typeof m.version !== 'string') missing.push('version');
|
||||
if (typeof m.description !== 'string') missing.push('description');
|
||||
if (!m.bmad || typeof m.bmad !== 'object') {
|
||||
missing.push('bmad');
|
||||
} else {
|
||||
if (typeof m.bmad.specVersion !== 'string') missing.push('bmad.specVersion');
|
||||
if (typeof m.bmad.code !== 'string') missing.push('bmad.code');
|
||||
if (typeof m.bmad.compatibility?.bmadMethod !== 'string') missing.push('bmad.compatibility.bmadMethod');
|
||||
}
|
||||
if (missing.length) {
|
||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json missing required fields: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!NAME_REGEX.test(m.name) || m.name.length < 3 || m.name.length > 64) {
|
||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json#name "${m.name}" must match ${NAME_REGEX} and be 3–64 chars`);
|
||||
}
|
||||
if (!semver.valid(m.version)) {
|
||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json#version "${m.version}" is not valid semver`);
|
||||
}
|
||||
if (!CODE_REGEX.test(m.bmad.code)) {
|
||||
throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json#bmad.code "${m.bmad.code}" must match ${CODE_REGEX}`);
|
||||
}
|
||||
if (RESERVED_CODES.has(m.bmad.code)) {
|
||||
throw new BmadModuleError(EXIT.RESERVED_PREFIX, `plugin.json#bmad.code "${m.bmad.code}" is reserved (spec §7.1)`);
|
||||
}
|
||||
if (!semver.validRange(m.bmad.compatibility.bmadMethod)) {
|
||||
throw new BmadModuleError(
|
||||
EXIT.BAD_MANIFEST,
|
||||
`plugin.json#bmad.compatibility.bmadMethod "${m.bmad.compatibility.bmadMethod}" is not a valid semver range`,
|
||||
);
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { copyDir } from './fs-safe.mjs';
|
||||
import { EXIT, BmadModuleError } from './exit.mjs';
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
const GH_SHORT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
// Normalize a `<source>` argument from the CLI into a descriptor:
|
||||
// { kind: 'local' | 'git', path?, url?, displayName, rawInput }
|
||||
// Accepts:
|
||||
// - owner/repo → GitHub HTTPS
|
||||
// - https://… or git@… URL → as given
|
||||
// - file://path → local
|
||||
// - relative or absolute path → local (if it exists on disk)
|
||||
export function parseSource(input) {
|
||||
if (typeof input !== 'string' || !input.trim()) {
|
||||
throw new BmadModuleError(EXIT.USAGE, `source is required`);
|
||||
}
|
||||
const raw = input.trim();
|
||||
|
||||
if (raw.startsWith('file://')) {
|
||||
const p = decodeURI(raw.slice('file://'.length));
|
||||
return { kind: 'local', path: path.resolve(p), displayName: p, rawInput: raw };
|
||||
}
|
||||
|
||||
if (
|
||||
raw.startsWith('https://') ||
|
||||
raw.startsWith('http://') ||
|
||||
raw.startsWith('git@') ||
|
||||
raw.startsWith('ssh://') ||
|
||||
raw.startsWith('git://')
|
||||
) {
|
||||
return { kind: 'git', url: raw, displayName: raw, rawInput: raw };
|
||||
}
|
||||
|
||||
if (GH_SHORT_RE.test(raw)) {
|
||||
const url = `https://github.com/${raw}`;
|
||||
return { kind: 'git', url, displayName: raw, rawInput: raw };
|
||||
}
|
||||
|
||||
// Treat anything else as a local path.
|
||||
return { kind: 'local', path: path.resolve(raw), displayName: raw, rawInput: raw };
|
||||
}
|
||||
|
||||
// Resolve a parsed descriptor into a usable source directory on disk.
|
||||
// For local sources, copies into a fresh temp dir so installation can stage
|
||||
// without touching the user's working tree. For git, shallow-clones into a
|
||||
// temp dir (no shared cache for v1 — keeps install deterministic and avoids
|
||||
// stale checkouts).
|
||||
//
|
||||
// Returns { dir, sha, ref, cleanup } where `sha` and `ref` are null for
|
||||
// local sources and `cleanup()` removes the temp dir.
|
||||
export async function materializeSource(descriptor, opts = {}) {
|
||||
const { ref = null } = opts;
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-'));
|
||||
|
||||
if (descriptor.kind === 'local') {
|
||||
const srcStat = await fs.stat(descriptor.path).catch(() => null);
|
||||
if (!srcStat || !srcStat.isDirectory()) {
|
||||
throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`);
|
||||
}
|
||||
const dir = path.join(tmpRoot, 'src');
|
||||
await copyDir(
|
||||
descriptor.path,
|
||||
dir,
|
||||
(rel) => rel === '.git' || rel.startsWith('.git/') || rel === 'node_modules' || rel.startsWith('node_modules/'),
|
||||
);
|
||||
return {
|
||||
dir,
|
||||
sha: null,
|
||||
ref: null,
|
||||
cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
// git
|
||||
const dir = path.join(tmpRoot, 'src');
|
||||
const args = ['clone', '--depth', '1'];
|
||||
if (ref) args.push('--branch', ref);
|
||||
args.push(descriptor.url, dir);
|
||||
try {
|
||||
await execFileP('git', args, { timeout: 120_000 });
|
||||
} catch (e) {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed: ${e.stderr || e.message}`);
|
||||
}
|
||||
|
||||
let sha = null;
|
||||
try {
|
||||
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd: dir });
|
||||
sha = stdout.trim();
|
||||
} catch {
|
||||
// sha unknown — non-fatal, manifest will show null
|
||||
}
|
||||
|
||||
return {
|
||||
dir,
|
||||
sha,
|
||||
ref,
|
||||
cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
||||
import { findBmadDir } from './lib/bmad-dir.mjs';
|
||||
import { listModuleEntries } from './lib/manifest-ops.mjs';
|
||||
|
||||
// List community-source modules from manifest.yaml. Output is a fixed-width
|
||||
// table; `--json` swaps in JSON for programmatic callers.
|
||||
export async function runList(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 modules = await listModuleEntries(bmadDir);
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ modules }, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modules.length === 0) {
|
||||
process.stdout.write(`[bmad-module] no modules installed.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = modules.map((p) => ({
|
||||
code: p.name,
|
||||
name: p.moduleName || '-',
|
||||
version: p.version || '-',
|
||||
sha: p.sha ? p.sha.slice(0, 7) : '-',
|
||||
source: p.repoUrl || p.rawSource || '-',
|
||||
installed: p.installDate ? p.installDate.slice(0, 10) : '-',
|
||||
}));
|
||||
const cols = ['code', 'name', 'version', 'sha', 'source', 'installed'];
|
||||
const widths = cols.reduce((acc, c) => {
|
||||
acc[c] = Math.max(c.length, ...rows.map((r) => String(r[c]).length));
|
||||
return acc;
|
||||
}, {});
|
||||
const fmt = (r) => cols.map((c) => String(r[c]).padEnd(widths[c])).join(' ');
|
||||
process.stdout.write(fmt(Object.fromEntries(cols.map((c) => [c, c.toUpperCase()]))) + '\n');
|
||||
process.stdout.write(cols.map((c) => '-'.repeat(widths[c])).join(' ') + '\n');
|
||||
for (const r of rows) process.stdout.write(fmt(r) + '\n');
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
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 { pruneEmptyDirs } from './lib/fs-safe.mjs';
|
||||
import {
|
||||
readManifestYaml,
|
||||
removeModuleFromManifest,
|
||||
removeSkillManifestRows,
|
||||
removeFilesManifestRows,
|
||||
readFileEntriesForModule,
|
||||
} from './lib/manifest-ops.mjs';
|
||||
|
||||
// Remove a module's installed files and manifest entries. With `--purge` also
|
||||
// deletes `_bmad/custom/<code>/` (user customization dir). Without it, customs
|
||||
// are preserved so a re-install picks them back up.
|
||||
export async function runRemove(opts) {
|
||||
const projectDir = opts.projectDir || process.cwd();
|
||||
const code = opts.code;
|
||||
if (!code) throw new BmadModuleError(EXIT.USAGE, `bmad-module remove <code> is required`);
|
||||
|
||||
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 entry = manifest?.modules?.find((m) => m && m.name === code);
|
||||
if (!entry) {
|
||||
throw new BmadModuleError(EXIT.NOT_INSTALLED, `no module "${code}" in manifest.yaml`);
|
||||
}
|
||||
if (entry.source !== 'community') {
|
||||
throw new BmadModuleError(
|
||||
EXIT.PREFIX_COLLISION,
|
||||
`module "${code}" was installed as source="${entry.source}", not "community". ` +
|
||||
`Use the appropriate uninstaller (e.g. \`bmad-method uninstall\`).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete each file tracked in files-manifest.csv; prune empty dirs after.
|
||||
const fileEntries = await readFileEntriesForModule(bmadDir, code);
|
||||
const moduleRoot = path.join(bmadDir, code);
|
||||
for (const fe of fileEntries) {
|
||||
const abs = path.join(bmadDir, fe.path);
|
||||
try {
|
||||
await fs.rm(abs, { force: true });
|
||||
await pruneEmptyDirs(path.dirname(abs), moduleRoot);
|
||||
} catch (e) {
|
||||
process.stderr.write(`[bmad-module] warn: failed to remove ${fe.path}: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the module root if it still exists (in case files-manifest was
|
||||
// incomplete or empty). Safe — at this point we've confirmed source=community.
|
||||
await fs.rm(moduleRoot, { recursive: true, force: true });
|
||||
|
||||
// Optionally purge custom overrides.
|
||||
if (opts.purge) {
|
||||
const customDir = path.join(bmadDir, 'custom', code);
|
||||
await fs.rm(customDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Drop manifest rows.
|
||||
await removeFilesManifestRows(bmadDir, code);
|
||||
await removeSkillManifestRows(bmadDir, code);
|
||||
await removeModuleFromManifest(bmadDir, code);
|
||||
|
||||
process.stdout.write(`[bmad-module] removed ${code} (${fileEntries.length} file(s))\n`);
|
||||
if (opts.purge) {
|
||||
process.stdout.write(`[bmad-module] purged _bmad/custom/${code}/\n`);
|
||||
} else if (await dirExists(path.join(bmadDir, 'custom', code))) {
|
||||
process.stdout.write(`[bmad-module] preserved _bmad/custom/${code}/ (use --purge to remove)\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function dirExists(p) {
|
||||
try {
|
||||
const s = await fs.stat(p);
|
||||
return s.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
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 {
|
||||
readManifestYaml,
|
||||
addModuleToManifest,
|
||||
appendSkillManifestRows,
|
||||
appendFilesManifestRows,
|
||||
removeSkillManifestRows,
|
||||
removeFilesManifestRows,
|
||||
readFileEntriesForModule,
|
||||
} from './lib/manifest-ops.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}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 list, 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 stagedDir = path.join(path.dirname(materialized.dir), 'staged-out');
|
||||
await copyDir(materialized.dir, stagedDir, (rel) => !copyList.includes(rel) && !isAncestorOfAny(rel, copyList));
|
||||
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 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);
|
||||
|
||||
// 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 ${copyList.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "fixture-missing-fields",
|
||||
"version": "0.1.0",
|
||||
"description": "Negative fixture — missing the entire bmad object, so install-time validation fails."
|
||||
}
|
||||
11
src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/.claude-plugin/plugin.json
vendored
Normal file
11
src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "fixture-bad-traversal",
|
||||
"version": "0.1.0",
|
||||
"description": "Negative fixture — declares a skill path that escapes the module root via '..'.",
|
||||
"skills": ["../escape"],
|
||||
"bmad": {
|
||||
"specVersion": "1.0.0",
|
||||
"code": "badpath",
|
||||
"compatibility": { "bmadMethod": ">=6.6.0" }
|
||||
}
|
||||
}
|
||||
6
src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/skills/skill-a/SKILL.md
vendored
Normal file
6
src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/skills/skill-a/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
name: skill-a
|
||||
description: Stub skill that exists so the path-traversal check is exercised against the manifest's bogus entry, not a missing file.
|
||||
---
|
||||
|
||||
Stub.
|
||||
11
src/core-skills/bmad-module/tests/fixtures/module-reserved-code/.claude-plugin/plugin.json
vendored
Normal file
11
src/core-skills/bmad-module/tests/fixtures/module-reserved-code/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "fixture-reserved-code",
|
||||
"version": "0.1.0",
|
||||
"description": "Negative fixture — declares bmad.code that collides with the reserved BMAD module 'bmm'.",
|
||||
"skills": ["./skills/skill-a"],
|
||||
"bmad": {
|
||||
"specVersion": "1.0.0",
|
||||
"code": "bmm",
|
||||
"compatibility": { "bmadMethod": ">=6.6.0" }
|
||||
}
|
||||
}
|
||||
6
src/core-skills/bmad-module/tests/fixtures/module-reserved-code/skills/skill-a/SKILL.md
vendored
Normal file
6
src/core-skills/bmad-module/tests/fixtures/module-reserved-code/skills/skill-a/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
name: skill-a
|
||||
description: Stub skill for the reserved-code negative fixture. Never installed.
|
||||
---
|
||||
|
||||
Stub.
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env bash
|
||||
# integration.test.sh — end-to-end smoke test for the bmad-module skill.
|
||||
#
|
||||
# Hermetic: fabricates a minimal _bmad/_config/manifest.yaml skeleton in a
|
||||
# tmp dir and exercises every verb against the local reference modules and
|
||||
# negative fixtures. Does NOT require BMAD-METHOD's installer; the upstream
|
||||
# patch (§5) is verified separately.
|
||||
#
|
||||
# Run from anywhere:
|
||||
# bash src/core-skills/bmad-module/tests/integration.test.sh
|
||||
#
|
||||
# Exit 0 on full pass; non-zero on first failed assertion (set -e).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
REPO_DIR="$(cd "${SKILL_DIR}/../../.." && pwd)"
|
||||
MODULE_JS="${SKILL_DIR}/scripts/bmad-module.mjs"
|
||||
EXAMPLES="${REPO_DIR}/examples"
|
||||
FIXTURES="${SCRIPT_DIR}/fixtures"
|
||||
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${WORKDIR}"' EXIT
|
||||
cd "${WORKDIR}"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
note() { printf '\n\033[1m── %s\033[0m\n' "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; pass=$((pass+1)); }
|
||||
ko() { printf ' \033[31m✗\033[0m %s\n' "$*"; fail=$((fail+1)); }
|
||||
|
||||
# Wrapper that captures stdout/stderr/exit code into globals.
|
||||
run() {
|
||||
set +e
|
||||
STDOUT="$(node "${MODULE_JS}" "$@" 2>/tmp/bmad-module-stderr.$$)"
|
||||
EXIT=$?
|
||||
STDERR="$(cat /tmp/bmad-module-stderr.$$)"
|
||||
rm -f /tmp/bmad-module-stderr.$$
|
||||
set -e
|
||||
}
|
||||
|
||||
assert_exit() {
|
||||
local want=$1; local label=$2
|
||||
if [[ "${EXIT}" -eq "${want}" ]]; then ok "${label} → exit ${want}"
|
||||
else ko "${label} → expected exit ${want}, got ${EXIT}. stderr: ${STDERR}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_path_exists() {
|
||||
if [[ -e "$1" ]]; then ok "exists: $1"
|
||||
else ko "missing: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_path_absent() {
|
||||
if [[ ! -e "$1" ]]; then ok "absent: $1"
|
||||
else ko "should be gone: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_grep() {
|
||||
local pat=$1; local file=$2
|
||||
if grep -q -E "$pat" "$file"; then ok "grep '$pat' in $(basename "$file")"
|
||||
else ko "grep '$pat' NOT in $(basename "$file"); contents:\n$(cat "$file")"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Setup: fabricate _bmad/_config/manifest.yaml ────────────────────────────
|
||||
|
||||
note "setup: minimal _bmad/ skeleton"
|
||||
mkdir -p _bmad/_config
|
||||
mkdir -p _bmad/core _bmad/bmm
|
||||
cat > _bmad/_config/manifest.yaml <<'YAML'
|
||||
installation:
|
||||
version: "v6.7.1"
|
||||
installDate: "2026-05-21T00:00:00.000Z"
|
||||
lastUpdated: "2026-05-21T00:00:00.000Z"
|
||||
modules:
|
||||
- name: core
|
||||
version: "v6.7.1"
|
||||
installDate: "2026-05-21T00:00:00.000Z"
|
||||
lastUpdated: "2026-05-21T00:00:00.000Z"
|
||||
source: built-in
|
||||
npmPackage: null
|
||||
repoUrl: null
|
||||
- name: bmm
|
||||
version: "v6.7.1"
|
||||
installDate: "2026-05-21T00:00:00.000Z"
|
||||
lastUpdated: "2026-05-21T00:00:00.000Z"
|
||||
source: built-in
|
||||
npmPackage: null
|
||||
repoUrl: null
|
||||
ides: []
|
||||
YAML
|
||||
printf 'canonicalId,name,description,module,path\n' > _bmad/_config/skill-manifest.csv
|
||||
printf 'type,name,module,path,hash\n' > _bmad/_config/files-manifest.csv
|
||||
ok "skeleton seeded at ${WORKDIR}/_bmad/"
|
||||
|
||||
# ─── 1. list (empty) ─────────────────────────────────────────────────────────
|
||||
note "list (no modules)"
|
||||
run list
|
||||
assert_exit 0 "list empty"
|
||||
[[ "${STDOUT}" == *"no modules installed"* ]] && ok "stdout reports empty" \
|
||||
|| ko "expected 'no modules installed' in stdout: ${STDOUT}"
|
||||
|
||||
# ─── 2. dry-run install of minimal module ────────────────────────────────────
|
||||
note "install --dry-run examples/minimal/acme-md-lint"
|
||||
run install "${EXAMPLES}/minimal/acme-md-lint" --dry-run
|
||||
assert_exit 0 "dry-run install"
|
||||
[[ "${STDOUT}" == *"dry-run"* ]] && ok "stdout mentions dry-run" \
|
||||
|| ko "expected 'dry-run' in stdout: ${STDOUT}"
|
||||
assert_path_absent "_bmad/mdlint"
|
||||
|
||||
# ─── 3. real install of minimal module ───────────────────────────────────────
|
||||
note "install examples/minimal/acme-md-lint"
|
||||
run install "${EXAMPLES}/minimal/acme-md-lint"
|
||||
assert_exit 0 "install minimal"
|
||||
assert_path_exists "_bmad/mdlint/.claude-plugin/plugin.json"
|
||||
assert_path_exists "_bmad/mdlint/skills/acme-md-lint/SKILL.md"
|
||||
assert_grep '^ - name: mdlint' "_bmad/_config/manifest.yaml"
|
||||
assert_grep 'source: community' "_bmad/_config/manifest.yaml"
|
||||
assert_grep '"acme-md-lint","acme-md-lint"' "_bmad/_config/skill-manifest.csv"
|
||||
assert_grep ',"mdlint",' "_bmad/_config/files-manifest.csv"
|
||||
|
||||
# ─── 4. list (one module) ────────────────────────────────────────────────────
|
||||
note "list (after minimal install)"
|
||||
run list
|
||||
assert_exit 0 "list one"
|
||||
[[ "${STDOUT}" == *"mdlint"* ]] && ok "stdout includes mdlint" \
|
||||
|| ko "expected 'mdlint' in stdout: ${STDOUT}"
|
||||
|
||||
run list --json
|
||||
assert_exit 0 "list --json"
|
||||
[[ "${STDOUT}" == *"\"name\": \"mdlint\""* ]] && ok "json includes mdlint name" \
|
||||
|| ko "expected mdlint in JSON: ${STDOUT}"
|
||||
|
||||
# ─── 5. idempotent re-install ────────────────────────────────────────────────
|
||||
note "install acme-md-lint again (idempotent / collision)"
|
||||
# Local sources have no sha, so the no-op fast path can't trigger — we hit
|
||||
# the collision branch instead. Asserting exit 30 documents the v1 behavior:
|
||||
# local re-installs require `update`.
|
||||
run install "${EXAMPLES}/minimal/acme-md-lint"
|
||||
assert_exit 30 "re-install collision"
|
||||
|
||||
# ─── 6. negative: reserved-code fixture ──────────────────────────────────────
|
||||
note "install module-reserved-code → exit 21"
|
||||
run install "${FIXTURES}/module-reserved-code"
|
||||
assert_exit 21 "reserved code"
|
||||
|
||||
# ─── 7. negative: bad-traversal fixture ──────────────────────────────────────
|
||||
note "install module-bad-traversal → exit 70"
|
||||
run install "${FIXTURES}/module-bad-traversal"
|
||||
assert_exit 70 "path traversal"
|
||||
|
||||
# ─── 8. negative: missing-fields fixture ─────────────────────────────────────
|
||||
note "install module-bad-missing-fields → exit 20"
|
||||
run install "${FIXTURES}/module-bad-missing-fields"
|
||||
assert_exit 20 "missing required fields"
|
||||
|
||||
# ─── 9. comprehensive module install ─────────────────────────────────────────
|
||||
note "install examples/comprehensive/acme-devlog"
|
||||
run install "${EXAMPLES}/comprehensive/acme-devlog"
|
||||
assert_exit 0 "install comprehensive"
|
||||
assert_path_exists "_bmad/devlog/skills/bmad-devlog-write/SKILL.md"
|
||||
assert_path_exists "_bmad/devlog/skills/bmad-devlog-setup/SKILL.md"
|
||||
assert_path_exists "_bmad/devlog/agents/changelog-archivist.md"
|
||||
assert_path_exists "_bmad/devlog/hooks/hooks.json"
|
||||
assert_path_exists "_bmad/devlog/.mcp.json"
|
||||
# install.ignore excludes docs/ and tests/ and README.md / CHANGELOG.md
|
||||
assert_path_absent "_bmad/devlog/docs"
|
||||
assert_path_absent "_bmad/devlog/README.md"
|
||||
assert_path_absent "_bmad/devlog/CHANGELOG.md"
|
||||
[[ "${STDOUT}" == *"hooks"* ]] && ok "warns about hooks not auto-activated" \
|
||||
|| ko "expected hooks warning in stdout: ${STDOUT}"
|
||||
|
||||
# ─── 10. remove minimal (no purge), preserve custom ─────────────────────────
|
||||
note "create _bmad/custom/mdlint to test preservation, then remove"
|
||||
mkdir -p _bmad/custom/mdlint
|
||||
echo "user override" > _bmad/custom/mdlint/override.md
|
||||
run remove mdlint
|
||||
assert_exit 0 "remove mdlint"
|
||||
assert_path_absent "_bmad/mdlint"
|
||||
assert_path_exists "_bmad/custom/mdlint/override.md"
|
||||
[[ "${STDOUT}" == *"preserved"* ]] && ok "stdout mentions preserved customs" \
|
||||
|| ko "expected 'preserved' in stdout: ${STDOUT}"
|
||||
# manifest rows for mdlint should be gone
|
||||
grep -q ',"mdlint",' _bmad/_config/files-manifest.csv && \
|
||||
ko "mdlint rows still in files-manifest.csv" || ok "files-manifest.csv pruned"
|
||||
grep -q '"acme-md-lint"' _bmad/_config/skill-manifest.csv && \
|
||||
ko "acme-md-lint row still in skill-manifest.csv" || ok "skill-manifest.csv pruned"
|
||||
|
||||
# ─── 11. remove --purge ──────────────────────────────────────────────────────
|
||||
note "remove devlog --purge"
|
||||
mkdir -p _bmad/custom/devlog
|
||||
echo "user override" > _bmad/custom/devlog/override.md
|
||||
run remove devlog --purge
|
||||
assert_exit 0 "remove --purge"
|
||||
assert_path_absent "_bmad/devlog"
|
||||
assert_path_absent "_bmad/custom/devlog"
|
||||
|
||||
# ─── 12. remove unknown ──────────────────────────────────────────────────────
|
||||
note "remove unknown code"
|
||||
run remove nope
|
||||
assert_exit 90 "remove unknown"
|
||||
|
||||
# ─── Summary ─────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
printf ' %d pass · %d fail\n' "${pass}" "${fail}"
|
||||
if [[ "${fail}" -gt 0 ]]; then
|
||||
echo " FAIL"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK"
|
||||
Loading…
Reference in New Issue