mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-28 19:20:41 +08:00
91a57499e9
Closes #1663. Adds two installer flags so module config options can be set without interactive prompts. Designed for CI scripts, Dockerfiles, and enterprise rollouts where the user wants to bake answers into the install command rather than answer prompts. `--set <module>.<key>=<value>` (repeatable) sets any module config option. `--list-options [module]` lists every key the installer can discover locally — built-in modules (`core`, `bmm`) plus any cached official modules. One flag scales to every module without growing the CLI surface per option. ```bash npx bmad-method install --yes \ --modules bmm --tools claude-code \ --set bmm.project_knowledge=research \ --set bmm.user_skill_level=expert \ --set core.user_name=Brian ``` ## How it works `--set` is a post-install patch. The installer runs its normal flow untouched, then `applySetOverrides` upserts each value into the relevant config files: - `_bmad/config.toml` (team scope, default) - `_bmad/config.user.toml` (user scope, when the key already lives there — so user-scope keys like `core.user_name` and `bmm.user_skill_level` keep their proper file) - `_bmad/<module>/config.yaml` (so declared schema keys carry forward via the existingValue path on the next install) A module without `_bmad/<module>/config.yaml` is skipped silently — no orphan sections in `config.toml` for uninstalled modules. ## Tradeoffs documented in install-bmad.md - **Verbatim values.** `--set bmm.project_knowledge=research` writes `"research"`, not `"{project-root}/research"`. The `result:` template is not applied. Pass it explicitly if you want the rendered form: `--set bmm.project_knowledge='{project-root}/research'`. - **Carry-forward, declared keys.** Free — values land in the per-module `config.yaml`, so the next install reads them as `existingValue` and they become the prompt default (accepted under `--yes`). - **Carry-forward, undeclared keys.** Best-effort. The value lives in `config.toml` for the current install but won't be re-emitted on the next install (the manifest writer's schema-strict partition drops unknown keys). Re-pass `--set` if needed. - **No "key not in schema" validation.** Whatever you assert is written. ## Security Prototype-pollution defense: `--set __proto__.x=1` would otherwise reach `overrides.__proto__[x] = 1` and pollute `Object.prototype`, cascading into every plain-object lookup in the process. Defense-in- depth via parser-level reserved-name rejection (`__proto__`, `prototype`, `constructor`) AND `Object.create(null)` for the override maps. Verified the attack reproduces without the guard and is blocked with it. ## What's intentionally NOT integrated `--set` deliberately does not touch the prompt / template / schema collection flow. No pre-seeding answers, no question filtering, no function-default evaluation, no schema-strict partition exemption. That earlier integration approach was tried and scrapped: it spread state across `Config`, `OfficialModules`, `manifest-generator`, both collection helpers, and required parallel plumbing for quick-update — every bug fix touched a different layer. The post-install patch model covers the actual user need (set a config value from CI) in ~330 lines of `set-overrides.js` without the schema gymnastics. ## Files - `tools/installer/set-overrides.js` (new): parser, prototype-pollution guard, `applySetOverrides` post-install patch, `upsertTomlKey` / `tomlString` / `tomlHasKey` line-based TOML helpers - `tools/installer/list-options.js` (new): module.yaml discovery + formatter for `--list-options` - `tools/installer/commands/install.js`: register `--set` / `--list-options` flags, early validation, `--list-options` exit-code handling (await `stream.write` callback then `process.exitCode` to avoid truncating piped output), thread `setOverrides` through to quick-update - `tools/installer/core/config.js`: carry `setOverrides` field for the post-install patch step - `tools/installer/core/installer.js`: invoke `applySetOverrides` after `writeCentralConfig` (covers regular install + quick-update via the shared install path) - `tools/installer/ui.js`: parse `--set` for early validation, warn about overrides targeting modules not in `--modules`, drop those entries before threading - `docs/how-to/install-bmad.md`, `README.md`: usage, routing rules, carry-forward semantics, tradeoffs ## Test plan Suite 44 (24 cases): parser, prototype-pollution guard, `tomlString` escaping, `upsertTomlKey` across insert/replace/missing-section/ empty-file/preserved-newline cases, `applySetOverrides` happy path + uninstalled-module skip + missing-user-toml-creation + empty-input no-op, `discoverOfficialModuleYamls` / `formatOptionsList` sanity (hermetic via `BMAD_EXTERNAL_MODULES_CACHE` temp dir). 355 total passing. Lint + prettier + markdownlint clean. E2E smoke verified across: - [x] `--set` writes correct files (team toml / user toml / per-module yaml) for declared and undeclared keys - [x] Quick-update without `--set` carries forward declared keys via `existingValue` path - [x] Quick-update WITH `--set` applies cleanly (uniform behavior across action types) - [x] `--set` for unselected module: warned, no orphan section - [x] Prototype pollution: rejected with non-zero exit - [x] `--list-options bmm` exit 0 with full output through pipe; `--list-options nope` exit 1 - [x] Translated docs (`docs/{cs,fr,vi-vn,zh-cn}/`) intentionally not touched — they'll lag behind English until the translation pipeline runs
211 lines
8.1 KiB
JavaScript
211 lines
8.1 KiB
JavaScript
const path = require('node:path');
|
|
const fs = require('./fs-native');
|
|
const yaml = require('yaml');
|
|
const { getProjectRoot, getModulePath, getExternalModuleCachePath } = require('./project-root');
|
|
|
|
/**
|
|
* Read a module.yaml and return its declared `code:` field, or null if missing/unparseable.
|
|
*/
|
|
async function readModuleCode(yamlPath) {
|
|
try {
|
|
const parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
|
if (parsed && typeof parsed === 'object' && typeof parsed.code === 'string') {
|
|
return parsed.code;
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Discover module.yaml files for officials we can read locally:
|
|
* - core, bmm: bundled in src/ (always present)
|
|
* - external officials: only if previously cloned to ~/.bmad/cache/external-modules/
|
|
*
|
|
* Each result's `code` is the `code:` field from the module.yaml when present;
|
|
* that's the value `--set <module>.<key>=<value>` matches against.
|
|
*
|
|
* Community/custom modules are not enumerated; users reference their own
|
|
* module.yaml directly per the design (see issue #1663).
|
|
*
|
|
* @returns {Promise<Array<{code: string, yamlPath: string, source: string}>>}
|
|
*/
|
|
async function discoverOfficialModuleYamls() {
|
|
const found = [];
|
|
// Dedupe is case-insensitive because module caches occasionally retain a
|
|
// legacy UPPERCASE-named directory alongside the canonical lowercase one
|
|
// (same module, different cache key from an older schema). We pick whichever
|
|
// entry we see first and skip the alternate-case duplicate. NOTE: `--set`
|
|
// matching itself is case-sensitive (it keys on `moduleName` from the install
|
|
// flow's selected list, which is always lowercase short codes), so the
|
|
// surfaced `code` here is what users should type. Don't change to
|
|
// case-sensitive dedupe without revisiting that contract.
|
|
const seenCodes = new Set();
|
|
|
|
const addFound = async (yamlPath, source, fallbackCode) => {
|
|
const declaredCode = await readModuleCode(yamlPath);
|
|
const code = declaredCode || fallbackCode;
|
|
if (!code) return;
|
|
const lower = code.toLowerCase();
|
|
if (seenCodes.has(lower)) return;
|
|
seenCodes.add(lower);
|
|
found.push({ code, yamlPath, source });
|
|
};
|
|
|
|
// Built-ins.
|
|
for (const code of ['core', 'bmm']) {
|
|
const yamlPath = path.join(getModulePath(code), 'module.yaml');
|
|
if (await fs.pathExists(yamlPath)) {
|
|
// Built-ins use their well-known short codes regardless of what the
|
|
// module.yaml `code:` says, since the install flow keys on these.
|
|
seenCodes.add(code.toLowerCase());
|
|
found.push({ code, yamlPath, source: 'built-in' });
|
|
}
|
|
}
|
|
|
|
// Bundled in src/modules/<code>/module.yaml (rare, but supported by getModulePath).
|
|
const srcModulesDir = path.join(getProjectRoot(), 'src', 'modules');
|
|
if (await fs.pathExists(srcModulesDir)) {
|
|
const entries = await fs.readdir(srcModulesDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const yamlPath = path.join(srcModulesDir, entry.name, 'module.yaml');
|
|
if (await fs.pathExists(yamlPath)) {
|
|
await addFound(yamlPath, 'bundled', entry.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// External cache (~/.bmad/cache/external-modules/<code>/...).
|
|
const cacheRoot = getExternalModuleCachePath('').replace(/\/$/, '');
|
|
if (await fs.pathExists(cacheRoot)) {
|
|
const rawEntries = await fs.readdir(cacheRoot, { withFileTypes: true });
|
|
for (const entry of rawEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const candidates = [
|
|
path.join(cacheRoot, entry.name, 'module.yaml'),
|
|
path.join(cacheRoot, entry.name, 'src', 'module.yaml'),
|
|
path.join(cacheRoot, entry.name, 'skills', 'module.yaml'),
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (await fs.pathExists(candidate)) {
|
|
await addFound(candidate, 'cached', entry.name);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
function formatPromptText(item) {
|
|
if (Array.isArray(item.prompt)) return item.prompt.join(' ');
|
|
return String(item.prompt || '').trim();
|
|
}
|
|
|
|
function inferType(item) {
|
|
if (item['single-select']) return 'single-select';
|
|
if (item['multi-select']) return 'multi-select';
|
|
if (typeof item.default === 'boolean') return 'boolean';
|
|
if (typeof item.default === 'number') return 'number';
|
|
return 'string';
|
|
}
|
|
|
|
function formatModuleOptions(code, parsed, source) {
|
|
const lines = [];
|
|
const header = source === 'built-in' ? code : `${code} (${source})`;
|
|
lines.push(header + ':');
|
|
|
|
let count = 0;
|
|
for (const [key, item] of Object.entries(parsed)) {
|
|
if (!item || typeof item !== 'object' || !('prompt' in item)) continue;
|
|
count++;
|
|
const type = inferType(item);
|
|
const scope = item.scope === 'user' ? ' [user-scope]' : '';
|
|
const defaultStr = item.default === undefined || item.default === null ? '(none)' : String(item.default);
|
|
lines.push(` ${code}.${key} (${type}${scope}) default: ${defaultStr}`);
|
|
const promptText = formatPromptText(item);
|
|
if (promptText) lines.push(` ${promptText}`);
|
|
if (Array.isArray(item['single-select'])) {
|
|
const values = item['single-select'].map((v) => (typeof v === 'object' ? v.value : v)).filter((v) => v !== undefined);
|
|
if (values.length > 0) lines.push(` values: ${values.join(' | ')}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (count === 0) {
|
|
lines.push(' (no configurable options)', '');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Render `--list-options` output.
|
|
*
|
|
* Returns `{ text, ok }` so callers can surface a non-zero exit code on
|
|
* a typo'd module-code lookup. Discovery dedupes case-insensitively, so
|
|
* the lookup is also case-insensitive — typing `--list-options BMM` and
|
|
* `--list-options bmm` both find the bmm built-in.
|
|
*
|
|
* @param {string|null} moduleCode - if non-null, restrict to this module
|
|
* @returns {Promise<{text: string, ok: boolean}>}
|
|
*/
|
|
async function formatOptionsList(moduleCode) {
|
|
const discovered = await discoverOfficialModuleYamls();
|
|
const needle = moduleCode ? moduleCode.toLowerCase() : null;
|
|
const filtered = needle ? discovered.filter((d) => d.code.toLowerCase() === needle) : discovered;
|
|
|
|
if (filtered.length === 0) {
|
|
if (moduleCode) {
|
|
const text = [
|
|
`No locally-known module.yaml for '${moduleCode}'.`,
|
|
'',
|
|
'Built-in modules (core, bmm) are always available. External officials',
|
|
'appear here after they have been installed at least once on this machine',
|
|
'(they are cached under ~/.bmad/cache/external-modules/).',
|
|
'',
|
|
'For community or custom modules, read the module.yaml file in that',
|
|
"module's source repository directly.",
|
|
].join('\n');
|
|
return { text, ok: false };
|
|
}
|
|
return { text: 'No modules found.', ok: false };
|
|
}
|
|
|
|
const sections = [];
|
|
// Track when a module-scoped lookup couldn't actually be rendered (yaml
|
|
// unparseable or empty after parse). The full `--list-options` output is
|
|
// tolerant of one bad entry, but `--list-options <module>` against a single
|
|
// unreadable module should still fail tooling so a CI script catches it.
|
|
let moduleScopedFailure = false;
|
|
sections.push('Available --set keys', 'Format: --set <module>.<key>=<value> (repeatable)', '');
|
|
for (const { code, yamlPath, source } of filtered) {
|
|
let parsed;
|
|
try {
|
|
parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
|
} catch {
|
|
sections.push(`${code} (${source}): could not parse module.yaml`, '');
|
|
if (moduleCode) moduleScopedFailure = true;
|
|
continue;
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
sections.push(`${code} (${source}): module.yaml is not a valid object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, '');
|
|
if (moduleCode) moduleScopedFailure = true;
|
|
continue;
|
|
}
|
|
sections.push(formatModuleOptions(code, parsed, source));
|
|
}
|
|
|
|
if (!moduleCode) {
|
|
sections.push(
|
|
'Community and custom modules are not listed here — read their module.yaml directly. Unknown keys still persist with a warning.',
|
|
);
|
|
}
|
|
|
|
return { text: sections.join('\n'), ok: !moduleScopedFailure };
|
|
}
|
|
|
|
module.exports = { formatOptionsList, discoverOfficialModuleYamls };
|