bmad-method 6.5.1-next.8 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ // `--set <module>.<key>=<value>` is a post-install patch. The installer runs
2
+ // its normal flow and writes `_bmad/config.toml`, `_bmad/config.user.toml`,
3
+ // and `_bmad/<module>/config.yaml`; afterwards `applySetOverrides` upserts
4
+ // each override into those files.
5
+ //
6
+ // This is intentionally NOT integrated with the prompt/template/schema
7
+ // system. Tradeoffs:
8
+ // - No `result:` template rendering: `--set bmm.project_knowledge=research`
9
+ // writes "research" verbatim. Pass `--set bmm.project_knowledge='{project-root}/research'`
10
+ // if you want the rendered form.
11
+ // - Carry-forward across installs is best-effort: declared schema keys
12
+ // persist via the existingValue path on the next interactive run; values
13
+ // for keys outside any module's schema may need to be re-passed on each
14
+ // install (or edited directly in `_bmad/config.toml`).
15
+ // - No "key not in schema" validation: whatever you assert, we write.
16
+ //
17
+ // Names that, when used as object keys, can mutate `Object.prototype` and
18
+ // cascade into every plain-object lookup in the process. The `--set` pipeline
19
+ // assigns into plain `{}` maps keyed by user input, so `--set __proto__.x=1`
20
+ // would otherwise reach `overrides.__proto__[x] = 1` and pollute every plain
21
+ // object. We reject the names at parse time and harden the maps in
22
+ // `parseSetEntries` with `Object.create(null)` for defense-in-depth.
23
+ const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
24
+
25
+ const path = require('node:path');
26
+ const fs = require('./fs-native');
27
+ const yaml = require('yaml');
28
+
29
+ /**
30
+ * Parse a single `--set <module>.<key>=<value>` entry.
31
+ * @param {string} entry - raw flag value
32
+ * @returns {{module: string, key: string, value: string}}
33
+ * @throws {Error} on malformed input
34
+ */
35
+ function parseSetEntry(entry) {
36
+ if (typeof entry !== 'string' || entry.length === 0) {
37
+ throw new Error('--set: empty entry. Expected <module>.<key>=<value>');
38
+ }
39
+ const eq = entry.indexOf('=');
40
+ if (eq === -1) {
41
+ throw new Error(`--set "${entry}": missing '='. Expected <module>.<key>=<value>`);
42
+ }
43
+ const lhs = entry.slice(0, eq);
44
+ // Note: only the LHS is trimmed. Values may legitimately contain leading
45
+ // or trailing whitespace (paths with spaces, quoted strings); module / key
46
+ // names cannot, so it's safe to be strict on the left.
47
+ const value = entry.slice(eq + 1);
48
+ const dot = lhs.indexOf('.');
49
+ if (dot === -1) {
50
+ throw new Error(`--set "${entry}": missing '.'. Expected <module>.<key>=<value>`);
51
+ }
52
+ const moduleCode = lhs.slice(0, dot).trim();
53
+ const key = lhs.slice(dot + 1).trim();
54
+ if (!moduleCode || !key) {
55
+ throw new Error(`--set "${entry}": empty module or key. Expected <module>.<key>=<value>`);
56
+ }
57
+ if (PROTOTYPE_POLLUTING_NAMES.has(moduleCode) || PROTOTYPE_POLLUTING_NAMES.has(key)) {
58
+ throw new Error(
59
+ `--set "${entry}": '__proto__', 'prototype', and 'constructor' are reserved and cannot be used as a module or key name.`,
60
+ );
61
+ }
62
+ return { module: moduleCode, key, value };
63
+ }
64
+
65
+ /**
66
+ * Parse repeated `--set` entries into a `{ module: { key: value } }` map.
67
+ * Later entries overwrite earlier ones for the same key. Both the outer
68
+ * map and the per-module inner maps are `Object.create(null)` so callers
69
+ * that bypass `parseSetEntry`'s name check still can't pollute prototypes.
70
+ *
71
+ * @param {string[]} entries
72
+ * @returns {Object<string, Object<string, string>>}
73
+ */
74
+ function parseSetEntries(entries) {
75
+ const overrides = Object.create(null);
76
+ if (!Array.isArray(entries)) return overrides;
77
+ for (const entry of entries) {
78
+ const { module: moduleCode, key, value } = parseSetEntry(entry);
79
+ if (!overrides[moduleCode]) overrides[moduleCode] = Object.create(null);
80
+ overrides[moduleCode][key] = value;
81
+ }
82
+ return overrides;
83
+ }
84
+
85
+ /**
86
+ * Encode a JS string as a TOML basic string (double-quoted with escapes).
87
+ * @param {string} value
88
+ */
89
+ function tomlString(value) {
90
+ const s = String(value);
91
+ // Per the TOML spec, basic strings escape `\`, `"`, and control characters.
92
+ return (
93
+ '"' +
94
+ s
95
+ .replaceAll('\\', '\\\\')
96
+ .replaceAll('"', String.raw`\"`)
97
+ .replaceAll('\b', String.raw`\b`)
98
+ .replaceAll('\f', String.raw`\f`)
99
+ .replaceAll('\n', String.raw`\n`)
100
+ .replaceAll('\r', String.raw`\r`)
101
+ .replaceAll('\t', String.raw`\t`) +
102
+ '"'
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Section header for a given module code.
108
+ * - `core` → `[core]`
109
+ * - `<other>` → `[modules.<other>]`
110
+ *
111
+ * Mirrors the layout `manifest-generator.writeCentralConfig` produces.
112
+ */
113
+ function sectionHeader(moduleCode) {
114
+ return moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
115
+ }
116
+
117
+ /**
118
+ * Insert or update `key = value` inside a TOML section, returning the new
119
+ * file content. The format produced by the installer is regular and small
120
+ * enough that a line scanner is more reliable than pulling in a TOML
121
+ * round-tripper that would normalize the file's existing whitespace and
122
+ * comment structure.
123
+ *
124
+ * - If `[section]` exists and contains `key`, replace the value on that
125
+ * line (preserving any inline comment after the value).
126
+ * - If `[section]` exists but `key` doesn't, append `key = value` at the
127
+ * end of the section (before the next `[...]` header or EOF, skipping
128
+ * trailing blank lines so the section stays tidy).
129
+ * - If `[section]` doesn't exist, append a new section block at EOF.
130
+ *
131
+ * @param {string} content existing file content (may be empty)
132
+ * @param {string} section exact `[section]` header to target
133
+ * @param {string} key
134
+ * @param {string} valueToml already TOML-encoded value (e.g. `"foo"`)
135
+ * @returns {string} new content
136
+ */
137
+ function upsertTomlKey(content, section, key, valueToml) {
138
+ const lines = content.split('\n');
139
+ // Track whether the file already ended with a newline so we can preserve
140
+ // that. `split('\n')` on `"a\n"` yields `['a', '']`, which gives us the
141
+ // marker we need.
142
+ const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
143
+ if (hadTrailingNewline) lines.pop();
144
+
145
+ // Locate the target section.
146
+ const sectionStart = lines.findIndex((line) => line.trim() === section);
147
+ if (sectionStart === -1) {
148
+ // Section doesn't exist — append a new block. Pad with a blank line if
149
+ // the file is non-empty so sections stay visually separated.
150
+ if (lines.length > 0 && lines.at(-1).trim() !== '') lines.push('');
151
+ lines.push(section, `${key} = ${valueToml}`);
152
+ return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
153
+ }
154
+
155
+ // Find the section's end (next `[...]` header or EOF).
156
+ let sectionEnd = lines.length;
157
+ for (let i = sectionStart + 1; i < lines.length; i++) {
158
+ if (/^\s*\[/.test(lines[i])) {
159
+ sectionEnd = i;
160
+ break;
161
+ }
162
+ }
163
+
164
+ // Look for the key inside the section. Match `<key> = ...` allowing
165
+ // optional leading whitespace; preserve the comment tail (`# ...`) if any.
166
+ const keyPattern = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=\\s*(.*)$`);
167
+ for (let i = sectionStart + 1; i < sectionEnd; i++) {
168
+ const match = lines[i].match(keyPattern);
169
+ if (match) {
170
+ const indent = match[1];
171
+ // Preserve trailing comment if present. We split on the first `#` that
172
+ // is preceded by whitespace — TOML strings can't contain unescaped `#`
173
+ // in basic-string form so this is safe for the values we emit.
174
+ const tail = match[2];
175
+ const commentIdx = tail.search(/\s+#/);
176
+ const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
177
+ lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
178
+ return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
179
+ }
180
+ }
181
+
182
+ // Section exists but key doesn't. Insert before the next section header,
183
+ // skipping trailing blank lines inside the current section so the new
184
+ // entry sits with its siblings.
185
+ let insertAt = sectionEnd;
186
+ while (insertAt > sectionStart + 1 && lines[insertAt - 1].trim() === '') {
187
+ insertAt--;
188
+ }
189
+ lines.splice(insertAt, 0, `${key} = ${valueToml}`);
190
+ return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
191
+ }
192
+
193
+ function escapeRegExp(s) {
194
+ return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
195
+ }
196
+
197
+ /**
198
+ * Look up `[section] key` in a TOML file. Returns true if the file exists,
199
+ * the section is present, and `key` is set within it. Used by
200
+ * `applySetOverrides` to route an override to the file that already owns
201
+ * the key (so user-scope keys land in `config.user.toml`, team-scope keys
202
+ * land in `config.toml`).
203
+ */
204
+ async function tomlHasKey(filePath, section, key) {
205
+ if (!(await fs.pathExists(filePath))) return false;
206
+ const content = await fs.readFile(filePath, 'utf8');
207
+ const lines = content.split('\n');
208
+ const sectionStart = lines.findIndex((line) => line.trim() === section);
209
+ if (sectionStart === -1) return false;
210
+ const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
211
+ for (let i = sectionStart + 1; i < lines.length; i++) {
212
+ if (/^\s*\[/.test(lines[i])) return false;
213
+ if (keyPattern.test(lines[i])) return true;
214
+ }
215
+ return false;
216
+ }
217
+
218
+ /**
219
+ * Apply parsed `--set` overrides to the central TOML files written by the
220
+ * installer. Called at the end of an install / quick-update.
221
+ *
222
+ * Routing per (module, key):
223
+ * 1. If `_bmad/config.user.toml` already has `[section] key`, update there
224
+ * (user-scope key like `core.user_name`, `bmm.user_skill_level`).
225
+ * 2. Otherwise update `_bmad/config.toml` (team scope, the default).
226
+ *
227
+ * The schema-correct user/team partition lives in `manifest-generator`. We
228
+ * intentionally don't re-read module schemas here — the only goal is to
229
+ * match the file the installer just wrote the key to. For brand-new keys
230
+ * (not in either file yet), team scope is the safe default.
231
+ *
232
+ * @param {Object<string, Object<string, string>>} overrides
233
+ * @param {string} bmadDir absolute path to `_bmad/`
234
+ * @returns {Promise<Array<{module:string,key:string,scope:'team'|'user',file:string}>>}
235
+ * a list of applied entries (for caller logging)
236
+ */
237
+ async function applySetOverrides(overrides, bmadDir) {
238
+ const applied = [];
239
+ if (!overrides || typeof overrides !== 'object') return applied;
240
+
241
+ const teamPath = path.join(bmadDir, 'config.toml');
242
+ const userPath = path.join(bmadDir, 'config.user.toml');
243
+
244
+ for (const moduleCode of Object.keys(overrides)) {
245
+ // Skip overrides for modules not actually installed. The installer writes
246
+ // `_bmad/<module>/config.yaml` for every installed module (including core),
247
+ // so its presence is a reliable "is this module here?" signal that works
248
+ // for both fresh installs and quick-updates without coupling to caller-
249
+ // supplied module lists.
250
+ const moduleConfigYaml = path.join(bmadDir, moduleCode, 'config.yaml');
251
+ if (!(await fs.pathExists(moduleConfigYaml))) {
252
+ continue;
253
+ }
254
+
255
+ const section = sectionHeader(moduleCode);
256
+ const moduleOverrides = overrides[moduleCode] || {};
257
+ for (const key of Object.keys(moduleOverrides)) {
258
+ const value = moduleOverrides[key];
259
+ const valueToml = tomlString(value);
260
+
261
+ const userOwnsIt = await tomlHasKey(userPath, section, key);
262
+ const targetPath = userOwnsIt ? userPath : teamPath;
263
+
264
+ // The team file always exists post-install; the user file only exists
265
+ // if the install wrote at least one user-scope key. If we're routing to
266
+ // it but it doesn't exist yet, create it with a minimal header so it
267
+ // has the same shape as installer-written user toml.
268
+ let content = '';
269
+ if (await fs.pathExists(targetPath)) {
270
+ content = await fs.readFile(targetPath, 'utf8');
271
+ } else {
272
+ content = '# Personal overrides for _bmad/config.toml.\n';
273
+ }
274
+
275
+ const next = upsertTomlKey(content, section, key, valueToml);
276
+ await fs.writeFile(targetPath, next, 'utf8');
277
+ applied.push({
278
+ module: moduleCode,
279
+ key,
280
+ scope: userOwnsIt ? 'user' : 'team',
281
+ file: path.basename(targetPath),
282
+ });
283
+ }
284
+
285
+ // Also patch the per-module yaml (`_bmad/<module>/config.yaml`). The
286
+ // installer reads this file as `_existingConfig` on subsequent runs and
287
+ // surfaces declared values as prompt defaults — under `--yes` those
288
+ // defaults are accepted, so patching here gives `--set` natural
289
+ // carry-forward for declared keys without needing schema-strict
290
+ // partition exemptions in the manifest writer. For undeclared keys the
291
+ // value lives in the per-module yaml but won't be re-emitted into
292
+ // config.toml on the next install (the schema-strict partition drops
293
+ // it); re-pass `--set` if you need it sticky.
294
+ const moduleYamlPath = path.join(bmadDir, moduleCode, 'config.yaml');
295
+ if (await fs.pathExists(moduleYamlPath)) {
296
+ try {
297
+ const text = await fs.readFile(moduleYamlPath, 'utf8');
298
+ const parsed = yaml.parse(text);
299
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
300
+ // Preserve the installer's banner header (everything up to the
301
+ // first non-comment line) so `_bmad/<module>/config.yaml` keeps
302
+ // its provenance comments after we round-trip it.
303
+ const headerLines = [];
304
+ for (const line of text.split('\n')) {
305
+ if (line.startsWith('#') || line.trim() === '') {
306
+ headerLines.push(line);
307
+ } else {
308
+ break;
309
+ }
310
+ }
311
+ for (const key of Object.keys(moduleOverrides)) {
312
+ parsed[key] = moduleOverrides[key];
313
+ }
314
+ const body = yaml.stringify(parsed, { indent: 2, lineWidth: 0, minContentWidth: 0 });
315
+ const header = headerLines.length > 0 ? headerLines.join('\n') + '\n' : '';
316
+ await fs.writeFile(moduleYamlPath, header + body, 'utf8');
317
+ }
318
+ } catch {
319
+ // Per-module yaml unparseable — skip silently. The central toml was
320
+ // already patched above, which is the user-visible state for the
321
+ // current install. Carry-forward will fail next install but the
322
+ // current install reflects the override.
323
+ }
324
+ }
325
+ }
326
+
327
+ return applied;
328
+ }
329
+
330
+ module.exports = { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString };
@@ -16,6 +16,7 @@ const {
16
16
  } = require('./modules/channel-plan');
17
17
  const channelResolver = require('./modules/channel-resolver');
18
18
  const prompts = require('./prompts');
19
+ const { parseSetEntries } = require('./set-overrides');
19
20
 
20
21
  const manifest = new Manifest();
21
22
 
@@ -287,7 +288,7 @@ class UI {
287
288
  // Get tool selection
288
289
  const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
289
290
 
290
- const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
291
+ const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
291
292
  ...options,
292
293
  channelOptions,
293
294
  });
@@ -313,6 +314,7 @@ class UI {
313
314
  skipIde: toolSelection.skipIde,
314
315
  coreConfig: moduleConfigs.core || {},
315
316
  moduleConfigs: moduleConfigs,
317
+ setOverrides,
316
318
  skipPrompts: options.yes || false,
317
319
  channelOptions,
318
320
  };
@@ -364,7 +366,7 @@ class UI {
364
366
  await this._interactiveChannelGate({ options, channelOptions, selectedModules });
365
367
 
366
368
  let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
367
- const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
369
+ const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
368
370
  ...options,
369
371
  channelOptions,
370
372
  });
@@ -390,6 +392,7 @@ class UI {
390
392
  skipIde: toolSelection.skipIde,
391
393
  coreConfig: moduleConfigs.core || {},
392
394
  moduleConfigs: moduleConfigs,
395
+ setOverrides,
393
396
  skipPrompts: options.yes || false,
394
397
  channelOptions,
395
398
  };
@@ -709,6 +712,33 @@ class UI {
709
712
  */
710
713
  async collectModuleConfigs(directory, modules, options = {}) {
711
714
  const { OfficialModules } = require('./modules/official-modules');
715
+
716
+ // Parse --set up front purely to surface user-error before the install
717
+ // burns time on the network / filesystem. The actual application happens
718
+ // in installer.install() as a post-write TOML patch — see
719
+ // `tools/installer/set-overrides.js`. We also warn about overrides
720
+ // targeting modules the user didn't include, since those will silently
721
+ // miss the file the patch step looks for.
722
+ let setOverrides = {};
723
+ try {
724
+ setOverrides = parseSetEntries(options.set || []);
725
+ } catch (error) {
726
+ // install.js validated already; rethrow as-is for the user.
727
+ throw error;
728
+ }
729
+ // Drop overrides for modules that aren't in the install set so the
730
+ // post-install patch step doesn't create orphan sections in config.toml
731
+ // for modules that were never installed.
732
+ const selectedModuleSet = new Set(['core', ...modules]);
733
+ for (const moduleCode of Object.keys(setOverrides)) {
734
+ if (!selectedModuleSet.has(moduleCode)) {
735
+ await prompts.log.warn(
736
+ `--set ${moduleCode}.* — module '${moduleCode}' is not in the install set; values will be ignored. Add it to --modules to apply.`,
737
+ );
738
+ delete setOverrides[moduleCode];
739
+ }
740
+ }
741
+
712
742
  const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
713
743
 
714
744
  // Seed core config from CLI options if provided
@@ -758,6 +788,9 @@ class UI {
758
788
  const defaultUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1);
759
789
  configCollector.collectedConfig.core = {
760
790
  user_name: defaultUsername,
791
+ // {directory_name} default per src/core-skills/module.yaml — matches what the
792
+ // interactive flow resolves via buildQuestion()'s {directory_name} placeholder.
793
+ project_name: path.basename(directory),
761
794
  communication_language: 'English',
762
795
  document_output_language: 'English',
763
796
  output_folder: '_bmad-output',
@@ -771,7 +804,7 @@ class UI {
771
804
  skipPrompts: options.yes || false,
772
805
  });
773
806
 
774
- return configCollector.collectedConfig;
807
+ return { moduleConfigs: configCollector.collectedConfig, setOverrides };
775
808
  }
776
809
 
777
810
  /**