Compare commits
No commits in common. "1ab4d5f93c57a079aeb59e17990cec4adc31ac3a" and "fb57c81176d753db175fe9db077eae7d354fd29e" have entirely different histories.
1ab4d5f93c
...
fb57c81176
|
|
@ -117,22 +117,22 @@ 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. |
|
||||
| `--tools <a,b>` | IDE/tool selection. Required for fresh `--yes` installs. Run `--list-tools` for valid IDs. |
|
||||
| `--list-tools` | Print all supported tool/IDE IDs (with target directories) and exit. |
|
||||
| `--action <type>` | `install`, `update`, or `quick-update`. Defaults based on existing install state. |
|
||||
| `--custom-source <urls>` | Install custom modules from Git URLs or local paths |
|
||||
| `--channel <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
|
||||
| `--all-stable` | Alias for `--channel=stable` |
|
||||
| `--all-next` | Alias for `--channel=next` |
|
||||
| `--next=<code>` | Put one module on next. Repeatable. |
|
||||
| `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. |
|
||||
| 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. |
|
||||
| `--tools <a,b>` | IDE/tool selection. Required for fresh `--yes` installs. Run `--list-tools` for valid IDs. |
|
||||
| `--list-tools` | Print all supported tool/IDE IDs (with target directories) and exit. |
|
||||
| `--action <type>` | `install`, `update`, or `quick-update`. Defaults based on existing install state. |
|
||||
| `--custom-source <urls>` | Install custom modules from Git URLs or local paths |
|
||||
| `--channel <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
|
||||
| `--all-stable` | Alias for `--channel=stable` |
|
||||
| `--all-next` | Alias for `--channel=next` |
|
||||
| `--next=<code>` | Put one module on next. Repeatable. |
|
||||
| `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. |
|
||||
| `--set <module>.<key>=<value>` | Set any module config option non-interactively (preferred — see [Module config overrides](#module-config-overrides)). Repeatable. |
|
||||
| `--list-options [module]` | Print every `--set` key for built-in and locally-cached official modules, then exit. Pass a module code to scope to one module. |
|
||||
| `--list-options [module]` | Print every `--set` key for built-in and locally-cached official modules, then exit. Pass a module code to scope to one module. |
|
||||
| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Legacy shortcuts equivalent to `--set core.<key>=<value>` (still supported) |
|
||||
|
||||
Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`).
|
||||
|
|
@ -183,9 +183,9 @@ npx bmad-method install --yes --action update \
|
|||
|
||||
### 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
|
||||
npx bmad-method install --yes \
|
||||
|
|
@ -201,21 +201,18 @@ 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 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.
|
||||
- **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).
|
||||
- `<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.
|
||||
|
||||
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]
|
||||
`--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.
|
||||
:::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.
|
||||
:::
|
||||
|
||||
:::caution[Rate limit on shared IPs]
|
||||
|
|
|
|||
|
|
@ -2988,17 +2988,21 @@ async function runTests() {
|
|||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
|
||||
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');
|
||||
|
||||
// ---- Parser ----------------------------------------------------------
|
||||
// parseSetEntry — happy path
|
||||
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) {
|
||||
|
|
@ -3011,17 +3015,23 @@ 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',
|
||||
);
|
||||
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();
|
||||
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
|
||||
// `overrides.__proto__[x] = 1` and pollute every plain object.
|
||||
// parseSetEntries — prototype-pollution guard. `--set __proto__.x=1` would
|
||||
// otherwise reach `overrides.__proto__[x] = 1` and pollute Object.prototype.
|
||||
const polluteProbe = {};
|
||||
let pollutionThrown = false;
|
||||
try {
|
||||
|
|
@ -3039,188 +3049,40 @@ async function runTests() {
|
|||
}
|
||||
assert(constructorThrown, 'parseSetEntries rejects "constructor" as a key name');
|
||||
|
||||
// ---- 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.
|
||||
// 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.
|
||||
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 filter.
|
||||
const bmmUpper = await formatOptionsList('BMM');
|
||||
assert(bmmUpper.ok === true && bmmUpper.text.includes('bmm.project_knowledge'), '--list-options is case-insensitive');
|
||||
// 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');
|
||||
|
||||
// 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');
|
||||
// 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',
|
||||
);
|
||||
} finally {
|
||||
if (priorCacheEnv44 === undefined) {
|
||||
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||
|
|
@ -3229,6 +3091,133 @@ 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);
|
||||
|
|
|
|||
|
|
@ -62,17 +62,10 @@ module.exports = {
|
|||
const moduleArg = options.listOptions === true ? null : options.listOptions;
|
||||
const { text, ok } = await formatOptionsList(moduleArg);
|
||||
const stream = ok ? process.stdout : process.stderr;
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
|
||||
// Set debug flag as environment variable for all components
|
||||
|
|
@ -102,13 +95,13 @@ module.exports = {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Handle quick update separately
|
||||
if (config.actionType === 'quick-update') {
|
||||
const { parseSetEntries } = require('../set-overrides');
|
||||
config.setOverrides = parseSetEntries(options.set || []);
|
||||
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 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(', ')})`);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class Config {
|
|||
moduleConfigs,
|
||||
quickUpdate,
|
||||
channelOptions,
|
||||
setOverrides,
|
||||
setOverrideKeys,
|
||||
}) {
|
||||
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;
|
||||
// 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 || {};
|
||||
// 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 || {};
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ class Config {
|
|||
moduleConfigs: userInput.moduleConfigs || null,
|
||||
quickUpdate: userInput._quickUpdate || false,
|
||||
channelOptions: userInput.channelOptions || null,
|
||||
setOverrides: userInput.setOverrides || {},
|
||||
setOverrideKeys: userInput.setOverrideKeys || {},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -308,21 +308,9 @@ 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');
|
||||
|
|
@ -1296,10 +1284,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,11 @@ class ManifestGenerator {
|
|||
await this.collectAgentsFromModuleYaml();
|
||||
|
||||
// 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 = [
|
||||
await this.writeMainManifest(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).
|
||||
* @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 userPath = path.join(bmadDir, 'config.user.toml');
|
||||
|
||||
|
|
@ -474,17 +478,20 @@ 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. 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 — 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.
|
||||
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)) continue;
|
||||
if (onlyDeclaredKeys && !(key in scopes) && !overrideKeys.has(key)) continue;
|
||||
if (scopes[key] === 'user') {
|
||||
user[key] = value;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -190,8 +190,7 @@ async function formatOptionsList(moduleCode) {
|
|||
if (moduleCode) moduleScopedFailure = true;
|
||||
continue;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
sections.push(`${code} (${source}): module.yaml is not a valid object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, '');
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
if (moduleCode) moduleScopedFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,88 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1497,6 +1579,7 @@ 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];
|
||||
|
|
@ -1515,6 +1598,7 @@ 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);
|
||||
|
|
@ -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 };
|
||||
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) {
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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.
|
||||
// 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)`.
|
||||
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
|
||||
|
|
@ -64,9 +45,12 @@ 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 callers
|
||||
* that bypass `parseSetEntry`'s name check still can't pollute prototypes.
|
||||
* 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).
|
||||
*
|
||||
* @param {string[]} entries
|
||||
* @returns {Object<string, Object<string, string>>}
|
||||
|
|
@ -82,249 +66,4 @@ function parseSetEntries(entries) {
|
|||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
module.exports = { parseSetEntry, parseSetEntries };
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ class UI {
|
|||
// Get tool selection
|
||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||
|
||||
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
...options,
|
||||
channelOptions,
|
||||
});
|
||||
|
|
@ -314,7 +314,7 @@ class UI {
|
|||
skipIde: toolSelection.skipIde,
|
||||
coreConfig: moduleConfigs.core || {},
|
||||
moduleConfigs: moduleConfigs,
|
||||
setOverrides,
|
||||
setOverrideKeys,
|
||||
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, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
...options,
|
||||
channelOptions,
|
||||
});
|
||||
|
|
@ -392,7 +392,7 @@ class UI {
|
|||
skipIde: toolSelection.skipIde,
|
||||
coreConfig: moduleConfigs.core || {},
|
||||
moduleConfigs: moduleConfigs,
|
||||
setOverrides,
|
||||
setOverrideKeys,
|
||||
skipPrompts: options.yes || false,
|
||||
channelOptions,
|
||||
};
|
||||
|
|
@ -713,12 +713,9 @@ class UI {
|
|||
async collectModuleConfigs(directory, modules, options = {}) {
|
||||
const { OfficialModules } = require('./modules/official-modules');
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
let setOverrides = {};
|
||||
try {
|
||||
setOverrides = parseSetEntries(options.set || []);
|
||||
|
|
@ -726,20 +723,16 @@ 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 });
|
||||
const configCollector = new OfficialModules({ channelOptions: options.channelOptions, setOverrides });
|
||||
|
||||
// Seed core config from CLI options if provided
|
||||
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
|
||||
|
|
@ -804,7 +797,24 @@ class UI {
|
|||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue