Compare commits

..

3 Commits

Author SHA1 Message Date
Brian Madison 1ab4d5f93c refactor(installer): simplify --set to a post-install TOML patch
The original implementation tried to integrate `--set` with the prompt /
result-template / schema-strict-partition system: pre-seeding answers,
filtering questions, evaluating function defaults, tracking override
keys for partition exemption, mirroring carry-forward in two collection
helpers, threading state through Config + ui.js + collection helpers +
manifest writer. ~900 lines spawned across 4 review rounds, with bugs
the bots kept finding because every change touched a different layer.

The simpler model: `--set` is a post-install patch. The installer runs
its normal flow untouched, then `applySetOverrides` upserts each value
into `_bmad/config.toml` (team scope) or `_bmad/config.user.toml` (user
scope) AND into `_bmad/<module>/config.yaml` so declared keys carry
forward via the existingValue path on the next install.

What gets ripped out
- All `setOverrides` plumbing through OfficialModules (constructor
  field, applyOverridesAfterSeeding, _trackUnknownKeysAsOverrides,
  declaredResultKeys, override classification + pre-write +
  question-filter + two-pass function-defaults + carry-forward in
  collectModuleConfig, _trackUnknownKeysAsOverrides calls in
  collectModuleConfigQuick, headless-branch additions in
  Installer.build). official-modules.js reset to its pre-#1663 baseline
  (commit 48a7ec8b).
- `setOverrideKeys` field on Config, threading from ui.js, partition
  exemption parameter on `manifest-generator.writeCentralConfig`.
- The "ignored under quick-update" warning in install.js — `--set` is
  now a uniform post-install patch, so it works the same way for
  quick-update as for a regular install.

What stays
- `tools/installer/set-overrides.js` parser with the prototype-pollution
  guard, prefixed by the new `applySetOverrides` / `upsertTomlKey` /
  `tomlString` / `tomlHasKey` helpers.
- `tools/installer/list-options.js` — small standalone discovery
  helper, untouched.
- The `--set` and `--list-options` CLI flag registration in
  `commands/install.js`.
- ui.js `collectModuleConfigs` retains the early-feedback warning for
  overrides targeting modules not in the install set (and now also
  filters them out of `setOverrides` before threading).

Routing rules (post-install patch)
- If `_bmad/config.user.toml` already has `[section] key`, update it
  there (so user-scope keys like `core.user_name` and
  `bmm.user_skill_level` keep their proper file).
- Otherwise update `_bmad/config.toml` (team scope, default).
- A module without `_bmad/<module>/config.yaml` (i.e. not installed)
  is skipped silently — no orphan `[modules.notamodule]` sections.

Tradeoffs documented in `docs/how-to/install-bmad.md`
- Values are written verbatim — no `result:` template rendering. Pass
  `--set bmm.project_knowledge='{project-root}/research'` if you want
  the rendered form.
- Carry-forward is automatic for declared schema keys (per-module yaml
  → existingValue → prompt default → accepted under --yes). For keys
  outside any module's schema, the value lands in `config.toml` for
  the current install but won't be re-emitted on the next install.
  Re-pass `--set` if you need it sticky.
- No "key not in schema" validation — whatever you assert is written.

Tests: Suite 44 rewritten. 355 passing (was 351). Coverage now focused
on what matters: parser + 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.

E2E smoke verified across all 6 scenarios:
1. fresh install with mixed declared + undeclared --set → correct files
2. quick-update no --set → declared keys persist via per-module yaml
3. quick-update WITH --set → applies (used to be warned + dropped)
4. --set for unselected module → warned, no orphan section
5. prototype pollution → exit 1
6. --list-options bmm exit 0, --list-options nope exit 1

Net: -158 lines vs HEAD. The complex integration was load-bearing for
edge cases nobody actually needed; the simple post-install patch
covers the real use case (script a config value from CI) without the
schema gymnastics.
2026-04-28 19:48:30 -05:00
Brian Madison 8ab1de8501 fix(installer): forward-compat --set keys survive quick-update reinstalls
Found via end-to-end smoke test, not flagged by either bot review:
`--set bmm.future_thing=x` was persisted to config.toml on install #1
but silently dropped on the next quick-update reinstall, even though
the per-module _bmad/bmm/config.yaml retained it. The central
manifest's schema-strict partition stripped it because
collectModuleConfigQuick (the quick-update helper) never populated
setOverrideKeys for carried-forward unknown keys, and quickUpdate's
installConfig didn't thread setOverrideKeys into the install call.

This is the same bug class as the round-1 fix to collectModuleConfig
(CodeRabbit major #3155145084) but for the quick-update code path,
which has a separate collection helper.

Fix:
- Add OfficialModules._trackUnknownKeysAsOverrides(moduleName, schema)
  helper that walks collectedConfig[moduleName] and adds any non-schema
  key to setOverrideKeys[moduleName]. Without a schema, every key is
  treated as unknown (safe fallback for modules with no module.yaml).
- Call it from all four return paths in collectModuleConfigQuick:
  no-schema, parse-failed, hasNoConfig+subheader, silent+no-new-keys,
  and the regular end-of-method.
- Mirror ui.collectModuleConfigs's setOverrideKeys conversion in
  installer.quickUpdate so the Set→array round-trip lands in
  Config.build, and writeCentralConfig sees the exemption list.

Tests: +4 cases — collectModuleConfigQuick carry-forward of unknown
key, declared-key non-tracking under quick-update, and
_trackUnknownKeysAsOverrides no-schema fallback. Total 351 passing.

E2E smoke verified: --set <unknown>=x survives install→quick-update,
install→regular-update, and install→quick-update→regular-update with
a new --set added.
2026-04-28 19:22:34 -05:00
Brian Madison 7ad054f0e9 fix(installer): address fourth-round PR #2353 review comments
(1) Use process.exitCode instead of process.exit() after --list-options
write (CodeRabbit major). process.exit() forces immediate termination
even with pending I/O, which can truncate buffered writes when stdout
is piped or captured by CI. Await the write callback, set exitCode,
and return so the event loop drains naturally.

(2) Thread setOverrides through Config → OfficialModules.build for
headless callers (CodeRabbit major). Non-UI entry points (direct
installer.install({...}) without going through ui.collectModuleConfigs)
previously got an empty override map. Config now carries setOverrides
and the headless branch of OfficialModules.build also runs
loadExistingConfig + applyOverridesAfterSeeding('core') to mirror the
UI path's semantics. The UI path is unaffected because it takes the
moduleConfigs early-return.

(3) Evaluate function defaults under skipPrompts and accept-defaults
paths (CodeRabbit major). Both branches were dropping function defaults
silently, so any same-module dynamic default (`{other_key}` placeholder
in default:) disappeared under --yes. Two-pass: write non-function
defaults first so the answer bag is populated, then call function
defaults with that bag. Try/catch around the call surfaces resolution
failures as warnings instead of crashing the install.

(4) Track result-only schema keys as declared (Augment medium). A
schema entry with `result:` and no `prompt:` was being classified as
"unknown" when targeted by --set, producing a wrong warning and
overwriting the computed template output with the raw value. Added
declaredResultKeys parallel to declaredPromptKeys; an override on
either is now seeded as the answer so the result template still
renders ({value} substitution preserved). Carry-forward block
refactored to consume the same set.

(5) Diagnose non-object module.yaml under --list-options (Augment low).
The non-object branch silently flipped moduleScopedFailure with no
output. Now emits "module.yaml is not a valid object (got <type>)"
mirroring the catch branch, and the type guard also catches arrays
which typeof reports as 'object'.

(6) Reword --list-options doc cache scope (CodeRabbit minor).
"Installed at least once on this machine" → "currently cached official
modules" with a note that cache can be cleared or absent on ephemeral
CI workers — accurately reflects what the command can discover.

Tests: +4 cases — Config.build setOverrides threading and default,
formatOptionsList non-object yaml diagnostic and ok:false. Total 347
passing.
2026-04-28 18:59:17 -05:00
10 changed files with 542 additions and 440 deletions

View File

@ -118,7 +118,7 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
### Flag reference
| Flag | Purpose |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
| `--directory <path>` | Install into this directory (default: current working dir) |
| `--modules <a,b,c>` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. |
@ -183,9 +183,9 @@ npx bmad-method install --yes --action update \
### Module config overrides
`--set <module>.<key>=<value>` is the preferred way to provide answers that would otherwise be asked interactively. It's repeatable, scales to every module, and survives across upgrades because the values land in `_bmad/config.toml` next to the rest of your install state.
`--set <module>.<key>=<value>` lets you set any module config option non-interactively. It's repeatable and scales to every module — present and future. The flag is applied as a post-install patch: the installer runs its normal flow first, then `--set` upserts each value into `_bmad/config.toml` (team scope) or `_bmad/config.user.toml` (user scope), and into `_bmad/<module>/config.yaml` so declared values carry forward to the next install.
**Example — install bmm without using `docs/` for project knowledge:**
**Example — install bmm with explicit project knowledge and skill level:**
```bash
npx bmad-method install --yes \
@ -201,18 +201,21 @@ npx bmad-method install --yes \
npx bmad-method install --list-options bmm
```
`--list-options` (no argument) lists every key the installer can find locally — built-in modules (`core`, `bmm`) plus any external officials that have been installed at least once on this machine. Community and custom modules aren't enumerated here; read the module's `module.yaml` directly to see what keys it declares.
`--list-options` (no argument) lists every key the installer can find locally — built-in modules (`core`, `bmm`) plus any currently cached official modules. The cache is per-machine and can be cleared, so previously installed officials won't appear on a fresh checkout or an ephemeral CI worker until they're installed again. Community and custom modules aren't enumerated here; read the module's `module.yaml` directly to see what keys it declares.
**Validation rules:**
**How it works:**
- `<module>` must be in `--modules` (or core, which is always installed). Setting a value for a module you didn't include prints a warning and the value is ignored.
- `<key>` is matched against the module's `module.yaml` declarations. An unknown key prints a warning but still gets persisted to `config.toml` — useful for forward-compatibility or for community modules whose schema isn't validated here.
- `single-select` values are not validated against the allowed choices. The value lands in config as-is, even if it falls outside the module's enumeration.
- **Routing.** The patch step looks for `[modules.<module>] <key>` (or `[core] <key>`) in `config.user.toml` first; if found there, it updates that file. Otherwise it writes to the team-scope `config.toml`. So user-scope keys (e.g. `core.user_name`, `bmm.user_skill_level`) end up in `config.user.toml` and team-scope keys end up in `config.toml`, matching the partition the installer uses.
- **Verbatim values.** The value is written exactly as you provided it — no `result:` template rendering. To get the rendered form (e.g. `{project-root}/research`), pass it explicitly: `--set bmm.project_knowledge='{project-root}/research'`.
- **Carry-forward, declared keys.** Values for keys declared in `module.yaml` survive subsequent installs because they're also written to `_bmad/<module>/config.yaml`, which the installer reads as the prompt default on the next run.
- **Carry-forward, undeclared keys.** A value for a key the module's schema doesn't declare lands 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 you need it sticky, or edit `_bmad/config.toml` directly.
- **No validation.** `single-select` values aren't checked against the allowed choices, and unknown keys aren't rejected — whatever you assert is written.
- **Modules not in `--modules`.** Setting a value for a module you didn't include prints a warning and the value is dropped (no file gets created for an uninstalled module).
The legacy core shortcuts (`--user-name`, `--output-folder`, etc.) still work and remain documented for backward compatibility, but `--set core.user_name=...` is equivalent and uses the same code path.
The legacy core shortcuts (`--user-name`, `--output-folder`, etc.) still work and remain documented for backward compatibility, but `--set core.user_name=...` is equivalent.
:::note[Quick-update is unaffected]
`bmad install --action quick-update` preserves the existing `config.toml` answers as-is. `--set` flags passed alongside `--action quick-update` are ignored — the installer prints a warning so scripted runs surface the mismatch in CI logs. To change a stored value, run `bmad install --action update` (or just `bmad install`) instead.
:::note[Works with quick-update]
`--set` is a post-install patch, so it applies the same way regardless of action type. Under `bmad install --action quick-update` (or `--yes` against an existing install, where quick-update is the default), `--set` patches the central config files at the end just like a regular install.
:::
:::caution[Rate limit on shared IPs]

View File

@ -2988,21 +2988,17 @@ async function runTests() {
// ============================================================
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
try {
const { parseSetEntry, parseSetEntries } = require('../tools/installer/set-overrides');
const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides');
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
// parseSetEntry — happy path
// ---- Parser ----------------------------------------------------------
const ok = parseSetEntry('bmm.project_knowledge=research');
assert(
ok.module === 'bmm' && ok.key === 'project_knowledge' && ok.value === 'research',
'parseSetEntry splits <module>.<key>=<value> correctly',
);
assert(parseSetEntry('bmm.weird=a=b=c').value === 'a=b=c', 'parseSetEntry preserves additional "=" inside the value');
// parseSetEntry — value containing '='
const okEq = parseSetEntry('bmm.weird=a=b=c');
assert(okEq.value === 'a=b=c', 'parseSetEntry preserves additional "=" inside the value');
// parseSetEntry — malformed inputs
const badInputs = ['no-equals', 'no-dot=value', '=value', '.=value', 'foo.=value', '.bar=value', ''];
let allBadThrow = true;
for (const bad of badInputs) {
@ -3015,23 +3011,17 @@ async function runTests() {
}
assert(allBadThrow, `parseSetEntry rejects malformed inputs (${badInputs.length} cases)`);
// parseSetEntries — multiple entries collapse into a {module: {key: value}} map
const multi = parseSetEntries(['bmm.project_knowledge=research', 'bmm.user_skill_level=expert', 'core.user_name=Brian']);
assert(
multi.bmm.project_knowledge === 'research' && multi.bmm.user_skill_level === 'expert' && multi.core.user_name === 'Brian',
'parseSetEntries groups by module',
);
// parseSetEntries — later entry wins for the same key
const later = parseSetEntries(['bmm.x=first', 'bmm.x=second']);
assert(later.bmm.x === 'second', 'parseSetEntries: later --set entry overrides earlier');
// parseSetEntries — non-array / missing input → empty object
assert(parseSetEntries(['bmm.x=first', 'bmm.x=second']).bmm.x === 'second', 'parseSetEntries: later --set entry overrides earlier');
const empty = parseSetEntries();
assert(empty && Object.keys(empty).length === 0, 'parseSetEntries() returns empty object when called without args');
// parseSetEntries — prototype-pollution guard. `--set __proto__.x=1` would
// otherwise reach `overrides.__proto__[x] = 1` and pollute Object.prototype.
// Prototype-pollution guard. `--set __proto__.x=1` would otherwise reach
// `overrides.__proto__[x] = 1` and pollute every plain object.
const polluteProbe = {};
let pollutionThrown = false;
try {
@ -3049,40 +3039,188 @@ async function runTests() {
}
assert(constructorThrown, 'parseSetEntries rejects "constructor" as a key name');
// discoverOfficialModuleYamls + formatOptionsList read the on-disk
// external-module cache. Point that env at a temp dir so test results
// don't depend on whatever the developer / CI runner has cached.
// ---- tomlString ------------------------------------------------------
assert(tomlString('hello') === '"hello"', 'tomlString quotes a plain string');
assert(tomlString('with "quotes"') === String.raw`"with \"quotes\""`, 'tomlString escapes embedded double-quotes');
assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes');
assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines');
// ---- upsertTomlKey: insert into existing section ---------------------
{
const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`;
const after = upsertTomlKey(before, '[modules.bmm]', 'future_thing', '"persists"');
assert(after.includes('future_thing = "persists"'), 'upsertTomlKey inserts a new key into an existing section');
assert(/project_knowledge = "{project-root}\/docs"/.test(after), 'upsertTomlKey preserves existing keys');
}
// ---- upsertTomlKey: replace existing key, keep comment tail ----------
{
const before = `[core]\nuser_name = "old" # set on first install\n`;
const after = upsertTomlKey(before, '[core]', 'user_name', '"Brian"');
assert(/user_name = "Brian"\s+# set on first install/.test(after), 'upsertTomlKey preserves trailing comments');
assert(!after.includes('"old"'), 'upsertTomlKey replaces the prior value');
}
// ---- upsertTomlKey: section missing → append new section -------------
{
const before = `[core]\nuser_name = "Brian"\n`;
const after = upsertTomlKey(before, '[modules.bmm]', 'project_knowledge', '"research"');
assert(after.includes('[modules.bmm]'), 'upsertTomlKey appends a new section when missing');
assert(after.includes('project_knowledge = "research"'), 'upsertTomlKey appends the key under the new section');
// Existing section remains untouched
assert(after.indexOf('[core]') < after.indexOf('[modules.bmm]'), 'upsertTomlKey adds the new section AFTER existing content');
}
// ---- upsertTomlKey: empty file ---------------------------------------
{
const after = upsertTomlKey('', '[core]', 'user_name', '"Brian"');
assert(after.startsWith('[core]'), 'upsertTomlKey on an empty string emits the section header');
assert(after.includes('user_name = "Brian"'), 'upsertTomlKey on an empty string writes the key');
}
// ---- upsertTomlKey: trailing newline preserved -----------------------
{
const withTrailing = upsertTomlKey('[core]\nuser_name = "old"\n', '[core]', 'user_name', '"new"');
assert(withTrailing.endsWith('\n'), 'upsertTomlKey preserves trailing newline');
const withoutTrailing = upsertTomlKey('[core]\nuser_name = "old"', '[core]', 'user_name', '"new"');
assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline');
}
// ---- applySetOverrides happy path ------------------------------------
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-'));
const bmadDir = path.join(tmp, '_bmad');
await fs.ensureDir(bmadDir);
// Seed a realistic post-install state: team config has bmm.project_knowledge,
// user config has core.user_name. The applySetOverrides router should
// route bmm.user_skill_level → user.toml (already there), core.user_name
// update → user.toml (already there), and a brand-new key → team.toml.
await fs.writeFile(
path.join(bmadDir, 'config.toml'),
'[core]\nproject_name = "demo"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n',
'utf8',
);
await fs.writeFile(
path.join(bmadDir, 'config.user.toml'),
'[core]\nuser_name = "OldName"\n\n[modules.bmm]\nuser_skill_level = "intermediate"\n',
'utf8',
);
// Per-module config.yaml stubs are the "is this module installed?"
// signal applySetOverrides uses to skip uninstalled-module overrides.
await fs.ensureDir(path.join(bmadDir, 'core'));
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'project_name: demo\n', 'utf8');
await fs.ensureDir(path.join(bmadDir, 'bmm'));
await fs.writeFile(
path.join(bmadDir, 'bmm', 'config.yaml'),
'project_knowledge: "{project-root}/docs"\nuser_skill_level: intermediate\n',
'utf8',
);
const overrides = {
core: { user_name: 'Brian' },
bmm: { user_skill_level: 'expert', future_thing: 'persists' },
};
const applied = await applySetOverrides(overrides, bmadDir);
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
const user = await fs.readFile(path.join(bmadDir, 'config.user.toml'), 'utf8');
assert(user.includes('user_name = "Brian"'), 'applySetOverrides updates user-scope key in config.user.toml');
assert(user.includes('user_skill_level = "expert"'), 'applySetOverrides updates pre-existing user-scope key in config.user.toml');
assert(team.includes('future_thing = "persists"'), 'applySetOverrides routes brand-new key to team config.toml');
assert(team.includes('project_knowledge = "{project-root}/docs"'), 'applySetOverrides leaves untouched team keys alone');
assert(!team.includes('user_name = "Brian"'), 'applySetOverrides does NOT duplicate user-scope key into team file');
const summary = applied
.map((a) => `${a.module}.${a.key}->${a.scope}`)
.sort()
.join(',');
assert(
summary === 'bmm.future_thing->team,bmm.user_skill_level->user,core.user_name->user',
`applySetOverrides reports correct routing decisions (got: ${summary})`,
);
await fs.remove(tmp).catch(() => {});
}
// ---- applySetOverrides creates config.user.toml if missing -----------
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-nouser-'));
const bmadDir = path.join(tmp, '_bmad');
await fs.ensureDir(bmadDir);
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
await fs.ensureDir(path.join(bmadDir, 'core'));
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
// Override targets a key only in team config; routes to team. user.toml
// never gets created in this case (correct — no user-scope writes).
await applySetOverrides({ core: { user_name: 'Updated' } }, bmadDir);
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
assert(team.includes('user_name = "Updated"'), 'applySetOverrides updates team key when user.toml is absent');
assert(
!(await fs.pathExists(path.join(bmadDir, 'config.user.toml'))),
'applySetOverrides does not create config.user.toml unnecessarily',
);
await fs.remove(tmp).catch(() => {});
}
// ---- applySetOverrides skips modules without per-module config.yaml --
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-skip-'));
const bmadDir = path.join(tmp, '_bmad');
await fs.ensureDir(bmadDir);
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
await fs.ensureDir(path.join(bmadDir, 'core'));
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
// bmm is not installed (no `_bmad/bmm/config.yaml`). The override for
// bmm should be silently skipped, no `[modules.bmm]` section created.
const applied = await applySetOverrides({ bmm: { foo: 'bar' }, core: { user_name: 'Updated' } }, bmadDir);
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
assert(!team.includes('[modules.bmm]'), 'applySetOverrides does NOT create section for uninstalled module');
assert(team.includes('user_name = "Updated"'), 'applySetOverrides still applies overrides for installed modules');
assert(applied.length === 1 && applied[0].module === 'core', 'applySetOverrides reports only the installed-module entries');
await fs.remove(tmp).catch(() => {});
}
// ---- applySetOverrides: empty/missing input is a no-op ---------------
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-empty-'));
const bmadDir = path.join(tmp, '_bmad');
await fs.ensureDir(bmadDir);
const empty1 = await applySetOverrides({}, bmadDir);
const empty2 = await applySetOverrides(null, bmadDir);
const empty3 = await applySetOverrides(undefined, bmadDir);
assert(
empty1.length === 0 && empty2.length === 0 && empty3.length === 0,
'applySetOverrides is a no-op for empty/null/undefined input',
);
await fs.remove(tmp).catch(() => {});
}
// ---- discoverOfficialModuleYamls + formatOptionsList -----------------
// These read the on-disk external-module cache. Point that env at a temp
// dir so test results don't depend on whatever the developer / CI runner
// has cached.
const priorCacheEnv44 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
const tempCacheDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-list-options-cache-'));
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir44;
try {
// discoverOfficialModuleYamls includes core and bmm built-ins.
const discovered = await discoverOfficialModuleYamls();
const codes = new Set(discovered.map((d) => d.code));
assert(codes.has('core') && codes.has('bmm'), 'discoverOfficialModuleYamls finds core and bmm built-ins');
const coreEntry = discovered.find((d) => d.code === 'core');
assert(coreEntry && coreEntry.source === 'built-in', 'core is reported with source="built-in"');
// formatOptionsList rendering: bmm-only filter shows the project_knowledge key from issue #1663.
const bmmListing = await formatOptionsList('bmm');
assert(bmmListing.ok === true, '--list-options bmm reports ok: true');
assert(bmmListing.text.includes('bmm.project_knowledge'), '--list-options bmm renders bmm.project_knowledge');
assert(bmmListing.text.includes('bmm.user_skill_level'), '--list-options bmm renders bmm.user_skill_level');
assert(bmmListing.text.includes('beginner | intermediate | expert'), '--list-options renders single-select choices');
// Case-insensitive match: `--list-options BMM` and `bmm` resolve to the same entry.
const bmmUpperListing = await formatOptionsList('BMM');
assert(bmmUpperListing.ok === true, '--list-options BMM (uppercase) finds the bmm built-in');
assert(bmmUpperListing.text.includes('bmm.project_knowledge'), '--list-options BMM renders bmm.project_knowledge');
// Case-insensitive filter.
const bmmUpper = await formatOptionsList('BMM');
assert(bmmUpper.ok === true && bmmUpper.text.includes('bmm.project_knowledge'), '--list-options is case-insensitive');
// formatOptionsList for an unknown module gives a helpful message AND ok: false
// so install.js can exit non-zero (CI scripts can detect typos).
const unknownListing = await formatOptionsList('definitely-not-a-module');
assert(unknownListing.ok === false, '--list-options <unknown> reports ok: false (non-zero exit signal)');
assert(
unknownListing.text.includes("No locally-known module.yaml for 'definitely-not-a-module'"),
'--list-options handles unknown module gracefully',
);
// Unknown module → non-zero exit signal.
const unknown = await formatOptionsList('definitely-not-a-module');
assert(unknown.ok === false, '--list-options <unknown> reports ok: false');
assert(unknown.text.includes('No locally-known module.yaml'), '--list-options unknown explains the miss');
} finally {
if (priorCacheEnv44 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
@ -3091,133 +3229,6 @@ async function runTests() {
}
await fs.remove(tempCacheDir44).catch(() => {});
}
// partition() in writeCentralConfig respects setOverrideKeys: an unknown key
// for a known schema must survive when the user asserted it via --set.
const tmp44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-44-'));
const bmadDir44 = path.join(tmp44, '_bmad');
await fs.ensureDir(bmadDir44);
const mg = new ManifestGenerator({ ides: [] });
mg.updatedModules = ['core', 'bmm'];
const moduleConfigsForWrite = {
core: { user_name: 'Brian' },
bmm: { project_knowledge: '/proj/research', future_thing: 'pre-seeded' },
};
const setOverrideKeys = { bmm: ['future_thing'] };
await mg.writeCentralConfig(bmadDir44, moduleConfigsForWrite, setOverrideKeys);
const teamToml = await fs.readFile(path.join(bmadDir44, 'config.toml'), 'utf8');
assert(teamToml.includes('project_knowledge = "/proj/research"'), 'writeCentralConfig writes a known schema key');
assert(teamToml.includes('future_thing = "pre-seeded"'), 'writeCentralConfig keeps an unknown key listed in setOverrideKeys');
// Same fixture, no override → unknown key is dropped (control case).
const tmp44b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-44b-'));
const bmadDir44b = path.join(tmp44b, '_bmad');
await fs.ensureDir(bmadDir44b);
const mg2 = new ManifestGenerator({ ides: [] });
mg2.updatedModules = ['core', 'bmm'];
await mg2.writeCentralConfig(bmadDir44b, moduleConfigsForWrite, {});
const teamToml2 = await fs.readFile(path.join(bmadDir44b, 'config.toml'), 'utf8');
assert(
!teamToml2.includes('future_thing'),
'writeCentralConfig drops an unknown key when not asserted via --set (schema-strict default holds)',
);
await fs.remove(tmp44).catch(() => {});
await fs.remove(tmp44b).catch(() => {});
// Integration: --set actually applies through collectModuleConfig with skipPrompts.
// Constructs OfficialModules directly (no UI), runs the bmm collector, asserts
// the override value lands in collectedConfig with the result template rendered.
const tmp44c = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-44c-'));
try {
const om = new OfficialModules({
setOverrides: { bmm: { project_knowledge: 'research', user_skill_level: 'expert' } },
});
om.skipPrompts = true;
om._silentConfig = true;
om.modulesToCustomize = new Set();
om.allAnswers = {};
om._existingConfig = {};
await om.collectModuleConfig('bmm', tmp44c, true, true);
assert(
om.collectedConfig.bmm?.project_knowledge === '{project-root}/research',
'collectModuleConfig pre-fills bmm.project_knowledge from --set and renders {project-root}/{value}',
);
assert(
om.collectedConfig.bmm?.user_skill_level === 'expert',
'collectModuleConfig pre-fills bmm.user_skill_level from --set ({value} template)',
);
// Unrelated bmm keys still get their schema defaults applied.
assert(
typeof om.collectedConfig.bmm?.planning_artifacts === 'string',
'collectModuleConfig still fills non-overridden bmm keys with schema defaults under skipPrompts',
);
} catch (error) {
console.log(`${colors.red} collectModuleConfig --set integration failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
await fs.remove(tmp44c).catch(() => {});
// Carry-forward: an unknown key persisted by a prior install survives the
// next collectModuleConfig even when --set isn't repeated. This is the
// "persist across upgrades" contract from #1663 (CodeRabbit major fix).
const tmp44d = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-44d-'));
try {
const om = new OfficialModules();
om.skipPrompts = true;
om._silentConfig = true;
om.modulesToCustomize = new Set();
om.allAnswers = {};
// Simulate prior install: future_thing was --set on run #1, persisted to
// _bmad/bmm/config.yaml, and is now loaded as _existingConfig.
om._existingConfig = { bmm: { future_thing: 'pre-seeded', user_skill_level: 'beginner' } };
await om.collectModuleConfig('bmm', tmp44d, true, true);
assert(om.collectedConfig.bmm?.future_thing === 'pre-seeded', 'collectModuleConfig carries unknown key forward from _existingConfig');
assert(
om.setOverrideKeys?.bmm?.has('future_thing'),
'carried-forward keys are tracked in setOverrideKeys so writeCentralConfig keeps them',
);
// Declared keys from _existingConfig are NOT carried forward by this
// mechanism — they go through normal prompt processing and would be
// seeded as defaults via buildQuestion's existingValue lookup.
assert(!om.setOverrideKeys?.bmm?.has('user_skill_level'), 'carry-forward leaves declared keys to the normal prompt path');
} catch (error) {
console.log(`${colors.red} collectModuleConfig carry-forward failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
await fs.remove(tmp44d).catch(() => {});
// applyOverridesAfterSeeding mirrors the carry-forward behavior for the
// skip-collection path used by `core` (when seeded by --yes / legacy
// shortcuts) so unknown core keys persisted on a prior run survive
// subsequent installs even without re-passing --set.
try {
const om = new OfficialModules({
// No new --set entries this run — only prior persisted unknown.
setOverrides: {},
});
om._existingConfig = { core: { future_core_thing: 'persisted-from-run-1' } };
// Simulate the seeded-core state ui.js leaves behind under --yes.
om.collectedConfig.core = { user_name: 'Brian', project_name: 'demo' };
await om.applyOverridesAfterSeeding('core');
assert(
om.collectedConfig.core?.future_core_thing === 'persisted-from-run-1',
'applyOverridesAfterSeeding carries unknown core key forward from _existingConfig',
);
assert(om.setOverrideKeys?.core?.has('future_core_thing'), 'carried-forward core keys are tracked in setOverrideKeys');
assert(!om.setOverrideKeys?.core?.has('user_name'), 'declared core keys (user_name) are not flagged as overrides');
} catch (error) {
console.log(`${colors.red} applyOverridesAfterSeeding carry-forward failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
} catch (error) {
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);

View File

@ -62,10 +62,17 @@ module.exports = {
const moduleArg = options.listOptions === true ? null : options.listOptions;
const { text, ok } = await formatOptionsList(moduleArg);
const stream = ok ? process.stdout : process.stderr;
stream.write(text + '\n');
// Non-zero exit when a single-module lookup misses so a CI typo like
// `--list-options bmn` doesn't look successful in scripts.
process.exit(ok ? 0 : 1);
// process.exit() forces immediate termination and can truncate the
// buffered write when stdout/stderr is piped or captured by CI. Wait
// for the write to flush, then set process.exitCode and return so the
// event loop drains naturally. Non-zero exit when a single-module
// lookup misses so a CI typo like `--list-options bmn` doesn't look
// successful in scripts.
await new Promise((resolve, reject) => {
stream.write(text + '\n', (error) => (error ? reject(error) : resolve()));
});
process.exitCode = ok ? 0 : 1;
return;
}
// Set debug flag as environment variable for all components
@ -95,13 +102,13 @@ module.exports = {
process.exit(0);
}
// Handle quick update separately
// Handle quick update separately. --set is a post-install TOML patch so
// it works the same way for quick-update as for a regular install — the
// installer runs, then `applySetOverrides` patches the central config
// files. Pass the parsed overrides through.
if (config.actionType === 'quick-update') {
if (options.set && options.set.length > 0) {
await prompts.log.warn(
'--set flags are ignored under quick-update (it preserves existing answers). Re-run with --action update to apply them.',
);
}
const { parseSetEntries } = require('../set-overrides');
config.setOverrides = parseSetEntries(options.set || []);
const result = await installer.quickUpdate(config);
await prompts.log.success('Quick update complete!');
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);

View File

@ -14,7 +14,7 @@ class Config {
moduleConfigs,
quickUpdate,
channelOptions,
setOverrideKeys,
setOverrides,
}) {
this.directory = directory;
this.modules = Object.freeze([...modules]);
@ -27,11 +27,11 @@ class Config {
this._quickUpdate = quickUpdate;
// channelOptions carry a Map + Set; don't deep-freeze.
this.channelOptions = channelOptions || null;
// Per-module list of keys originating from `--set <module>.<key>=<value>`
// that are NOT in the module's prompt schema. The manifest writer keeps
// these through the schema-strict partition so user-asserted overrides
// survive into config.toml even when the schema doesn't declare them.
this.setOverrideKeys = setOverrideKeys || {};
// Parsed `--set <module>.<key>=<value>` overrides, applied as a TOML
// patch AFTER the install finishes. Shape: { moduleCode: { key: value } }.
// Intentionally NOT integrated with the prompt/template/schema flow; see
// `tools/installer/set-overrides.js` for the rationale and tradeoffs.
this.setOverrides = setOverrides || {};
Object.freeze(this);
}
@ -57,7 +57,7 @@ class Config {
moduleConfigs: userInput.moduleConfigs || null,
quickUpdate: userInput._quickUpdate || false,
channelOptions: userInput.channelOptions || null,
setOverrideKeys: userInput.setOverrideKeys || {},
setOverrides: userInput.setOverrides || {},
});
}

View File

@ -308,9 +308,21 @@ class Installer {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
moduleConfigs,
setOverrideKeys: config.setOverrideKeys || {},
});
// Apply post-install --set TOML patches. Runs after writeCentralConfig
// (inside generateManifests above) so the patch operates on the
// freshly written `_bmad/config.toml` / `_bmad/config.user.toml`.
// See `tools/installer/set-overrides.js` for routing rules.
if (config.setOverrides && Object.keys(config.setOverrides).length > 0) {
const { applySetOverrides } = require('../set-overrides');
const applied = await applySetOverrides(config.setOverrides, paths.bmadDir);
if (applied.length > 0) {
const summary = applied.map((a) => `${a.module}.${a.key}${a.file}`).join(', ');
await prompts.log.info(`Applied --set overrides: ${summary}`);
}
}
message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
addResult('Help catalog', 'ok');
@ -1284,6 +1296,10 @@ class Installer {
ides: configuredIdes,
coreConfig: quickModules.collectedConfig.core,
moduleConfigs: quickModules.collectedConfig,
// Forward `--set` overrides so the post-install patch step
// (`applySetOverrides`) runs at the end of quick-update too. The
// installer.install path applies them after writeCentralConfig.
setOverrides: config.setOverrides || {},
actionType: 'install',
_quickUpdate: true,
_preserveModules: skippedModules,

View File

@ -81,11 +81,7 @@ class ManifestGenerator {
await this.collectAgentsFromModuleYaml();
// Write manifest files and collect their paths
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(
bmadDir,
options.moduleConfigs || {},
options.setOverrideKeys || {},
);
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {});
const manifestFiles = [
await this.writeMainManifest(cfgDir),
await this.writeSkillManifest(cfgDir),
@ -429,7 +425,7 @@ class ManifestGenerator {
* _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer).
* @returns {string[]} Paths to the written config files
*/
async writeCentralConfig(bmadDir, moduleConfigs, setOverrideKeys = {}) {
async writeCentralConfig(bmadDir, moduleConfigs) {
const teamPath = path.join(bmadDir, 'config.toml');
const userPath = path.join(bmadDir, 'config.user.toml');
@ -478,20 +474,17 @@ class ManifestGenerator {
// Partition a module's answered config into team vs user buckets.
// For non-core modules: strip core keys always; when we know the module's
// own schema, also drop keys it doesn't declare — unless the user
// asserted them via `--set <module>.<key>=<value>`, in which case we
// keep them (warn-and-write semantics from issue #1663). Unknown-schema
// modules (external / marketplace) fall through with their remaining
// answers as team so they don't vanish from the config.
// own schema, also drop keys it doesn't declare. Unknown-schema modules
// (external / marketplace) fall through with their remaining answers as
// team so they don't vanish from the config.
const partition = (moduleName, cfg, onlyDeclaredKeys = false) => {
const team = {};
const user = {};
const scopes = scopeByModuleKey[moduleName] || {};
const isCore = moduleName === 'core';
const overrideKeys = new Set(setOverrideKeys[moduleName] || []);
for (const [key, value] of Object.entries(cfg || {})) {
if (!isCore && coreKeys.has(key)) continue;
if (onlyDeclaredKeys && !(key in scopes) && !overrideKeys.has(key)) continue;
if (onlyDeclaredKeys && !(key in scopes)) continue;
if (scopes[key] === 'user') {
user[key] = value;
} else {

View File

@ -190,7 +190,8 @@ async function formatOptionsList(moduleCode) {
if (moduleCode) moduleScopedFailure = true;
continue;
}
if (!parsed || typeof parsed !== 'object') {
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;
}

View File

@ -20,88 +20,6 @@ class OfficialModules {
// pre-install config collection and the install step agree on which ref
// to clone.
this.channelOptions = options.channelOptions || null;
// Per-module CLI overrides from `--set <module>.<key>=<value>`.
// Shape: { moduleCode: { key: rawStringValue } }. Keys matching a
// declared prompt skip the prompt; unknown keys are persisted with
// a warning so future / community modules can opt in.
this.setOverrides = options.setOverrides || {};
}
/**
* Apply `--set <module>.<key>=<value>` overrides AFTER normal collection.
*
* Used for modules whose `collectModuleConfig` was skipped currently only
* `core`, when its config was seeded by `--yes` defaults or by legacy
* core-shortcut flags (--user-name/--output-folder/etc.). For all other
* modules, override handling is integrated into `collectModuleConfig`.
*
* Validates known keys against the module's `module.yaml` schema (located
* via `getModulePath`); unknown keys are warned but persisted, mirroring
* the schema-handling for non-core modules. Core's `result:` templates are
* all `{value}` (or `{project-root}/{value}` for `output_folder`, which the
* legacy flag handlers also write raw), so writing the raw override value
* preserves parity with the `--user-name` / `--output-folder` shortcuts.
*
* @param {string} moduleName
*/
async applyOverridesAfterSeeding(moduleName) {
const overrides = this.setOverrides[moduleName] || {};
const priorConfig = this._existingConfig?.[moduleName];
const hasPrior = priorConfig && typeof priorConfig === 'object' && !Array.isArray(priorConfig);
if (Object.keys(overrides).length === 0 && !hasPrior) return;
if (!this.collectedConfig[moduleName]) this.collectedConfig[moduleName] = {};
for (const [key, value] of Object.entries(overrides)) {
this.collectedConfig[moduleName][key] = value;
}
if (!this.setOverrideKeys) this.setOverrideKeys = {};
if (!this.setOverrideKeys[moduleName]) this.setOverrideKeys[moduleName] = new Set();
// Try to load the module's schema. When available we can distinguish
// declared keys from unknown ones; when not (built-in is missing or
// unparseable — rare for `core`), we treat every prior + override key as
// unknown so carry-forward still runs and writeCentralConfig keeps them.
let schema = null;
const schemaPath = path.join(getModulePath(moduleName), 'module.yaml');
if (await fs.pathExists(schemaPath)) {
try {
schema = yaml.parse(await fs.readFile(schemaPath, 'utf8'));
} catch {
// schema unparseable — fall through to no-schema behavior
}
}
const declaredKeys = new Set();
if (schema && typeof schema === 'object') {
for (const [key, decl] of Object.entries(schema)) {
if (decl && typeof decl === 'object' && 'prompt' in decl) declaredKeys.add(key);
}
}
// Warn + track unknown keys from this run's --set entries.
for (const key of Object.keys(overrides)) {
if (!declaredKeys.has(key)) {
await prompts.log.warn(
`--set ${moduleName}.${key} — '${key}' is not a declared config key for module '${moduleName}'; persisted but unused by current install.`,
);
this.setOverrideKeys[moduleName].add(key);
}
}
// Carry forward any non-schema keys persisted by a prior install. Mirrors
// the carry-forward logic in `collectModuleConfig` so the skip-collection
// path (e.g. core under --yes) doesn't drop unknown keys on subsequent
// runs. Without this, `--set core.future=x` lands in config.toml on run #1
// and would silently disappear on the next install. (#1663 forward-compat)
if (hasPrior) {
for (const [key, value] of Object.entries(priorConfig)) {
if (declaredKeys.has(key)) continue;
if (key in this.collectedConfig[moduleName]) continue; // already set this run
this.collectedConfig[moduleName][key] = value;
this.setOverrideKeys[moduleName].add(key);
}
}
}
/**
@ -1579,7 +1497,6 @@ class OfficialModules {
const questions = [];
const staticAnswers = {};
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
const declaredPromptKeys = new Set();
for (const key of configKeys) {
const item = moduleConfig[key];
@ -1598,7 +1515,6 @@ class OfficialModules {
// Handle interactive values (with prompt)
if (item.prompt) {
declaredPromptKeys.add(key);
const question = await this.buildQuestion(moduleName, key, item, moduleConfig);
if (question) {
questions.push(question);
@ -1606,63 +1522,8 @@ class OfficialModules {
}
}
// Apply --set <module>.<key>=<value> overrides for this module.
// - Known prompt key → answer pre-filled, prompt skipped (interactive + --yes).
// - Unknown prompt key → warn, then write directly to collectedConfig at end of
// this method. The corresponding key is also tracked on this.setOverrideKeys
// so writeCentralConfig knows to keep it through the schema-strict partition.
const moduleOverrides = this.setOverrides[moduleName] || {};
const seededOverrideKeys = new Set();
const unknownOverrideKeys = [];
for (const [overrideKey, overrideValue] of Object.entries(moduleOverrides)) {
if (declaredPromptKeys.has(overrideKey)) {
seededOverrideKeys.add(overrideKey);
} else {
unknownOverrideKeys.push([overrideKey, overrideValue]);
}
}
if (unknownOverrideKeys.length > 0) {
for (const [overrideKey] of unknownOverrideKeys) {
await prompts.log.warn(
`--set ${moduleName}.${overrideKey} — '${overrideKey}' is not a declared config key for module '${moduleName}'; persisted but unused by current install.`,
);
}
}
// Collect all answers (static + prompted). Pre-seed override answers
// so the prompt loop and skipPrompts path both see them as already-set.
// Collect all answers (static + prompted)
let allAnswers = { ...staticAnswers };
for (const key of seededOverrideKeys) {
allAnswers[`${moduleName}_${key}`] = moduleOverrides[key];
}
// Pre-write raw override values into collectedConfig so dynamic-default
// resolvers (`buildQuestion`'s function default) can see them when a
// sibling key uses a `{other_key}` placeholder. The fallback chain in
// that closure is: current prompt batch → `this.collectedConfig[mod]`,
// and overridden keys are removed from the prompt batch — without this
// pre-write the placeholder lookup would miss them. The raw value is
// overwritten with the template-rendered version after prompts complete.
if (seededOverrideKeys.size > 0) {
if (!this.collectedConfig[moduleName]) this.collectedConfig[moduleName] = {};
for (const key of seededOverrideKeys) {
this.collectedConfig[moduleName][key] = moduleOverrides[key];
}
}
// Drop pre-seeded questions so the user is not re-prompted and so
// skipPrompts mode doesn't overwrite the override with the default.
// In-place mutation keeps the rest of this method's `questions` references
// pointing at the filtered list without renaming a local through 100+ lines.
if (seededOverrideKeys.size > 0) {
const remaining = questions.filter((q) => !seededOverrideKeys.has(q.name.replace(`${moduleName}_`, '')));
questions.length = 0;
questions.push(...remaining);
}
if (seededOverrideKeys.size > 0 && !this._silentConfig) {
const list = [...seededOverrideKeys].map((k) => `${moduleName}.${k}`).join(', ');
await prompts.log.info(`Applying --set overrides: ${list}`);
}
// If there are questions to ask, prompt for accepting defaults vs customizing
if (questions.length > 0) {
@ -1869,47 +1730,6 @@ class OfficialModules {
}
}
// Persist any unknown --set keys for this module (warn-and-write semantics).
// The keys are also tracked so writeCentralConfig keeps them through the
// schema-strict partition for officials.
if (unknownOverrideKeys.length > 0) {
if (!this.collectedConfig[moduleName]) this.collectedConfig[moduleName] = {};
if (!this.setOverrideKeys) this.setOverrideKeys = {};
if (!this.setOverrideKeys[moduleName]) this.setOverrideKeys[moduleName] = new Set();
for (const [overrideKey, overrideValue] of unknownOverrideKeys) {
this.collectedConfig[moduleName][overrideKey] = overrideValue;
this.setOverrideKeys[moduleName].add(overrideKey);
}
}
// Carry forward unknown keys persisted by a prior install. Without this,
// a value originally set via `--set <module>.future_thing=...` lands in
// `_bmad/<module>/config.yaml` on run #1, but the next collectModuleConfig
// rebuilds collectedConfig from prompt answers only — the unknown key
// would be silently dropped from the regenerated config.toml. Tracking
// the carried keys in setOverrideKeys ensures the schema-strict partition
// in writeCentralConfig keeps them. (#1663 forward-compat contract)
const priorConfig = this._existingConfig?.[moduleName];
if (priorConfig && typeof priorConfig === 'object' && !Array.isArray(priorConfig)) {
const declaredAndStaticKeys = new Set(declaredPromptKeys);
for (const key of configKeys) {
const item = moduleConfig[key];
if (item && typeof item === 'object' && item.result && !item.prompt) {
declaredAndStaticKeys.add(key);
}
}
for (const [key, value] of Object.entries(priorConfig)) {
if (declaredAndStaticKeys.has(key)) continue;
// Already written by this run (e.g. via unknown --set above): leave alone.
if (this.collectedConfig[moduleName] && key in this.collectedConfig[moduleName]) continue;
if (!this.collectedConfig[moduleName]) this.collectedConfig[moduleName] = {};
if (!this.setOverrideKeys) this.setOverrideKeys = {};
if (!this.setOverrideKeys[moduleName]) this.setOverrideKeys[moduleName] = new Set();
this.collectedConfig[moduleName][key] = value;
this.setOverrideKeys[moduleName].add(key);
}
}
await this.displayModulePostConfigNotes(moduleName, moduleConfig);
}

View File

@ -1,12 +1,31 @@
// `--set <module>.<key>=<value>` is a post-install patch. The installer runs
// its normal flow and writes `_bmad/config.toml`, `_bmad/config.user.toml`,
// and `_bmad/<module>/config.yaml`; afterwards `applySetOverrides` upserts
// each override into those files.
//
// This is intentionally NOT integrated with the prompt/template/schema
// system. Tradeoffs:
// - No `result:` template rendering: `--set bmm.project_knowledge=research`
// writes "research" verbatim. Pass `--set bmm.project_knowledge='{project-root}/research'`
// if you want the rendered form.
// - Carry-forward across installs is best-effort: declared schema keys
// persist via the existingValue path on the next interactive run; values
// for keys outside any module's schema may need to be re-passed on each
// install (or edited directly in `_bmad/config.toml`).
// - No "key not in schema" validation: whatever you assert, we write.
//
// Names that, when used as object keys, can mutate `Object.prototype` and
// cascade into every plain-object lookup in the process. The whole `--set`
// pipeline assigns into plain `{}` maps keyed by user input, so a flag like
// `--set __proto__.polluted=1` would otherwise reach
// `overrides.__proto__[key] = value`, which assigns onto Object.prototype.
// We reject both segments at parse time and harden the maps in
// `parseSetEntries` with `Object.create(null)`.
// cascade into every plain-object lookup in the process. The `--set` pipeline
// assigns into plain `{}` maps keyed by user input, so `--set __proto__.x=1`
// would otherwise reach `overrides.__proto__[x] = 1` and pollute every plain
// object. We reject the names at parse time and harden the maps in
// `parseSetEntries` with `Object.create(null)` for defense-in-depth.
const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
const path = require('node:path');
const fs = require('./fs-native');
const yaml = require('yaml');
/**
* Parse a single `--set <module>.<key>=<value>` entry.
* @param {string} entry - raw flag value
@ -45,12 +64,9 @@ function parseSetEntry(entry) {
/**
* Parse repeated `--set` entries into a `{ module: { key: value } }` map.
* Later entries overwrite earlier ones for the same key.
*
* Both the outer map and the per-module inner maps are `Object.create(null)`
* so that even if a future caller bypasses `parseSetEntry`'s reserved-name
* check, lookups can't traverse `Object.prototype` and pollution-style writes
* land on the map's own properties (not the global prototype).
* Later entries overwrite earlier ones for the same key. Both the outer
* map and the per-module inner maps are `Object.create(null)` so callers
* that bypass `parseSetEntry`'s name check still can't pollute prototypes.
*
* @param {string[]} entries
* @returns {Object<string, Object<string, string>>}
@ -66,4 +82,249 @@ function parseSetEntries(entries) {
return overrides;
}
module.exports = { parseSetEntry, parseSetEntries };
/**
* Encode a JS string as a TOML basic string (double-quoted with escapes).
* @param {string} value
*/
function tomlString(value) {
const s = String(value);
// Per the TOML spec, basic strings escape `\`, `"`, and control characters.
return (
'"' +
s
.replaceAll('\\', '\\\\')
.replaceAll('"', String.raw`\"`)
.replaceAll('\b', String.raw`\b`)
.replaceAll('\f', String.raw`\f`)
.replaceAll('\n', String.raw`\n`)
.replaceAll('\r', String.raw`\r`)
.replaceAll('\t', String.raw`\t`) +
'"'
);
}
/**
* Section header for a given module code.
* - `core` `[core]`
* - `<other>` `[modules.<other>]`
*
* Mirrors the layout `manifest-generator.writeCentralConfig` produces.
*/
function sectionHeader(moduleCode) {
return moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
}
/**
* Insert or update `key = value` inside a TOML section, returning the new
* file content. The format produced by the installer is regular and small
* enough that a line scanner is more reliable than pulling in a TOML
* round-tripper that would normalize the file's existing whitespace and
* comment structure.
*
* - If `[section]` exists and contains `key`, replace the value on that
* line (preserving any inline comment after the value).
* - If `[section]` exists but `key` doesn't, append `key = value` at the
* end of the section (before the next `[...]` header or EOF, skipping
* trailing blank lines so the section stays tidy).
* - If `[section]` doesn't exist, append a new section block at EOF.
*
* @param {string} content existing file content (may be empty)
* @param {string} section exact `[section]` header to target
* @param {string} key
* @param {string} valueToml already TOML-encoded value (e.g. `"foo"`)
* @returns {string} new content
*/
function upsertTomlKey(content, section, key, valueToml) {
const lines = content.split('\n');
// Track whether the file already ended with a newline so we can preserve
// that. `split('\n')` on `"a\n"` yields `['a', '']`, which gives us the
// marker we need.
const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
if (hadTrailingNewline) lines.pop();
// Locate the target section.
const sectionStart = lines.findIndex((line) => line.trim() === section);
if (sectionStart === -1) {
// Section doesn't exist — append a new block. Pad with a blank line if
// the file is non-empty so sections stay visually separated.
if (lines.length > 0 && lines.at(-1).trim() !== '') lines.push('');
lines.push(section, `${key} = ${valueToml}`);
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
}
// Find the section's end (next `[...]` header or EOF).
let sectionEnd = lines.length;
for (let i = sectionStart + 1; i < lines.length; i++) {
if (/^\s*\[/.test(lines[i])) {
sectionEnd = i;
break;
}
}
// Look for the key inside the section. Match `<key> = ...` allowing
// optional leading whitespace; preserve the comment tail (`# ...`) if any.
const keyPattern = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=\\s*(.*)$`);
for (let i = sectionStart + 1; i < sectionEnd; i++) {
const match = lines[i].match(keyPattern);
if (match) {
const indent = match[1];
// Preserve trailing comment if present. We split on the first `#` that
// is preceded by whitespace — TOML strings can't contain unescaped `#`
// in basic-string form so this is safe for the values we emit.
const tail = match[2];
const commentIdx = tail.search(/\s+#/);
const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
}
}
// Section exists but key doesn't. Insert before the next section header,
// skipping trailing blank lines inside the current section so the new
// entry sits with its siblings.
let insertAt = sectionEnd;
while (insertAt > sectionStart + 1 && lines[insertAt - 1].trim() === '') {
insertAt--;
}
lines.splice(insertAt, 0, `${key} = ${valueToml}`);
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
}
function escapeRegExp(s) {
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}
/**
* Look up `[section] key` in a TOML file. Returns true if the file exists,
* the section is present, and `key` is set within it. Used by
* `applySetOverrides` to route an override to the file that already owns
* the key (so user-scope keys land in `config.user.toml`, team-scope keys
* land in `config.toml`).
*/
async function tomlHasKey(filePath, section, key) {
if (!(await fs.pathExists(filePath))) return false;
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n');
const sectionStart = lines.findIndex((line) => line.trim() === section);
if (sectionStart === -1) return false;
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
for (let i = sectionStart + 1; i < lines.length; i++) {
if (/^\s*\[/.test(lines[i])) return false;
if (keyPattern.test(lines[i])) return true;
}
return false;
}
/**
* Apply parsed `--set` overrides to the central TOML files written by the
* installer. Called at the end of an install / quick-update.
*
* Routing per (module, key):
* 1. If `_bmad/config.user.toml` already has `[section] key`, update there
* (user-scope key like `core.user_name`, `bmm.user_skill_level`).
* 2. Otherwise update `_bmad/config.toml` (team scope, the default).
*
* The schema-correct user/team partition lives in `manifest-generator`. We
* intentionally don't re-read module schemas here the only goal is to
* match the file the installer just wrote the key to. For brand-new keys
* (not in either file yet), team scope is the safe default.
*
* @param {Object<string, Object<string, string>>} overrides
* @param {string} bmadDir absolute path to `_bmad/`
* @returns {Promise<Array<{module:string,key:string,scope:'team'|'user',file:string}>>}
* a list of applied entries (for caller logging)
*/
async function applySetOverrides(overrides, bmadDir) {
const applied = [];
if (!overrides || typeof overrides !== 'object') return applied;
const teamPath = path.join(bmadDir, 'config.toml');
const userPath = path.join(bmadDir, 'config.user.toml');
for (const moduleCode of Object.keys(overrides)) {
// Skip overrides for modules not actually installed. The installer writes
// `_bmad/<module>/config.yaml` for every installed module (including core),
// so its presence is a reliable "is this module here?" signal that works
// for both fresh installs and quick-updates without coupling to caller-
// supplied module lists.
const moduleConfigYaml = path.join(bmadDir, moduleCode, 'config.yaml');
if (!(await fs.pathExists(moduleConfigYaml))) {
continue;
}
const section = sectionHeader(moduleCode);
const moduleOverrides = overrides[moduleCode] || {};
for (const key of Object.keys(moduleOverrides)) {
const value = moduleOverrides[key];
const valueToml = tomlString(value);
const userOwnsIt = await tomlHasKey(userPath, section, key);
const targetPath = userOwnsIt ? userPath : teamPath;
// The team file always exists post-install; the user file only exists
// if the install wrote at least one user-scope key. If we're routing to
// it but it doesn't exist yet, create it with a minimal header so it
// has the same shape as installer-written user toml.
let content = '';
if (await fs.pathExists(targetPath)) {
content = await fs.readFile(targetPath, 'utf8');
} else {
content = '# Personal overrides for _bmad/config.toml.\n';
}
const next = upsertTomlKey(content, section, key, valueToml);
await fs.writeFile(targetPath, next, 'utf8');
applied.push({
module: moduleCode,
key,
scope: userOwnsIt ? 'user' : 'team',
file: path.basename(targetPath),
});
}
// Also patch the per-module yaml (`_bmad/<module>/config.yaml`). The
// installer reads this file as `_existingConfig` on subsequent runs and
// surfaces declared values as prompt defaults — under `--yes` those
// defaults are accepted, so patching here gives `--set` natural
// carry-forward for declared keys without needing schema-strict
// partition exemptions in the manifest writer. For undeclared keys the
// value lives in the per-module yaml but won't be re-emitted into
// config.toml on the next install (the schema-strict partition drops
// it); re-pass `--set` if you need it sticky.
const moduleYamlPath = path.join(bmadDir, moduleCode, 'config.yaml');
if (await fs.pathExists(moduleYamlPath)) {
try {
const text = await fs.readFile(moduleYamlPath, 'utf8');
const parsed = yaml.parse(text);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
// Preserve the installer's banner header (everything up to the
// first non-comment line) so `_bmad/<module>/config.yaml` keeps
// its provenance comments after we round-trip it.
const headerLines = [];
for (const line of text.split('\n')) {
if (line.startsWith('#') || line.trim() === '') {
headerLines.push(line);
} else {
break;
}
}
for (const key of Object.keys(moduleOverrides)) {
parsed[key] = moduleOverrides[key];
}
const body = yaml.stringify(parsed, { indent: 2, lineWidth: 0, minContentWidth: 0 });
const header = headerLines.length > 0 ? headerLines.join('\n') + '\n' : '';
await fs.writeFile(moduleYamlPath, header + body, 'utf8');
}
} catch {
// Per-module yaml unparseable — skip silently. The central toml was
// already patched above, which is the user-visible state for the
// current install. Carry-forward will fail next install but the
// current install reflects the override.
}
}
}
return applied;
}
module.exports = { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString };

View File

@ -288,7 +288,7 @@ class UI {
// Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
channelOptions,
});
@ -314,7 +314,7 @@ class UI {
skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
setOverrideKeys,
setOverrides,
skipPrompts: options.yes || false,
channelOptions,
};
@ -366,7 +366,7 @@ class UI {
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
channelOptions,
});
@ -392,7 +392,7 @@ class UI {
skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
setOverrideKeys,
setOverrides,
skipPrompts: options.yes || false,
channelOptions,
};
@ -713,9 +713,12 @@ class UI {
async collectModuleConfigs(directory, modules, options = {}) {
const { OfficialModules } = require('./modules/official-modules');
// Parse --set entries up front so we can both (a) hand them to the config
// collector to skip prompts, and (b) warn about modules referenced in --set
// that aren't part of this install (those values are dropped, not persisted).
// Parse --set up front purely to surface user-error before the install
// burns time on the network / filesystem. The actual application happens
// in installer.install() as a post-write TOML patch — see
// `tools/installer/set-overrides.js`. We also warn about overrides
// targeting modules the user didn't include, since those will silently
// miss the file the patch step looks for.
let setOverrides = {};
try {
setOverrides = parseSetEntries(options.set || []);
@ -723,16 +726,20 @@ class UI {
// install.js validated already; rethrow as-is for the user.
throw error;
}
// Drop overrides for modules that aren't in the install set so the
// post-install patch step doesn't create orphan sections in config.toml
// for modules that were never installed.
const selectedModuleSet = new Set(['core', ...modules]);
for (const moduleCode of Object.keys(setOverrides)) {
if (!selectedModuleSet.has(moduleCode)) {
await prompts.log.warn(
`--set ${moduleCode}.* — module '${moduleCode}' is not in the install set; values will be ignored. Add it to --modules to apply.`,
);
delete setOverrides[moduleCode];
}
}
const configCollector = new OfficialModules({ channelOptions: options.channelOptions, setOverrides });
const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
// Seed core config from CLI options if provided
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
@ -797,24 +804,7 @@ class UI {
skipPrompts: options.yes || false,
});
// Apply --set overrides for `core` AFTER collectAllConfigurations.
// Core is skipped inside collectAllConfigurations when its config was
// seeded by `--yes` defaults or by legacy core-shortcut flags
// (--user-name/--output-folder/etc.), so its overrides need a separate
// application path. Non-core modules apply overrides inside their own
// collectModuleConfig run.
await configCollector.applyOverridesAfterSeeding('core');
// Convert per-module override-key Sets to plain string arrays so the value
// round-trips cleanly through Config.build / freezing.
const setOverrideKeys = {};
if (configCollector.setOverrideKeys) {
for (const [moduleCode, keys] of Object.entries(configCollector.setOverrideKeys)) {
setOverrideKeys[moduleCode] = [...keys];
}
}
return { moduleConfigs: configCollector.collectedConfig, setOverrideKeys };
return { moduleConfigs: configCollector.collectedConfig, setOverrides };
}
/**