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:
parent
e96f16bf31
commit
a0fba4b824
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/**
|
||||
|
|
|
|||
|
|
@ -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*/**',
|
||||
|
|
|
|||
|
|
@ -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}": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// 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;
|
||||
|
||||
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++;
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
return out;
|
||||
// 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);
|
||||
}
|
||||
|
||||
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,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
|
||||
`);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 3–64 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`,
|
||||
|
|
|
|||
|
|
@ -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 1–3 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
Loading…
Reference in New Issue