fix(bmad-module): make skill self-contained so it works after npx install

The skill is copied into _bmad/core/skills/bmad-module/ by the installer,
which strips node_modules, ships no package.json under the skill, and never
runs npm install there. Bare `import 'yaml'`/`import 'semver'` therefore
crashed at module load (ERR_MODULE_NOT_FOUND, exit 1) before any structured
exit code could fire. Every other installed script is zero-third-party-dep.

- Vendor the real yaml@2.8.4 as a deterministic esbuild single-file bundle
  (scripts/lib/vendor/yaml.mjs), imported by relative path. Guarantees
  byte-identical manifest.yaml round-trips with BMAD core's writer, which uses
  the same library + options (tools/installer/core/manifest.js).
- Drop semver for a node:-only semver-lite.mjs (valid/validRange),
  parity-tested against the real semver across 469 cases (400 fuzzed).
- Fix a third bare yaml import that the original report missed (frontmatter.mjs).
- Make bmad-module.mjs a zero-import launcher that maps any load failure to a
  new documented EXIT.TOOLING (5) with reinstall guidance instead of leaking a
  raw ESM stack trace; the verb dispatcher moves to cli.mjs.
- Enforce vendor freshness so a yaml/esbuild bump can't ship a stale bundle:
  build-vendor.mjs --check is wired into npm test (pre-commit), npm run quality,
  and the quality.yaml CI validate job. Adds vendor:build / vendor:check scripts.
- Ignore the generated vendor/ dir in eslint + prettier; document the rationale
  in SKILL.md, README.md, and vendor/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pbean 2026-05-23 17:29:46 -07:00
parent e96f16bf31
commit a0fba4b824
16 changed files with 7862 additions and 161 deletions

View File

@ -103,6 +103,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Vendor bundle freshness (bmad-module yaml)
run: npm run vendor:check
- name: Test agent compilation components
run: npm run test:install

View File

@ -10,3 +10,6 @@ _bmad*/
# IDE integration folders (user-specific, not in repo)
.junie/
# Generated vendored bundles in the bmad-module skill (not authored source)
src/core-skills/bmad-module/scripts/lib/vendor/**

View File

@ -11,6 +11,9 @@ export default [
'dist/**',
'coverage/**',
'**/*.min.js',
// Generated, self-contained vendored bundles shipped with the bmad-module
// skill (regenerated by its build-vendor.mjs) — not authored source.
'src/core-skills/bmad-module/scripts/lib/vendor/**',
'test/template-test-generator/**',
'test/fixtures/**',
'_bmad*/**',

View File

@ -39,15 +39,17 @@
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
"lint:md": "markdownlint-cli2 \"**/*.md\"",
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills",
"quality": "npm run vendor:check && npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills",
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
"test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
"test": "npm run vendor:check && npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
"test:channels": "node test/test-installer-channels.js",
"test:install": "node test/test-installation-components.js",
"test:refs": "node test/test-file-refs-csv.js",
"test:urls": "node test/test-parse-source-urls.js",
"validate:refs": "node tools/validate-file-refs.js --strict",
"validate:skills": "node tools/validate-skills.js --strict"
"validate:skills": "node tools/validate-skills.js --strict",
"vendor:build": "node src/core-skills/bmad-module/scripts/lib/vendor/build-vendor.mjs",
"vendor:check": "node src/core-skills/bmad-module/scripts/lib/vendor/build-vendor.mjs --check"
},
"lint-staged": {
"*.{js,cjs,mjs}": [

View File

@ -41,11 +41,23 @@ bmad-module list [--json]
## 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.
The skill itself is a thin verb router (`SKILL.md`). `scripts/bmad-module.mjs`
is a zero-import launcher that guards the import graph (a missing/corrupt
runtime file becomes a documented exit code, not a raw stack trace); the verb
dispatcher lives in `scripts/cli.mjs` and all filesystem work happens in the
`lib/` modules. These carry **no registry dependencies** — important because
the installer copies the skill into `_bmad/` without `node_modules` and never
runs `npm install` there:
- `manifest.yaml` is read/written with a **vendored copy of the real `yaml`
library** (`lib/vendor/yaml.mjs`, regenerated by `lib/vendor/build-vendor.mjs`)
so it stays byte-identical to BMAD core's writer.
- `semver` validity/range checks use a small `node:`-only helper
(`lib/semver-lite.mjs`).
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

View File

@ -22,7 +22,10 @@ plugin via its `.claude-plugin/plugin.json` manifest.
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.
stop. Do NOT retry, do NOT try a different verb. The one exception is exit
code 5 (the skill's own bundled runtime files are missing/corrupt): that's a
fixable setup/packaging problem, not a module rejection — relay the script's
"reinstall the skill" guidance instead of reporting a failed install.
## EXECUTION
@ -97,6 +100,7 @@ suggest workarounds beyond what the script's message itself suggests
|---|---|
| 0 | success |
| 2 | usage error (bad/missing args or flags) |
| 5 | skill runtime files missing/corrupt — reinstall the skill (a setup/packaging problem, NOT a module rejection) |
| 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` |

View File

@ -1,153 +1,45 @@
#!/usr/bin/env node
// bmad-module — verb dispatcher.
// bmad-module — thin launcher (entry point).
//
// 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>]
// This file has NO imports on purpose. It must ALWAYS load, so that a broken or
// incomplete skill copy — e.g. a missing lib/vendor/yaml.mjs after a partial
// install — is reported as a DOCUMENTED exit code with actionable guidance,
// instead of crashing at module-load with a raw ESM resolver stack trace and a
// bare exit 1 (which is not in the exit-code table). The real CLI is cli.mjs.
//
// 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();
// TOOLING must equal EXIT.TOOLING in ./lib/exit.mjs. It is duplicated here as a
// literal so this guard depends on nothing and can never itself fail to load.
const TOOLING = 5;
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;
// A failure resolving/evaluating this import graph means a runtime asset is
// missing or corrupt. Runtime and usage errors are handled INSIDE cli.mjs
// (which maps them to their own structured exit codes and calls process.exit
// before returning), so they never reach the catch below.
const { main } = await import('./cli.mjs');
await main();
} catch (err) {
const code = err && err.code;
const isLoadError =
err instanceof SyntaxError ||
code === 'ERR_MODULE_NOT_FOUND' ||
code === 'ERR_UNSUPPORTED_DIR_IMPORT' ||
code === 'ERR_UNKNOWN_FILE_EXTENSION' ||
code === 'ERR_DLOPEN_FAILED' ||
code === 'ERR_REQUIRE_ESM';
if (isLoadError) {
process.stderr.write(
`[bmad-module] the skill's bundled runtime files are missing or corrupt ` +
`(${code || err.name}: ${err.message}).\n` +
`[bmad-module] this is a setup/packaging problem, not a module-rejection ` +
`decision — do not treat it as a failed install of the target module.\n` +
`[bmad-module] fix: reinstall the skill so its scripts/ tree (including ` +
`scripts/lib/vendor/) is complete — re-run \`npx bmad-method install\`, ` +
`or re-copy the bmad-module skill folder in full.\n`,
);
process.exit(TOOLING);
}
} 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`);
// Anything else escaping cli.mjs is a genuine, unexpected bug.
process.stderr.write(`[bmad-module] unexpected error: ${err && (err.stack || err.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();

View File

@ -0,0 +1,154 @@
// bmad-module CLI — verb dispatcher. Loaded by the thin launcher in
// bmad-module.mjs, which guards this whole import graph: if any runtime asset
// (e.g. lib/vendor/yaml.mjs) is missing, the launcher reports a documented
// setup error instead of leaking a raw ESM stack trace. See bmad-module.mjs.
//
// 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.mjs. 0 = ok; everything ≥5 = 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;
}
export 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
5 skill runtime files missing/corrupt reinstall the skill
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
`);
}

View File

@ -3,6 +3,12 @@
export const EXIT = {
OK: 0,
USAGE: 2,
// Setup/packaging problem — the skill's own bundled runtime files (e.g. the
// vendored yaml in lib/vendor/) are missing or corrupt, usually from an
// incomplete copy. Distinct from a module-rejection decision: it's fixable by
// reinstalling the skill. Emitted by the launcher in bmad-module.mjs, which
// duplicates the literal `5` so its guard can depend on nothing — keep in sync.
TOOLING: 5,
NO_BMAD_DIR: 10,
BAD_MANIFEST: 20,
RESERVED_PREFIX: 21,

View File

@ -1,4 +1,4 @@
import { parse as parseYaml } from 'yaml';
import { parse as parseYaml } from './vendor/yaml.mjs';
// Parse YAML frontmatter from a markdown string. Returns the parsed object,
// or null if no frontmatter block is present / it failed to parse.

View File

@ -1,6 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { parse as parseYaml, stringify as stringifyYaml } from './vendor/yaml.mjs';
import { parseFrontmatter } from './frontmatter.mjs';
import { sha256File } from './fs-safe.mjs';
import { EXIT, BmadModuleError } from './exit.mjs';

View File

@ -1,6 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import semver from 'semver';
import { valid as semverValid, validRange as semverValidRange } from './semver-lite.mjs';
import { EXIT, BmadModuleError } from './exit.mjs';
// Reserved bmad.code values — must match docs/spec.md §7.1 and the
@ -63,7 +63,7 @@ export async function readAndValidateManifest(sourceDir) {
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 364 chars`);
}
if (!semver.valid(m.version)) {
if (!semverValid(m.version)) {
throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json#version "${m.version}" is not valid semver`);
}
if (!CODE_REGEX.test(m.bmad.code)) {
@ -72,7 +72,7 @@ export async function readAndValidateManifest(sourceDir) {
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)) {
if (!semverValidRange(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`,

View File

@ -0,0 +1,82 @@
// semver-lite — zero-dependency stand-ins for the two semver functions this
// skill needs. We deliberately do NOT vendor the `semver` package: its only use
// here is install-time validation of an author's plugin.json (`valid` on
// #version, `validRange` on #bmad.compatibility.bmadMethod). That is bounded,
// low-severity input checking — unlike manifest.yaml round-tripping, which is
// co-owned with BMAD core and so keeps the REAL `yaml` library (see vendor/).
//
// `node:`-only, matching every other script BMAD installs. Both functions are
// generous: a wrong *reject* would fail a legitimate install, so where semver
// is lenient we are lenient too. Parity with the real `semver` is asserted by a
// battery in the repo's skill tests; keep that battery green if you edit this.
// Official SemVer 2.0.0 grammar (semver.org), with an optional leading `v` to
// match semver's parser. Build metadata is accepted but dropped from the
// normalized return, exactly like `semver.valid()`.
const SEMVER_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
/**
* Mirror of `semver.valid()`: returns the normalized version string for a valid
* full semver, otherwise null. Build metadata is stripped from the result.
*/
export function valid(version) {
if (typeof version !== 'string') return null;
const m = SEMVER_RE.exec(version.trim());
if (!m) return null;
return `${m[1]}.${m[2]}.${m[3]}${m[4] ? `-${m[4]}` : ''}`;
}
// ---- range grammar -------------------------------------------------------
// A "partial" is a 13 segment version where each segment may be a number or an
// x-range wildcard (x/X/*), with optional prerelease/build on the full form.
const XID = '(?:0|[1-9]\\d*|\\d+|[xX*])';
const PRE = '(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
const BUILD = '(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
const PARTIAL_RE = new RegExp(`^v?${XID}(?:\\.${XID}(?:\\.${XID}${PRE}${BUILD})?)?$`);
function isPartial(v) {
return v !== '' && PARTIAL_RE.test(v);
}
// One comparator: an optional operator (>=, <=, >, <, =, ~, ^) glued to a
// partial version. Bare `*`/`x`/`X` (or empty) means "any".
function isComparator(tok) {
if (tok === '' || tok === '*' || tok === 'x' || tok === 'X') return true;
// `~>` is a semver-supported alias for `~`; match it before `~`/`>`.
const m = /^(~>|>=|<=|>|<|=|~|\^)?(.*)$/.exec(tok);
return isPartial(m[2]);
}
// One comparator set (the AND-joined part of a `||` union).
function isComparatorSet(set) {
if (set === '' || set === '*') return true;
// Hyphen range: "<partial> - <partial>" (spaces required around the dash).
const hy = /^(.+?)\s+-\s+(.+)$/.exec(set);
if (hy) return isPartial(hy[1].trim()) && isPartial(hy[2].trim());
// Collapse whitespace after an operator so ">= 1.2.3" is a single comparator,
// then split the remaining intersection on whitespace.
const tokens = set
.replace(/(~>|>=|<=|>|<|=|~|\^)\s+/g, '$1')
.split(/\s+/)
.filter(Boolean);
// `[].every()` is already true, so an empty intersection means "any".
return tokens.every((t) => isComparator(t));
}
/**
* Mirror of `semver.validRange()` as a validator: returns the input range when
* it is a syntactically valid semver range, otherwise null. (We return the
* original string rather than semver's normalized form callers here only test
* truthiness.)
*/
export function validRange(range) {
if (typeof range !== 'string') return null;
const r = range.trim();
if (r === '') return '*';
const groups = r.split(/\s*\|\|\s*/);
for (const g of groups) {
if (!isComparatorSet(g.trim())) return null;
}
return range;
}

View File

@ -0,0 +1,65 @@
# `lib/vendor/` — vendored runtime dependencies
This directory holds **self-contained, generated** copies of third-party
libraries the skill needs at runtime. They are committed on purpose.
## Why vendor at all?
The `bmad-module` skill is **copied into a user's project** at
`_bmad/core/skills/bmad-module/` by `npx bmad-method install`. The installer:
- strips `node_modules` while copying (`tools/installer/core/installer.js`),
- ships **no** `package.json` under the skill, and
- never runs `npm install` inside `_bmad/`.
So a bare `import 'yaml'` cannot resolve at runtime — it throws
`ERR_MODULE_NOT_FOUND` before any of the skill's exit codes can fire. Every
other script BMAD installs is zero-third-party-dependency; vendoring keeps this
skill self-sufficient the same way, without setup.
Files here are imported by **relative path** (`./vendor/yaml.mjs`), which
resolves regardless of cwd, install location, or `node_modules` presence.
## What's vendored — and what's NOT
| Need | Strategy | Where |
|---|---|---|
| `yaml` (parse/stringify `_bmad/_config/manifest.yaml`) | **vendored, real library** | `vendor/yaml.mjs` |
| `semver` (`valid` + `validRange` on `plugin.json`) | **dropped** — hand-rolled, `node:` only | `../semver-lite.mjs` |
`manifest.yaml` is **co-owned** with BMAD core, which reads/writes it with the
same `yaml` package and the same `{indent:2, lineWidth:0}` options
(`tools/installer/core/manifest.js`). Hand-rolling a YAML emitter risks
diverging from that on the user's live install state, so we ship the **real**
library and verify byte-identical output in `build-vendor.mjs`. `semver` is
only input-validation of an author's manifest, so it is safe to hand-roll.
## `yaml.mjs`
- **GENERATED — do not edit by hand.** An esbuild single-file bundle of the
`yaml` npm package (eemeli/yaml), tree-shaken to just `parse` + `stringify`.
- The exact pinned version and build provenance are in the file's header.
- Upstream license is retained inline (`legalComments: 'inline'`).
### Regenerating
After bumping `yaml` (or esbuild) in the repo's **root** `package.json` +
`npm install`:
```bash
npm run vendor:build # regenerate this yaml.mjs
npm run vendor:check # verify it's in sync (what CI runs)
```
The build is **deterministic** for a given `yaml` + `esbuild` version (both
pinned in the lockfile) and self-checks a parse→stringify round-trip.
**You don't have to remember to do this.** `vendor:check` is wired into
`npm test` (husky pre-commit) and `npm run quality` (the `validate` job in
`.github/workflows/quality.yaml`). If the committed bundle drifts from the
installed `yaml`/`esbuild` version, those gates fail with a message telling you
to run `npm run vendor:build` — so a bump can't land with a stale bundle, and
manifest writes stay byte-identical between BMAD core and this skill.
Lint/format intentionally ignore this directory (see `eslint.config.mjs`
global ignores and `.prettierignore`) — it is generated, not authored.

View File

@ -0,0 +1,113 @@
#!/usr/bin/env node
// build-vendor — regenerates (and, with --check, verifies) the vendored,
// self-contained copy of the `yaml` library this skill ships.
//
// Why this exists: the bmad-module skill is COPIED into a user's project at
// `_bmad/core/skills/bmad-module/` by `npx bmad-method install`. The installer
// strips `node_modules` (tools/installer/core/installer.js), ships no
// package.json under the skill, and never runs `npm install` in `_bmad/`. So a
// bare `import 'yaml'` cannot resolve at runtime. Every other script BMAD
// installs is zero-third-party-dep; this is the one library we cannot safely
// hand-roll, because `_bmad/_config/manifest.yaml` is CO-OWNED with BMAD core
// (tools/installer/core/manifest.js writes it with the same `yaml` package and
// the same {indent:2,lineWidth:0} options). Vendoring the REAL library is the
// only way to guarantee byte-identical round-trips.
//
// The output `yaml.mjs` is imported by RELATIVE path from manifest-ops.mjs and
// frontmatter.mjs, so it resolves regardless of cwd, install location, or
// node_modules presence.
//
// Usage (via root package.json):
// npm run vendor:build # regenerate scripts/lib/vendor/yaml.mjs
// npm run vendor:check # fail if the committed bundle is stale (CI gate)
//
// The output is DETERMINISTIC for a given yaml + esbuild version (both pinned in
// the lockfile), which is what lets `--check` byte-compare. Bumping `yaml` (or
// esbuild) makes the check fail until you re-run `npm run vendor:build` and
// commit — so the vendored copy can never silently drift from BMAD core's.
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const vendorDir = path.dirname(fileURLToPath(import.meta.url));
const outfile = path.join(vendorDir, 'yaml.mjs');
const checkMode = process.argv.includes('--check');
const esbuild = await import('esbuild');
const yamlVersion = require('yaml/package.json').version;
const esbuildVersion = require('esbuild/package.json').version;
// NOTE: intentionally no builder-specific data (node version, timestamp) in the
// banner — the output must be reproducible so --check can byte-compare.
const banner = `// ============================================================================
// GENERATED — DO NOT EDIT BY HAND. Run \`npm run vendor:build\` to regenerate.
// Vendored, self-contained bundle of the \`yaml\` npm package (eemeli/yaml).
//
// yaml : ${yamlVersion}
// bundler : esbuild ${esbuildVersion}
//
// Shipped because the skill is copied into projects without node_modules; see
// build-vendor.mjs and vendor/README.md for the rationale. Only \`parse\` and
// \`stringify\` are re-exported (tree-shaken). Upstream license retained below.
// ============================================================================
import { createRequire as __createRequire } from 'node:module';
const require = __createRequire(import.meta.url);
`;
const result = await esbuild.build({
stdin: {
contents: "export { parse, stringify } from 'yaml';",
resolveDir: vendorDir,
sourcefile: 'vendor-entry.mjs',
loader: 'js',
},
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
minify: false,
legalComments: 'inline',
charset: 'utf8',
banner: { js: banner },
write: false,
});
const built = result.outputFiles[0].text;
// Self-check: the freshly built bundle must import and round-trip without any
// node_modules on its resolution path (the runtime condition). Import it from a
// temp file so we never need to write the real output to do this.
const tmp = path.join(os.tmpdir(), `bmad-vendor-yaml-${process.pid}.mjs`);
await fs.writeFile(tmp, built, 'utf8');
try {
const { parse, stringify } = await import(pathToFileURL(tmp).href);
const sample = { modules: [{ name: 'demo', version: '1.2.3', repoUrl: 'https://example.com/x', when: '2026-05-23T00:00:00.000Z' }] };
const round = parse(stringify(sample, { indent: 2, lineWidth: 0 }));
if (JSON.stringify(round) !== JSON.stringify(sample)) {
throw new Error('vendor self-check FAILED: round-trip mismatch');
}
} finally {
await fs.rm(tmp, { force: true });
}
if (checkMode) {
const current = await fs.readFile(outfile, 'utf8').catch(() => null);
if (current === built) {
process.stdout.write(`vendor:check OK — yaml.mjs matches yaml@${yamlVersion} (esbuild ${esbuildVersion})\n`);
process.exit(0);
}
process.stderr.write(
`vendor:check FAILED — scripts/lib/vendor/yaml.mjs is stale or hand-edited.\n` +
` Expected the bundle for yaml@${yamlVersion} (esbuild ${esbuildVersion}).\n` +
` Fix: run \`npm run vendor:build\` and commit the regenerated yaml.mjs.\n` +
` (This guards manifest.yaml fidelity: the vendored yaml must match the\n` +
` one BMAD core writes manifests with — see vendor/README.md.)\n`,
);
process.exit(1);
}
await fs.writeFile(outfile, built, 'utf8');
process.stdout.write(`vendored yaml@${yamlVersion} -> ${path.relative(process.cwd(), outfile)} (self-check OK)\n`);

File diff suppressed because it is too large Load Diff