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
|
// ESLint config file should not be checked for publish-related Node rules
|
||||||
{
|
{
|
||||||
files: ['eslint.config.mjs'],
|
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