Compare commits

..

No commits in common. "1ab4d5f93c57a079aeb59e17990cec4adc31ac3a" and "fb57c81176d753db175fe9db077eae7d354fd29e" have entirely different histories.

10 changed files with 440 additions and 542 deletions

View File

@ -118,7 +118,7 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
### Flag reference ### Flag reference
| Flag | Purpose | | Flag | Purpose |
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults | | `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
| `--directory <path>` | Install into this directory (default: current working dir) | | `--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. | | `--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 ### Module config overrides
`--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. `--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.
**Example — install bmm with explicit project knowledge and skill level:** **Example — install bmm without using `docs/` for project knowledge:**
```bash ```bash
npx bmad-method install --yes \ npx bmad-method install --yes \
@ -201,21 +201,18 @@ npx bmad-method install --yes \
npx bmad-method install --list-options bmm 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 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. `--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.
**How it works:** **Validation rules:**
- **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. - `<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.
- **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'`. - `<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.
- **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. - `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.
- **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. 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.
:::note[Works with quick-update] :::note[Quick-update is unaffected]
`--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. `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.
::: :::
:::caution[Rate limit on shared IPs] :::caution[Rate limit on shared IPs]

View File

@ -2988,17 +2988,21 @@ async function runTests() {
// ============================================================ // ============================================================
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`); console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
try { try {
const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides'); const { parseSetEntry, parseSetEntries } = require('../tools/installer/set-overrides');
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options'); const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
// ---- Parser ---------------------------------------------------------- // parseSetEntry — happy path
const ok = parseSetEntry('bmm.project_knowledge=research'); const ok = parseSetEntry('bmm.project_knowledge=research');
assert( assert(
ok.module === 'bmm' && ok.key === 'project_knowledge' && ok.value === 'research', ok.module === 'bmm' && ok.key === 'project_knowledge' && ok.value === 'research',
'parseSetEntry splits <module>.<key>=<value> correctly', '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', '']; const badInputs = ['no-equals', 'no-dot=value', '=value', '.=value', 'foo.=value', '.bar=value', ''];
let allBadThrow = true; let allBadThrow = true;
for (const bad of badInputs) { for (const bad of badInputs) {
@ -3011,17 +3015,23 @@ async function runTests() {
} }
assert(allBadThrow, `parseSetEntry rejects malformed inputs (${badInputs.length} cases)`); 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']); const multi = parseSetEntries(['bmm.project_knowledge=research', 'bmm.user_skill_level=expert', 'core.user_name=Brian']);
assert( assert(
multi.bmm.project_knowledge === 'research' && multi.bmm.user_skill_level === 'expert' && multi.core.user_name === 'Brian', multi.bmm.project_knowledge === 'research' && multi.bmm.user_skill_level === 'expert' && multi.core.user_name === 'Brian',
'parseSetEntries groups by module', 'parseSetEntries groups by module',
); );
assert(parseSetEntries(['bmm.x=first', 'bmm.x=second']).bmm.x === 'second', 'parseSetEntries: later --set entry overrides earlier');
// 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
const empty = parseSetEntries(); const empty = parseSetEntries();
assert(empty && Object.keys(empty).length === 0, 'parseSetEntries() returns empty object when called without args'); assert(empty && Object.keys(empty).length === 0, 'parseSetEntries() returns empty object when called without args');
// Prototype-pollution guard. `--set __proto__.x=1` would otherwise reach // parseSetEntries — prototype-pollution guard. `--set __proto__.x=1` would
// `overrides.__proto__[x] = 1` and pollute every plain object. // otherwise reach `overrides.__proto__[x] = 1` and pollute Object.prototype.
const polluteProbe = {}; const polluteProbe = {};
let pollutionThrown = false; let pollutionThrown = false;
try { try {
@ -3039,188 +3049,40 @@ async function runTests() {
} }
assert(constructorThrown, 'parseSetEntries rejects "constructor" as a key name'); assert(constructorThrown, 'parseSetEntries rejects "constructor" as a key name');
// ---- tomlString ------------------------------------------------------ // discoverOfficialModuleYamls + formatOptionsList read the on-disk
assert(tomlString('hello') === '"hello"', 'tomlString quotes a plain string'); // external-module cache. Point that env at a temp dir so test results
assert(tomlString('with "quotes"') === String.raw`"with \"quotes\""`, 'tomlString escapes embedded double-quotes'); // don't depend on whatever the developer / CI runner has cached.
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 priorCacheEnv44 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
const tempCacheDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-list-options-cache-')); const tempCacheDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-list-options-cache-'));
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir44; process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir44;
try { try {
// discoverOfficialModuleYamls includes core and bmm built-ins.
const discovered = await discoverOfficialModuleYamls(); const discovered = await discoverOfficialModuleYamls();
const codes = new Set(discovered.map((d) => d.code)); const codes = new Set(discovered.map((d) => d.code));
assert(codes.has('core') && codes.has('bmm'), 'discoverOfficialModuleYamls finds core and bmm built-ins'); 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'); const bmmListing = await formatOptionsList('bmm');
assert(bmmListing.ok === true, '--list-options bmm reports ok: true'); 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.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('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 filter. // Case-insensitive match: `--list-options BMM` and `bmm` resolve to the same entry.
const bmmUpper = await formatOptionsList('BMM'); const bmmUpperListing = await formatOptionsList('BMM');
assert(bmmUpper.ok === true && bmmUpper.text.includes('bmm.project_knowledge'), '--list-options is case-insensitive'); 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');
// Unknown module → non-zero exit signal. // formatOptionsList for an unknown module gives a helpful message AND ok: false
const unknown = await formatOptionsList('definitely-not-a-module'); // so install.js can exit non-zero (CI scripts can detect typos).
assert(unknown.ok === false, '--list-options <unknown> reports ok: false'); const unknownListing = await formatOptionsList('definitely-not-a-module');
assert(unknown.text.includes('No locally-known module.yaml'), '--list-options unknown explains the miss'); 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',
);
} finally { } finally {
if (priorCacheEnv44 === undefined) { if (priorCacheEnv44 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE; delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
@ -3229,6 +3091,133 @@ async function runTests() {
} }
await fs.remove(tempCacheDir44).catch(() => {}); 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) { } catch (error) {
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`); console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack); console.log(error.stack);

View File

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

View File

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

View File

@ -308,21 +308,9 @@ class Installer {
ides: config.ides || [], ides: config.ides || [],
preservedModules: modulesForCsvPreserve, preservedModules: modulesForCsvPreserve,
moduleConfigs, 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...'); message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents); await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
addResult('Help catalog', 'ok'); addResult('Help catalog', 'ok');
@ -1296,10 +1284,6 @@ class Installer {
ides: configuredIdes, ides: configuredIdes,
coreConfig: quickModules.collectedConfig.core, coreConfig: quickModules.collectedConfig.core,
moduleConfigs: quickModules.collectedConfig, 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', actionType: 'install',
_quickUpdate: true, _quickUpdate: true,
_preserveModules: skippedModules, _preserveModules: skippedModules,

View File

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

View File

@ -190,8 +190,7 @@ async function formatOptionsList(moduleCode) {
if (moduleCode) moduleScopedFailure = true; if (moduleCode) moduleScopedFailure = true;
continue; continue;
} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object') {
sections.push(`${code} (${source}): module.yaml is not a valid object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, '');
if (moduleCode) moduleScopedFailure = true; if (moduleCode) moduleScopedFailure = true;
continue; continue;
} }

View File

@ -20,6 +20,88 @@ class OfficialModules {
// pre-install config collection and the install step agree on which ref // pre-install config collection and the install step agree on which ref
// to clone. // to clone.
this.channelOptions = options.channelOptions || null; 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);
}
}
} }
/** /**
@ -1497,6 +1579,7 @@ class OfficialModules {
const questions = []; const questions = [];
const staticAnswers = {}; const staticAnswers = {};
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
const declaredPromptKeys = new Set();
for (const key of configKeys) { for (const key of configKeys) {
const item = moduleConfig[key]; const item = moduleConfig[key];
@ -1515,6 +1598,7 @@ class OfficialModules {
// Handle interactive values (with prompt) // Handle interactive values (with prompt)
if (item.prompt) { if (item.prompt) {
declaredPromptKeys.add(key);
const question = await this.buildQuestion(moduleName, key, item, moduleConfig); const question = await this.buildQuestion(moduleName, key, item, moduleConfig);
if (question) { if (question) {
questions.push(question); questions.push(question);
@ -1522,8 +1606,63 @@ class OfficialModules {
} }
} }
// Collect all answers (static + prompted) // 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.
let allAnswers = { ...staticAnswers }; 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 there are questions to ask, prompt for accepting defaults vs customizing
if (questions.length > 0) { if (questions.length > 0) {
@ -1730,6 +1869,47 @@ 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); await this.displayModulePostConfigNotes(moduleName, moduleConfig);
} }

View File

@ -1,31 +1,12 @@
// `--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 // Names that, when used as object keys, can mutate `Object.prototype` and
// cascade into every plain-object lookup in the process. The `--set` pipeline // cascade into every plain-object lookup in the process. The whole `--set`
// assigns into plain `{}` maps keyed by user input, so `--set __proto__.x=1` // pipeline assigns into plain `{}` maps keyed by user input, so a flag like
// would otherwise reach `overrides.__proto__[x] = 1` and pollute every plain // `--set __proto__.polluted=1` would otherwise reach
// object. We reject the names at parse time and harden the maps in // `overrides.__proto__[key] = value`, which assigns onto Object.prototype.
// `parseSetEntries` with `Object.create(null)` for defense-in-depth. // We reject both segments at parse time and harden the maps in
// `parseSetEntries` with `Object.create(null)`.
const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructor']); 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. * Parse a single `--set <module>.<key>=<value>` entry.
* @param {string} entry - raw flag value * @param {string} entry - raw flag value
@ -64,9 +45,12 @@ function parseSetEntry(entry) {
/** /**
* Parse repeated `--set` entries into a `{ module: { key: value } }` map. * Parse repeated `--set` entries into a `{ module: { key: value } }` map.
* Later entries overwrite earlier ones for the same key. Both the outer * Later entries overwrite earlier ones for the same key.
* map and the per-module inner maps are `Object.create(null)` so callers *
* that bypass `parseSetEntry`'s name check still can't pollute prototypes. * 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).
* *
* @param {string[]} entries * @param {string[]} entries
* @returns {Object<string, Object<string, string>>} * @returns {Object<string, Object<string, string>>}
@ -82,249 +66,4 @@ function parseSetEntries(entries) {
return overrides; 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 // Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options); const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options, ...options,
channelOptions, channelOptions,
}); });
@ -314,7 +314,7 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs, moduleConfigs: moduleConfigs,
setOverrides, setOverrideKeys,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
channelOptions, channelOptions,
}; };
@ -366,7 +366,7 @@ class UI {
await this._interactiveChannelGate({ options, channelOptions, selectedModules }); await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options); let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options, ...options,
channelOptions, channelOptions,
}); });
@ -392,7 +392,7 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs, moduleConfigs: moduleConfigs,
setOverrides, setOverrideKeys,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
channelOptions, channelOptions,
}; };
@ -713,12 +713,9 @@ class UI {
async collectModuleConfigs(directory, modules, options = {}) { async collectModuleConfigs(directory, modules, options = {}) {
const { OfficialModules } = require('./modules/official-modules'); const { OfficialModules } = require('./modules/official-modules');
// Parse --set up front purely to surface user-error before the install // Parse --set entries up front so we can both (a) hand them to the config
// burns time on the network / filesystem. The actual application happens // collector to skip prompts, and (b) warn about modules referenced in --set
// in installer.install() as a post-write TOML patch — see // that aren't part of this install (those values are dropped, not persisted).
// `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 = {}; let setOverrides = {};
try { try {
setOverrides = parseSetEntries(options.set || []); setOverrides = parseSetEntries(options.set || []);
@ -726,20 +723,16 @@ class UI {
// install.js validated already; rethrow as-is for the user. // install.js validated already; rethrow as-is for the user.
throw error; 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]); const selectedModuleSet = new Set(['core', ...modules]);
for (const moduleCode of Object.keys(setOverrides)) { for (const moduleCode of Object.keys(setOverrides)) {
if (!selectedModuleSet.has(moduleCode)) { if (!selectedModuleSet.has(moduleCode)) {
await prompts.log.warn( await prompts.log.warn(
`--set ${moduleCode}.* — module '${moduleCode}' is not in the install set; values will be ignored. Add it to --modules to apply.`, `--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 }); const configCollector = new OfficialModules({ channelOptions: options.channelOptions, setOverrides });
// Seed core config from CLI options if provided // Seed core config from CLI options if provided
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) { if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
@ -804,7 +797,24 @@ class UI {
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
}); });
return { moduleConfigs: configCollector.collectedConfig, setOverrides }; // 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 };
} }
/** /**