diff --git a/README.md b/README.md index c9fb503e2..ea7ba5254 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,15 @@ Follow the installer prompts, then open your AI IDE (Claude Code, Cursor, etc.) npx bmad-method install --directory /path/to/project --modules bmm --tools claude-code --yes ``` +Override any module config option with `--set .=` (repeatable). Run `--list-options [module]` to see locally-known official keys (built-in modules plus any external officials cached on this machine): + +```bash +npx bmad-method install --yes \ + --modules bmm --tools claude-code \ + --set bmm.project_knowledge=research \ + --set bmm.user_skill_level=expert +``` + [See all installation options](https://docs.bmad-method.org/how-to/non-interactive-installation/) > **Not sure what to do?** Ask `bmad-help` — it tells you exactly what's next and what's optional. You can also ask questions like `bmad-help I just finished the architecture, what do I do next?` diff --git a/docs/how-to/install-bmad.md b/docs/how-to/install-bmad.md index 6651143d6..224704a47 100644 --- a/docs/how-to/install-bmad.md +++ b/docs/how-to/install-bmad.md @@ -117,21 +117,23 @@ 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 ` | Install into this directory (default: current working dir) | -| `--modules ` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. | -| `--tools ` | 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 ` | `install`, `update`, or `quick-update`. Defaults based on existing install state. | -| `--custom-source ` | Install custom modules from Git URLs or local paths | -| `--channel ` | Apply to all externals (aliased as `--all-stable` / `--all-next`) | -| `--all-stable` | Alias for `--channel=stable` | -| `--all-next` | Alias for `--channel=next` | -| `--next=` | Put one module on next. Repeatable. | -| `--pin =` | Pin one module to a specific tag. Repeatable. | -| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Override per-user config defaults | +| Flag | Purpose | +| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| `--yes`, `-y` | Skip all prompts; accept flag values + defaults | +| `--directory ` | Install into this directory (default: current working dir) | +| `--modules ` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. | +| `--tools ` | 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 ` | `install`, `update`, or `quick-update`. Defaults based on existing install state. | +| `--custom-source ` | Install custom modules from Git URLs or local paths | +| `--channel ` | Apply to all externals (aliased as `--all-stable` / `--all-next`) | +| `--all-stable` | Alias for `--channel=stable` | +| `--all-next` | Alias for `--channel=next` | +| `--next=` | Put one module on next. Repeatable. | +| `--pin =` | Pin one module to a specific tag. Repeatable. | +| `--set .=` | 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. | +| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Legacy shortcuts equivalent to `--set core.=` (still supported) | Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`). @@ -179,6 +181,43 @@ npx bmad-method install --yes --action update \ --next=bmb ``` +### Module config overrides + +`--set .=` 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//config.yaml` so declared values carry forward to the next install. + +**Example — install bmm with explicit project knowledge and skill level:** + +```bash +npx bmad-method install --yes \ + --modules bmm \ + --tools claude-code \ + --set bmm.project_knowledge=research \ + --set bmm.user_skill_level=expert +``` + +**Discover available keys for a module:** + +```bash +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. + +**How it works:** + +- **Routing.** The patch step looks for `[modules.] ` (or `[core] `) 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//config.yaml`, which the installer reads as the prompt default on the next run. +- **Carry-forward, undeclared keys.** A value for a key the module's schema doesn't declare lands in `config.toml` for the current install but won't be re-emitted on the next install (the manifest writer's schema-strict partition drops unknown keys). Re-pass `--set` if you need it sticky, or edit `_bmad/config.toml` directly. +- **No validation.** `single-select` values aren't checked against the allowed choices, and unknown keys aren't rejected — whatever you assert is written. +- **Modules not in `--modules`.** Setting a value for a module you didn't include prints a warning and the value is dropped (no file gets created for an uninstalled module). + +The legacy core shortcuts (`--user-name`, `--output-folder`, etc.) still work and remain documented for backward compatibility, but `--set core.user_name=...` is equivalent. + +:::note[Works with quick-update] +`--set` is a post-install patch, so it applies the same way regardless of action type. Under `bmad install --action quick-update` (or `--yes` against an existing install, where quick-update is the default), `--set` patches the central config files at the end just like a regular install. +::: + :::caution[Rate limit on shared IPs] Anonymous GitHub API calls are capped at 60/hour per IP. A single install hits the API once per external module to resolve the stable tag. Offices behind NAT, CI runner pools, and VPNs can collectively exhaust this. diff --git a/test/test-installation-components.js b/test/test-installation-components.js index a8bf77756..4447f9010 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -2983,6 +2983,260 @@ async function runTests() { console.log(''); + // ============================================================ + // Test Suite 44: --set .= CLI overrides (#1663) + // ============================================================ + 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 { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options'); + + // ---- Parser ---------------------------------------------------------- + const ok = parseSetEntry('bmm.project_knowledge=research'); + assert( + ok.module === 'bmm' && ok.key === 'project_knowledge' && ok.value === 'research', + 'parseSetEntry splits .= correctly', + ); + assert(parseSetEntry('bmm.weird=a=b=c').value === 'a=b=c', 'parseSetEntry preserves additional "=" inside the value'); + + const badInputs = ['no-equals', 'no-dot=value', '=value', '.=value', 'foo.=value', '.bar=value', '']; + let allBadThrow = true; + for (const bad of badInputs) { + try { + parseSetEntry(bad); + allBadThrow = false; + } catch { + /* expected */ + } + } + assert(allBadThrow, `parseSetEntry rejects malformed inputs (${badInputs.length} cases)`); + + 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'); + 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. + const polluteProbe = {}; + let pollutionThrown = false; + try { + parseSetEntries(['__proto__.polluted=1']); + } catch { + pollutionThrown = true; + } + assert(pollutionThrown, 'parseSetEntries rejects __proto__ as a module name'); + assert(polluteProbe.polluted === undefined, 'Object.prototype is not polluted by __proto__ in --set entries'); + let constructorThrown = false; + try { + parseSetEntries(['bmm.constructor=evil']); + } catch { + constructorThrown = true; + } + 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. + 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 { + 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 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'); + + // Case-insensitive filter. + const bmmUpper = await formatOptionsList('BMM'); + assert(bmmUpper.ok === true && bmmUpper.text.includes('bmm.project_knowledge'), '--list-options is case-insensitive'); + + // Unknown module → non-zero exit signal. + const unknown = await formatOptionsList('definitely-not-a-module'); + assert(unknown.ok === false, '--list-options reports ok: false'); + assert(unknown.text.includes('No locally-known module.yaml'), '--list-options unknown explains the miss'); + } finally { + if (priorCacheEnv44 === undefined) { + delete process.env.BMAD_EXTERNAL_MODULES_CACHE; + } else { + process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv44; + } + await fs.remove(tempCacheDir44).catch(() => {}); + } + } catch (error) { + console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`); + console.log(error.stack); + failed++; + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 55adcfb9c..1dfe6fb70 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -18,6 +18,16 @@ module.exports = { 'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Required for fresh non-interactive (--yes) installs. Run with --list-tools to see all valid IDs.', ], ['--list-tools', 'Print all supported tool/IDE IDs (with target directories) and exit.'], + [ + '--set ', + 'Set a module config option non-interactively. Spec format: .= (e.g. bmm.project_knowledge=research). Repeatable. Run --list-options to see available keys.', + (value, prev) => [...(prev || []), value], + [], + ], + [ + '--list-options [module]', + 'List available --set keys for all locally-known official modules, or for a single module by code, then exit.', + ], ['--action ', 'Action type for existing installations: install, update, or quick-update'], ['--user-name ', 'Name for agents to use (default: system username)'], ['--communication-language ', 'Language for agent communication (default: English)'], @@ -47,12 +57,43 @@ module.exports = { process.exit(0); } + if (options.listOptions !== undefined) { + const { formatOptionsList } = require('../list-options'); + 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; + } + // Set debug flag as environment variable for all components if (options.debug) { process.env.BMAD_DEBUG_MANIFEST = 'true'; await prompts.log.info('Debug mode enabled'); } + // Validate --set syntax up-front so malformed entries fail fast, + // before we touch the network or filesystem. Parsed entries are + // re-derived inside ui.js where overrides are seeded. + if (options.set && options.set.length > 0) { + const { parseSetEntries } = require('../set-overrides'); + try { + parseSetEntries(options.set); + } catch (error) { + await prompts.log.error(error.message); + process.exit(1); + } + } + const config = await ui.promptInstall(options); // Handle cancel @@ -61,8 +102,13 @@ module.exports = { process.exit(0); } - // Handle quick update separately + // Handle quick update separately. --set is a post-install TOML patch so + // it works the same way for quick-update as for a regular install — the + // installer runs, then `applySetOverrides` patches the central config + // files. Pass the parsed overrides through. if (config.actionType === 'quick-update') { + const { parseSetEntries } = require('../set-overrides'); + config.setOverrides = parseSetEntries(options.set || []); const result = await installer.quickUpdate(config); await prompts.log.success('Quick update complete!'); await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`); diff --git a/tools/installer/core/config.js b/tools/installer/core/config.js index bc359fed9..39617de4c 100644 --- a/tools/installer/core/config.js +++ b/tools/installer/core/config.js @@ -3,7 +3,19 @@ * User input comes from either UI answers or headless CLI flags. */ class Config { - constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate, channelOptions }) { + constructor({ + directory, + modules, + ides, + skipPrompts, + verbose, + actionType, + coreConfig, + moduleConfigs, + quickUpdate, + channelOptions, + setOverrides, + }) { this.directory = directory; this.modules = Object.freeze([...modules]); this.ides = Object.freeze([...ides]); @@ -15,6 +27,11 @@ class Config { this._quickUpdate = quickUpdate; // channelOptions carry a Map + Set; don't deep-freeze. this.channelOptions = channelOptions || null; + // Parsed `--set .=` overrides, applied as a TOML + // patch AFTER the install finishes. Shape: { moduleCode: { key: value } }. + // Intentionally NOT integrated with the prompt/template/schema flow; see + // `tools/installer/set-overrides.js` for the rationale and tradeoffs. + this.setOverrides = setOverrides || {}; Object.freeze(this); } @@ -40,6 +57,7 @@ class Config { moduleConfigs: userInput.moduleConfigs || null, quickUpdate: userInput._quickUpdate || false, channelOptions: userInput.channelOptions || null, + setOverrides: userInput.setOverrides || {}, }); } diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index b91ba6bb7..4952c89e1 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -310,6 +310,19 @@ class Installer { moduleConfigs, }); + // 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'); @@ -1283,6 +1296,10 @@ class Installer { ides: configuredIdes, coreConfig: quickModules.collectedConfig.core, moduleConfigs: quickModules.collectedConfig, + // Forward `--set` overrides so the post-install patch step + // (`applySetOverrides`) runs at the end of quick-update too. The + // installer.install path applies them after writeCentralConfig. + setOverrides: config.setOverrides || {}, actionType: 'install', _quickUpdate: true, _preserveModules: skippedModules, diff --git a/tools/installer/list-options.js b/tools/installer/list-options.js new file mode 100644 index 000000000..d06be8b06 --- /dev/null +++ b/tools/installer/list-options.js @@ -0,0 +1,210 @@ +const path = require('node:path'); +const fs = require('./fs-native'); +const yaml = require('yaml'); +const { getProjectRoot, getModulePath, getExternalModuleCachePath } = require('./project-root'); + +/** + * Read a module.yaml and return its declared `code:` field, or null if missing/unparseable. + */ +async function readModuleCode(yamlPath) { + try { + const parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8')); + if (parsed && typeof parsed === 'object' && typeof parsed.code === 'string') { + return parsed.code; + } + } catch { + // fall through + } + return null; +} + +/** + * Discover module.yaml files for officials we can read locally: + * - core, bmm: bundled in src/ (always present) + * - external officials: only if previously cloned to ~/.bmad/cache/external-modules/ + * + * Each result's `code` is the `code:` field from the module.yaml when present; + * that's the value `--set .=` matches against. + * + * Community/custom modules are not enumerated; users reference their own + * module.yaml directly per the design (see issue #1663). + * + * @returns {Promise>} + */ +async function discoverOfficialModuleYamls() { + const found = []; + // Dedupe is case-insensitive because module caches occasionally retain a + // legacy UPPERCASE-named directory alongside the canonical lowercase one + // (same module, different cache key from an older schema). We pick whichever + // entry we see first and skip the alternate-case duplicate. NOTE: `--set` + // matching itself is case-sensitive (it keys on `moduleName` from the install + // flow's selected list, which is always lowercase short codes), so the + // surfaced `code` here is what users should type. Don't change to + // case-sensitive dedupe without revisiting that contract. + const seenCodes = new Set(); + + const addFound = async (yamlPath, source, fallbackCode) => { + const declaredCode = await readModuleCode(yamlPath); + const code = declaredCode || fallbackCode; + if (!code) return; + const lower = code.toLowerCase(); + if (seenCodes.has(lower)) return; + seenCodes.add(lower); + found.push({ code, yamlPath, source }); + }; + + // Built-ins. + for (const code of ['core', 'bmm']) { + const yamlPath = path.join(getModulePath(code), 'module.yaml'); + if (await fs.pathExists(yamlPath)) { + // Built-ins use their well-known short codes regardless of what the + // module.yaml `code:` says, since the install flow keys on these. + seenCodes.add(code.toLowerCase()); + found.push({ code, yamlPath, source: 'built-in' }); + } + } + + // Bundled in src/modules//module.yaml (rare, but supported by getModulePath). + const srcModulesDir = path.join(getProjectRoot(), 'src', 'modules'); + if (await fs.pathExists(srcModulesDir)) { + const entries = await fs.readdir(srcModulesDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const yamlPath = path.join(srcModulesDir, entry.name, 'module.yaml'); + if (await fs.pathExists(yamlPath)) { + await addFound(yamlPath, 'bundled', entry.name); + } + } + } + + // External cache (~/.bmad/cache/external-modules//...). + const cacheRoot = getExternalModuleCachePath('').replace(/\/$/, ''); + if (await fs.pathExists(cacheRoot)) { + const rawEntries = await fs.readdir(cacheRoot, { withFileTypes: true }); + for (const entry of rawEntries) { + if (!entry.isDirectory()) continue; + const candidates = [ + path.join(cacheRoot, entry.name, 'module.yaml'), + path.join(cacheRoot, entry.name, 'src', 'module.yaml'), + path.join(cacheRoot, entry.name, 'skills', 'module.yaml'), + ]; + for (const candidate of candidates) { + if (await fs.pathExists(candidate)) { + await addFound(candidate, 'cached', entry.name); + break; + } + } + } + } + + return found; +} + +function formatPromptText(item) { + if (Array.isArray(item.prompt)) return item.prompt.join(' '); + return String(item.prompt || '').trim(); +} + +function inferType(item) { + if (item['single-select']) return 'single-select'; + if (item['multi-select']) return 'multi-select'; + if (typeof item.default === 'boolean') return 'boolean'; + if (typeof item.default === 'number') return 'number'; + return 'string'; +} + +function formatModuleOptions(code, parsed, source) { + const lines = []; + const header = source === 'built-in' ? code : `${code} (${source})`; + lines.push(header + ':'); + + let count = 0; + for (const [key, item] of Object.entries(parsed)) { + if (!item || typeof item !== 'object' || !('prompt' in item)) continue; + count++; + const type = inferType(item); + const scope = item.scope === 'user' ? ' [user-scope]' : ''; + const defaultStr = item.default === undefined || item.default === null ? '(none)' : String(item.default); + lines.push(` ${code}.${key} (${type}${scope}) default: ${defaultStr}`); + const promptText = formatPromptText(item); + if (promptText) lines.push(` ${promptText}`); + if (Array.isArray(item['single-select'])) { + const values = item['single-select'].map((v) => (typeof v === 'object' ? v.value : v)).filter((v) => v !== undefined); + if (values.length > 0) lines.push(` values: ${values.join(' | ')}`); + } + lines.push(''); + } + + if (count === 0) { + lines.push(' (no configurable options)', ''); + } + return lines.join('\n'); +} + +/** + * Render `--list-options` output. + * + * Returns `{ text, ok }` so callers can surface a non-zero exit code on + * a typo'd module-code lookup. Discovery dedupes case-insensitively, so + * the lookup is also case-insensitive — typing `--list-options BMM` and + * `--list-options bmm` both find the bmm built-in. + * + * @param {string|null} moduleCode - if non-null, restrict to this module + * @returns {Promise<{text: string, ok: boolean}>} + */ +async function formatOptionsList(moduleCode) { + const discovered = await discoverOfficialModuleYamls(); + const needle = moduleCode ? moduleCode.toLowerCase() : null; + const filtered = needle ? discovered.filter((d) => d.code.toLowerCase() === needle) : discovered; + + if (filtered.length === 0) { + if (moduleCode) { + const text = [ + `No locally-known module.yaml for '${moduleCode}'.`, + '', + 'Built-in modules (core, bmm) are always available. External officials', + 'appear here after they have been installed at least once on this machine', + '(they are cached under ~/.bmad/cache/external-modules/).', + '', + 'For community or custom modules, read the module.yaml file in that', + "module's source repository directly.", + ].join('\n'); + return { text, ok: false }; + } + return { text: 'No modules found.', ok: false }; + } + + const sections = []; + // Track when a module-scoped lookup couldn't actually be rendered (yaml + // unparseable or empty after parse). The full `--list-options` output is + // tolerant of one bad entry, but `--list-options ` against a single + // unreadable module should still fail tooling so a CI script catches it. + let moduleScopedFailure = false; + sections.push('Available --set keys', 'Format: --set .= (repeatable)', ''); + for (const { code, yamlPath, source } of filtered) { + let parsed; + try { + parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8')); + } catch { + sections.push(`${code} (${source}): could not parse module.yaml`, ''); + if (moduleCode) moduleScopedFailure = true; + continue; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + sections.push(`${code} (${source}): module.yaml is not a valid object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, ''); + if (moduleCode) moduleScopedFailure = true; + continue; + } + sections.push(formatModuleOptions(code, parsed, source)); + } + + if (!moduleCode) { + sections.push( + 'Community and custom modules are not listed here — read their module.yaml directly. Unknown keys still persist with a warning.', + ); + } + + return { text: sections.join('\n'), ok: !moduleScopedFailure }; +} + +module.exports = { formatOptionsList, discoverOfficialModuleYamls }; diff --git a/tools/installer/set-overrides.js b/tools/installer/set-overrides.js new file mode 100644 index 000000000..9349ee2d6 --- /dev/null +++ b/tools/installer/set-overrides.js @@ -0,0 +1,330 @@ +// `--set .=` is a post-install patch. The installer runs +// its normal flow and writes `_bmad/config.toml`, `_bmad/config.user.toml`, +// and `_bmad//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. +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 .=` entry. + * @param {string} entry - raw flag value + * @returns {{module: string, key: string, value: string}} + * @throws {Error} on malformed input + */ +function parseSetEntry(entry) { + if (typeof entry !== 'string' || entry.length === 0) { + throw new Error('--set: empty entry. Expected .='); + } + const eq = entry.indexOf('='); + if (eq === -1) { + throw new Error(`--set "${entry}": missing '='. Expected .=`); + } + const lhs = entry.slice(0, eq); + // Note: only the LHS is trimmed. Values may legitimately contain leading + // or trailing whitespace (paths with spaces, quoted strings); module / key + // names cannot, so it's safe to be strict on the left. + const value = entry.slice(eq + 1); + const dot = lhs.indexOf('.'); + if (dot === -1) { + throw new Error(`--set "${entry}": missing '.'. Expected .=`); + } + const moduleCode = lhs.slice(0, dot).trim(); + const key = lhs.slice(dot + 1).trim(); + if (!moduleCode || !key) { + throw new Error(`--set "${entry}": empty module or key. Expected .=`); + } + if (PROTOTYPE_POLLUTING_NAMES.has(moduleCode) || PROTOTYPE_POLLUTING_NAMES.has(key)) { + throw new Error( + `--set "${entry}": '__proto__', 'prototype', and 'constructor' are reserved and cannot be used as a module or key name.`, + ); + } + return { module: moduleCode, key, value }; +} + +/** + * 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. + * + * @param {string[]} entries + * @returns {Object>} + */ +function parseSetEntries(entries) { + const overrides = Object.create(null); + if (!Array.isArray(entries)) return overrides; + for (const entry of entries) { + const { module: moduleCode, key, value } = parseSetEntry(entry); + if (!overrides[moduleCode]) overrides[moduleCode] = Object.create(null); + overrides[moduleCode][key] = value; + } + 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]` + * - `` → `[modules.]` + * + * 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 ` = ...` 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>} overrides + * @param {string} bmadDir absolute path to `_bmad/` + * @returns {Promise>} + * 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//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//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//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 }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 12501b3f2..5770206ef 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -16,6 +16,7 @@ const { } = require('./modules/channel-plan'); const channelResolver = require('./modules/channel-resolver'); const prompts = require('./prompts'); +const { parseSetEntries } = require('./set-overrides'); const manifest = new Manifest(); @@ -287,7 +288,7 @@ class UI { // Get tool selection const toolSelection = await this.promptToolSelection(confirmedDirectory, options); - const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { + const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { ...options, channelOptions, }); @@ -313,6 +314,7 @@ class UI { skipIde: toolSelection.skipIde, coreConfig: moduleConfigs.core || {}, moduleConfigs: moduleConfigs, + setOverrides, skipPrompts: options.yes || false, channelOptions, }; @@ -364,7 +366,7 @@ class UI { await this._interactiveChannelGate({ options, channelOptions, selectedModules }); let toolSelection = await this.promptToolSelection(confirmedDirectory, options); - const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { + const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { ...options, channelOptions, }); @@ -390,6 +392,7 @@ class UI { skipIde: toolSelection.skipIde, coreConfig: moduleConfigs.core || {}, moduleConfigs: moduleConfigs, + setOverrides, skipPrompts: options.yes || false, channelOptions, }; @@ -709,6 +712,33 @@ 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. + let setOverrides = {}; + try { + setOverrides = parseSetEntries(options.set || []); + } catch (error) { + // 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 }); // Seed core config from CLI options if provided @@ -774,7 +804,7 @@ class UI { skipPrompts: options.yes || false, }); - return configCollector.collectedConfig; + return { moduleConfigs: configCollector.collectedConfig, setOverrides }; } /**