bmad-method 6.5.1-next.10 → 6.5.1-next.12
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.
- package/README.md +9 -0
- package/package.json +4 -3
- package/tools/installer/commands/install.js +47 -1
- package/tools/installer/core/config.js +19 -1
- package/tools/installer/core/installer.js +17 -0
- package/tools/installer/list-options.js +210 -0
- package/tools/installer/modules/custom-module-manager.js +85 -41
- package/tools/installer/set-overrides.js +330 -0
- package/tools/installer/ui.js +33 -3
package/README.md
CHANGED
|
@@ -52,6 +52,15 @@ Follow the installer prompts, then open your AI IDE (Claude Code, Cursor, etc.)
|
|
|
52
52
|
npx bmad-method install --directory /path/to/project --modules bmm --tools claude-code --yes
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
Override any module config option with `--set <module>.<key>=<value>` (repeatable). Run `--list-options [module]` to see locally-known official keys (built-in modules plus any external officials cached on this machine):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx bmad-method install --yes \
|
|
59
|
+
--modules bmm --tools claude-code \
|
|
60
|
+
--set bmm.project_knowledge=research \
|
|
61
|
+
--set bmm.user_skill_level=expert
|
|
62
|
+
```
|
|
63
|
+
|
|
55
64
|
[See all installation options](https://docs.bmad-method.org/how-to/non-interactive-installation/)
|
|
56
65
|
|
|
57
66
|
> **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?`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-method",
|
|
4
|
-
"version": "6.5.1-next.
|
|
4
|
+
"version": "6.5.1-next.12",
|
|
5
5
|
"description": "Breakthrough Method of Agile AI-driven Development",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agile",
|
|
@@ -39,12 +39,13 @@
|
|
|
39
39
|
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
|
40
40
|
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
|
41
41
|
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
|
42
|
-
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
|
|
42
|
+
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills",
|
|
43
43
|
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
|
44
|
-
"test": "npm run test:refs && npm run test:install && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
|
|
44
|
+
"test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
|
|
45
45
|
"test:channels": "node test/test-installer-channels.js",
|
|
46
46
|
"test:install": "node test/test-installation-components.js",
|
|
47
47
|
"test:refs": "node test/test-file-refs-csv.js",
|
|
48
|
+
"test:urls": "node test/test-parse-source-urls.js",
|
|
48
49
|
"validate:refs": "node tools/validate-file-refs.js --strict",
|
|
49
50
|
"validate:skills": "node tools/validate-skills.js --strict"
|
|
50
51
|
},
|
|
@@ -18,6 +18,16 @@ module.exports = {
|
|
|
18
18
|
'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.',
|
|
19
19
|
],
|
|
20
20
|
['--list-tools', 'Print all supported tool/IDE IDs (with target directories) and exit.'],
|
|
21
|
+
[
|
|
22
|
+
'--set <spec>',
|
|
23
|
+
'Set a module config option non-interactively. Spec format: <module>.<key>=<value> (e.g. bmm.project_knowledge=research). Repeatable. Run --list-options to see available keys.',
|
|
24
|
+
(value, prev) => [...(prev || []), value],
|
|
25
|
+
[],
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
'--list-options [module]',
|
|
29
|
+
'List available --set keys for all locally-known official modules, or for a single module by code, then exit.',
|
|
30
|
+
],
|
|
21
31
|
['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
|
|
22
32
|
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
|
23
33
|
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
|
@@ -47,12 +57,43 @@ module.exports = {
|
|
|
47
57
|
process.exit(0);
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
if (options.listOptions !== undefined) {
|
|
61
|
+
const { formatOptionsList } = require('../list-options');
|
|
62
|
+
const moduleArg = options.listOptions === true ? null : options.listOptions;
|
|
63
|
+
const { text, ok } = await formatOptionsList(moduleArg);
|
|
64
|
+
const stream = ok ? process.stdout : process.stderr;
|
|
65
|
+
// process.exit() forces immediate termination and can truncate the
|
|
66
|
+
// buffered write when stdout/stderr is piped or captured by CI. Wait
|
|
67
|
+
// for the write to flush, then set process.exitCode and return so the
|
|
68
|
+
// event loop drains naturally. Non-zero exit when a single-module
|
|
69
|
+
// lookup misses so a CI typo like `--list-options bmn` doesn't look
|
|
70
|
+
// successful in scripts.
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
stream.write(text + '\n', (error) => (error ? reject(error) : resolve()));
|
|
73
|
+
});
|
|
74
|
+
process.exitCode = ok ? 0 : 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
// Set debug flag as environment variable for all components
|
|
51
79
|
if (options.debug) {
|
|
52
80
|
process.env.BMAD_DEBUG_MANIFEST = 'true';
|
|
53
81
|
await prompts.log.info('Debug mode enabled');
|
|
54
82
|
}
|
|
55
83
|
|
|
84
|
+
// Validate --set syntax up-front so malformed entries fail fast,
|
|
85
|
+
// before we touch the network or filesystem. Parsed entries are
|
|
86
|
+
// re-derived inside ui.js where overrides are seeded.
|
|
87
|
+
if (options.set && options.set.length > 0) {
|
|
88
|
+
const { parseSetEntries } = require('../set-overrides');
|
|
89
|
+
try {
|
|
90
|
+
parseSetEntries(options.set);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
await prompts.log.error(error.message);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
56
97
|
const config = await ui.promptInstall(options);
|
|
57
98
|
|
|
58
99
|
// Handle cancel
|
|
@@ -61,8 +102,13 @@ module.exports = {
|
|
|
61
102
|
process.exit(0);
|
|
62
103
|
}
|
|
63
104
|
|
|
64
|
-
// Handle quick update separately
|
|
105
|
+
// Handle quick update separately. --set is a post-install TOML patch so
|
|
106
|
+
// it works the same way for quick-update as for a regular install — the
|
|
107
|
+
// installer runs, then `applySetOverrides` patches the central config
|
|
108
|
+
// files. Pass the parsed overrides through.
|
|
65
109
|
if (config.actionType === 'quick-update') {
|
|
110
|
+
const { parseSetEntries } = require('../set-overrides');
|
|
111
|
+
config.setOverrides = parseSetEntries(options.set || []);
|
|
66
112
|
const result = await installer.quickUpdate(config);
|
|
67
113
|
await prompts.log.success('Quick update complete!');
|
|
68
114
|
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);
|
|
@@ -3,7 +3,19 @@
|
|
|
3
3
|
* User input comes from either UI answers or headless CLI flags.
|
|
4
4
|
*/
|
|
5
5
|
class Config {
|
|
6
|
-
constructor({
|
|
6
|
+
constructor({
|
|
7
|
+
directory,
|
|
8
|
+
modules,
|
|
9
|
+
ides,
|
|
10
|
+
skipPrompts,
|
|
11
|
+
verbose,
|
|
12
|
+
actionType,
|
|
13
|
+
coreConfig,
|
|
14
|
+
moduleConfigs,
|
|
15
|
+
quickUpdate,
|
|
16
|
+
channelOptions,
|
|
17
|
+
setOverrides,
|
|
18
|
+
}) {
|
|
7
19
|
this.directory = directory;
|
|
8
20
|
this.modules = Object.freeze([...modules]);
|
|
9
21
|
this.ides = Object.freeze([...ides]);
|
|
@@ -15,6 +27,11 @@ class Config {
|
|
|
15
27
|
this._quickUpdate = quickUpdate;
|
|
16
28
|
// channelOptions carry a Map + Set; don't deep-freeze.
|
|
17
29
|
this.channelOptions = channelOptions || null;
|
|
30
|
+
// Parsed `--set <module>.<key>=<value>` overrides, applied as a TOML
|
|
31
|
+
// patch AFTER the install finishes. Shape: { moduleCode: { key: value } }.
|
|
32
|
+
// Intentionally NOT integrated with the prompt/template/schema flow; see
|
|
33
|
+
// `tools/installer/set-overrides.js` for the rationale and tradeoffs.
|
|
34
|
+
this.setOverrides = setOverrides || {};
|
|
18
35
|
Object.freeze(this);
|
|
19
36
|
}
|
|
20
37
|
|
|
@@ -40,6 +57,7 @@ class Config {
|
|
|
40
57
|
moduleConfigs: userInput.moduleConfigs || null,
|
|
41
58
|
quickUpdate: userInput._quickUpdate || false,
|
|
42
59
|
channelOptions: userInput.channelOptions || null,
|
|
60
|
+
setOverrides: userInput.setOverrides || {},
|
|
43
61
|
});
|
|
44
62
|
}
|
|
45
63
|
|
|
@@ -310,6 +310,19 @@ class Installer {
|
|
|
310
310
|
moduleConfigs,
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
+
// Apply post-install --set TOML patches. Runs after writeCentralConfig
|
|
314
|
+
// (inside generateManifests above) so the patch operates on the
|
|
315
|
+
// freshly written `_bmad/config.toml` / `_bmad/config.user.toml`.
|
|
316
|
+
// See `tools/installer/set-overrides.js` for routing rules.
|
|
317
|
+
if (config.setOverrides && Object.keys(config.setOverrides).length > 0) {
|
|
318
|
+
const { applySetOverrides } = require('../set-overrides');
|
|
319
|
+
const applied = await applySetOverrides(config.setOverrides, paths.bmadDir);
|
|
320
|
+
if (applied.length > 0) {
|
|
321
|
+
const summary = applied.map((a) => `${a.module}.${a.key} → ${a.file}`).join(', ');
|
|
322
|
+
await prompts.log.info(`Applied --set overrides: ${summary}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
313
326
|
message('Generating help catalog...');
|
|
314
327
|
await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
|
|
315
328
|
addResult('Help catalog', 'ok');
|
|
@@ -1283,6 +1296,10 @@ class Installer {
|
|
|
1283
1296
|
ides: configuredIdes,
|
|
1284
1297
|
coreConfig: quickModules.collectedConfig.core,
|
|
1285
1298
|
moduleConfigs: quickModules.collectedConfig,
|
|
1299
|
+
// Forward `--set` overrides so the post-install patch step
|
|
1300
|
+
// (`applySetOverrides`) runs at the end of quick-update too. The
|
|
1301
|
+
// installer.install path applies them after writeCentralConfig.
|
|
1302
|
+
setOverrides: config.setOverrides || {},
|
|
1286
1303
|
actionType: 'install',
|
|
1287
1304
|
_quickUpdate: true,
|
|
1288
1305
|
_preserveModules: skippedModules,
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('./fs-native');
|
|
3
|
+
const yaml = require('yaml');
|
|
4
|
+
const { getProjectRoot, getModulePath, getExternalModuleCachePath } = require('./project-root');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read a module.yaml and return its declared `code:` field, or null if missing/unparseable.
|
|
8
|
+
*/
|
|
9
|
+
async function readModuleCode(yamlPath) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
|
12
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.code === 'string') {
|
|
13
|
+
return parsed.code;
|
|
14
|
+
}
|
|
15
|
+
} catch {
|
|
16
|
+
// fall through
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Discover module.yaml files for officials we can read locally:
|
|
23
|
+
* - core, bmm: bundled in src/ (always present)
|
|
24
|
+
* - external officials: only if previously cloned to ~/.bmad/cache/external-modules/
|
|
25
|
+
*
|
|
26
|
+
* Each result's `code` is the `code:` field from the module.yaml when present;
|
|
27
|
+
* that's the value `--set <module>.<key>=<value>` matches against.
|
|
28
|
+
*
|
|
29
|
+
* Community/custom modules are not enumerated; users reference their own
|
|
30
|
+
* module.yaml directly per the design (see issue #1663).
|
|
31
|
+
*
|
|
32
|
+
* @returns {Promise<Array<{code: string, yamlPath: string, source: string}>>}
|
|
33
|
+
*/
|
|
34
|
+
async function discoverOfficialModuleYamls() {
|
|
35
|
+
const found = [];
|
|
36
|
+
// Dedupe is case-insensitive because module caches occasionally retain a
|
|
37
|
+
// legacy UPPERCASE-named directory alongside the canonical lowercase one
|
|
38
|
+
// (same module, different cache key from an older schema). We pick whichever
|
|
39
|
+
// entry we see first and skip the alternate-case duplicate. NOTE: `--set`
|
|
40
|
+
// matching itself is case-sensitive (it keys on `moduleName` from the install
|
|
41
|
+
// flow's selected list, which is always lowercase short codes), so the
|
|
42
|
+
// surfaced `code` here is what users should type. Don't change to
|
|
43
|
+
// case-sensitive dedupe without revisiting that contract.
|
|
44
|
+
const seenCodes = new Set();
|
|
45
|
+
|
|
46
|
+
const addFound = async (yamlPath, source, fallbackCode) => {
|
|
47
|
+
const declaredCode = await readModuleCode(yamlPath);
|
|
48
|
+
const code = declaredCode || fallbackCode;
|
|
49
|
+
if (!code) return;
|
|
50
|
+
const lower = code.toLowerCase();
|
|
51
|
+
if (seenCodes.has(lower)) return;
|
|
52
|
+
seenCodes.add(lower);
|
|
53
|
+
found.push({ code, yamlPath, source });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Built-ins.
|
|
57
|
+
for (const code of ['core', 'bmm']) {
|
|
58
|
+
const yamlPath = path.join(getModulePath(code), 'module.yaml');
|
|
59
|
+
if (await fs.pathExists(yamlPath)) {
|
|
60
|
+
// Built-ins use their well-known short codes regardless of what the
|
|
61
|
+
// module.yaml `code:` says, since the install flow keys on these.
|
|
62
|
+
seenCodes.add(code.toLowerCase());
|
|
63
|
+
found.push({ code, yamlPath, source: 'built-in' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Bundled in src/modules/<code>/module.yaml (rare, but supported by getModulePath).
|
|
68
|
+
const srcModulesDir = path.join(getProjectRoot(), 'src', 'modules');
|
|
69
|
+
if (await fs.pathExists(srcModulesDir)) {
|
|
70
|
+
const entries = await fs.readdir(srcModulesDir, { withFileTypes: true });
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!entry.isDirectory()) continue;
|
|
73
|
+
const yamlPath = path.join(srcModulesDir, entry.name, 'module.yaml');
|
|
74
|
+
if (await fs.pathExists(yamlPath)) {
|
|
75
|
+
await addFound(yamlPath, 'bundled', entry.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// External cache (~/.bmad/cache/external-modules/<code>/...).
|
|
81
|
+
const cacheRoot = getExternalModuleCachePath('').replace(/\/$/, '');
|
|
82
|
+
if (await fs.pathExists(cacheRoot)) {
|
|
83
|
+
const rawEntries = await fs.readdir(cacheRoot, { withFileTypes: true });
|
|
84
|
+
for (const entry of rawEntries) {
|
|
85
|
+
if (!entry.isDirectory()) continue;
|
|
86
|
+
const candidates = [
|
|
87
|
+
path.join(cacheRoot, entry.name, 'module.yaml'),
|
|
88
|
+
path.join(cacheRoot, entry.name, 'src', 'module.yaml'),
|
|
89
|
+
path.join(cacheRoot, entry.name, 'skills', 'module.yaml'),
|
|
90
|
+
];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
if (await fs.pathExists(candidate)) {
|
|
93
|
+
await addFound(candidate, 'cached', entry.name);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatPromptText(item) {
|
|
104
|
+
if (Array.isArray(item.prompt)) return item.prompt.join(' ');
|
|
105
|
+
return String(item.prompt || '').trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function inferType(item) {
|
|
109
|
+
if (item['single-select']) return 'single-select';
|
|
110
|
+
if (item['multi-select']) return 'multi-select';
|
|
111
|
+
if (typeof item.default === 'boolean') return 'boolean';
|
|
112
|
+
if (typeof item.default === 'number') return 'number';
|
|
113
|
+
return 'string';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatModuleOptions(code, parsed, source) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
const header = source === 'built-in' ? code : `${code} (${source})`;
|
|
119
|
+
lines.push(header + ':');
|
|
120
|
+
|
|
121
|
+
let count = 0;
|
|
122
|
+
for (const [key, item] of Object.entries(parsed)) {
|
|
123
|
+
if (!item || typeof item !== 'object' || !('prompt' in item)) continue;
|
|
124
|
+
count++;
|
|
125
|
+
const type = inferType(item);
|
|
126
|
+
const scope = item.scope === 'user' ? ' [user-scope]' : '';
|
|
127
|
+
const defaultStr = item.default === undefined || item.default === null ? '(none)' : String(item.default);
|
|
128
|
+
lines.push(` ${code}.${key} (${type}${scope}) default: ${defaultStr}`);
|
|
129
|
+
const promptText = formatPromptText(item);
|
|
130
|
+
if (promptText) lines.push(` ${promptText}`);
|
|
131
|
+
if (Array.isArray(item['single-select'])) {
|
|
132
|
+
const values = item['single-select'].map((v) => (typeof v === 'object' ? v.value : v)).filter((v) => v !== undefined);
|
|
133
|
+
if (values.length > 0) lines.push(` values: ${values.join(' | ')}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (count === 0) {
|
|
139
|
+
lines.push(' (no configurable options)', '');
|
|
140
|
+
}
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render `--list-options` output.
|
|
146
|
+
*
|
|
147
|
+
* Returns `{ text, ok }` so callers can surface a non-zero exit code on
|
|
148
|
+
* a typo'd module-code lookup. Discovery dedupes case-insensitively, so
|
|
149
|
+
* the lookup is also case-insensitive — typing `--list-options BMM` and
|
|
150
|
+
* `--list-options bmm` both find the bmm built-in.
|
|
151
|
+
*
|
|
152
|
+
* @param {string|null} moduleCode - if non-null, restrict to this module
|
|
153
|
+
* @returns {Promise<{text: string, ok: boolean}>}
|
|
154
|
+
*/
|
|
155
|
+
async function formatOptionsList(moduleCode) {
|
|
156
|
+
const discovered = await discoverOfficialModuleYamls();
|
|
157
|
+
const needle = moduleCode ? moduleCode.toLowerCase() : null;
|
|
158
|
+
const filtered = needle ? discovered.filter((d) => d.code.toLowerCase() === needle) : discovered;
|
|
159
|
+
|
|
160
|
+
if (filtered.length === 0) {
|
|
161
|
+
if (moduleCode) {
|
|
162
|
+
const text = [
|
|
163
|
+
`No locally-known module.yaml for '${moduleCode}'.`,
|
|
164
|
+
'',
|
|
165
|
+
'Built-in modules (core, bmm) are always available. External officials',
|
|
166
|
+
'appear here after they have been installed at least once on this machine',
|
|
167
|
+
'(they are cached under ~/.bmad/cache/external-modules/).',
|
|
168
|
+
'',
|
|
169
|
+
'For community or custom modules, read the module.yaml file in that',
|
|
170
|
+
"module's source repository directly.",
|
|
171
|
+
].join('\n');
|
|
172
|
+
return { text, ok: false };
|
|
173
|
+
}
|
|
174
|
+
return { text: 'No modules found.', ok: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sections = [];
|
|
178
|
+
// Track when a module-scoped lookup couldn't actually be rendered (yaml
|
|
179
|
+
// unparseable or empty after parse). The full `--list-options` output is
|
|
180
|
+
// tolerant of one bad entry, but `--list-options <module>` against a single
|
|
181
|
+
// unreadable module should still fail tooling so a CI script catches it.
|
|
182
|
+
let moduleScopedFailure = false;
|
|
183
|
+
sections.push('Available --set keys', 'Format: --set <module>.<key>=<value> (repeatable)', '');
|
|
184
|
+
for (const { code, yamlPath, source } of filtered) {
|
|
185
|
+
let parsed;
|
|
186
|
+
try {
|
|
187
|
+
parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
|
188
|
+
} catch {
|
|
189
|
+
sections.push(`${code} (${source}): could not parse module.yaml`, '');
|
|
190
|
+
if (moduleCode) moduleScopedFailure = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
194
|
+
sections.push(`${code} (${source}): module.yaml is not a valid object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, '');
|
|
195
|
+
if (moduleCode) moduleScopedFailure = true;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
sections.push(formatModuleOptions(code, parsed, source));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!moduleCode) {
|
|
202
|
+
sections.push(
|
|
203
|
+
'Community and custom modules are not listed here — read their module.yaml directly. Unknown keys still persist with a warning.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { text: sections.join('\n'), ok: !moduleScopedFailure };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { formatOptionsList, discoverOfficialModuleYamls };
|
|
@@ -128,58 +128,102 @@ class CustomModuleManager {
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
// HTTPS/HTTP URL:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
// HTTPS/HTTP URL: generic handling for any Git host.
|
|
132
|
+
// We avoid host-specific parsing — `git clone` will accept whatever URL the
|
|
133
|
+
// user provides. We only need to (a) separate an optional browser-style
|
|
134
|
+
// subdir suffix from the clone URL, (b) extract any embedded ref
|
|
135
|
+
// (branch/tag) from deep-path URLs, and (c) derive a cache key / display
|
|
136
|
+
// name from the path. The original protocol (http or https) is preserved.
|
|
137
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
138
|
+
let url;
|
|
139
|
+
try {
|
|
140
|
+
url = new URL(trimmed);
|
|
141
|
+
} catch {
|
|
142
|
+
url = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (url && url.host) {
|
|
146
|
+
const host = url.host;
|
|
147
|
+
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
148
|
+
let subdir = null;
|
|
149
|
+
let urlRef = null; // branch/tag/commit extracted from deep-path URLs
|
|
150
|
+
|
|
151
|
+
// Detect browser-style deep-path patterns that embed a ref
|
|
152
|
+
// (branch/tag/commit) and optional subdirectory. These appear
|
|
153
|
+
// across many hosts:
|
|
154
|
+
// GitHub /<repo>/tree|blob/<ref>[/<subdir>]
|
|
155
|
+
// GitLab /<repo>/-/tree|blob/<ref>[/<subdir>]
|
|
156
|
+
// Gitea /<repo>/src/<ref>[/<subdir>]
|
|
157
|
+
// Gitea /<repo>/src/(branch|commit|tag)/<ref>[/<subdir>]
|
|
158
|
+
// Group 1 = repo path prefix, Group 2 = ref, Group 3 = subdir (optional).
|
|
141
159
|
const deepPathPatterns = [
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{ regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
|
|
160
|
+
/^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
|
|
161
|
+
/^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
|
|
145
162
|
];
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
for (const p of deepPathPatterns) {
|
|
150
|
-
const match = remainder.match(p.regex);
|
|
163
|
+
for (const pattern of deepPathPatterns) {
|
|
164
|
+
const match = repoPath.match(pattern);
|
|
151
165
|
if (match) {
|
|
152
|
-
|
|
153
|
-
|
|
166
|
+
repoPath = match[1];
|
|
167
|
+
if (match[2]) urlRef = match[2];
|
|
168
|
+
if (match[3]) {
|
|
169
|
+
const cleaned = match[3].replace(/\/+$/, '');
|
|
170
|
+
if (cleaned) subdir = cleaned;
|
|
171
|
+
}
|
|
154
172
|
break;
|
|
155
173
|
}
|
|
156
174
|
}
|
|
175
|
+
|
|
176
|
+
// Some hosts use ?path=/subdir on browse links to point at a file or
|
|
177
|
+
// directory. Honor it when no deep-path marker matched above.
|
|
157
178
|
if (!subdir) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
179
|
+
const pathParam = url.searchParams.get('path');
|
|
180
|
+
if (pathParam) {
|
|
181
|
+
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
182
|
+
if (cleaned) subdir = cleaned;
|
|
164
183
|
}
|
|
165
184
|
}
|
|
166
|
-
}
|
|
167
185
|
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
// Strip a single trailing .git for a stable cacheKey/displayName.
|
|
187
|
+
const repoPathClean = repoPath.replace(/\.git$/i, '');
|
|
188
|
+
if (!repoPathClean) {
|
|
189
|
+
return {
|
|
190
|
+
type: null,
|
|
191
|
+
cloneUrl: null,
|
|
192
|
+
subdir: null,
|
|
193
|
+
localPath: null,
|
|
194
|
+
cacheKey: null,
|
|
195
|
+
displayName: null,
|
|
196
|
+
isValid: false,
|
|
197
|
+
error: 'Not a valid Git URL or local path',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
170
200
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
displayName
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
201
|
+
const cloneUrl = `${url.protocol}//${host}/${repoPathClean}`;
|
|
202
|
+
const cacheKey = `${host}/${repoPathClean}`;
|
|
203
|
+
|
|
204
|
+
// Display name: prefer "<owner>/<repo>" using the last two meaningful
|
|
205
|
+
// path segments.
|
|
206
|
+
const segments = repoPathClean.split('/').filter(Boolean);
|
|
207
|
+
const repoSeg = segments.at(-1);
|
|
208
|
+
const ownerSeg = segments.at(-2);
|
|
209
|
+
const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg;
|
|
210
|
+
|
|
211
|
+
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
|
|
212
|
+
const version = versionSuffix || urlRef || null;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
type: 'url',
|
|
216
|
+
cloneUrl,
|
|
217
|
+
subdir,
|
|
218
|
+
localPath: null,
|
|
219
|
+
version,
|
|
220
|
+
rawInput: trimmedRaw,
|
|
221
|
+
cacheKey,
|
|
222
|
+
displayName,
|
|
223
|
+
isValid: true,
|
|
224
|
+
error: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
183
227
|
}
|
|
184
228
|
|
|
185
229
|
return {
|
|
@@ -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 };
|
package/tools/installer/ui.js
CHANGED
|
@@ -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
|
|
@@ -774,7 +804,7 @@ class UI {
|
|
|
774
804
|
skipPrompts: options.yes || false,
|
|
775
805
|
});
|
|
776
806
|
|
|
777
|
-
return configCollector.collectedConfig;
|
|
807
|
+
return { moduleConfigs: configCollector.collectedConfig, setOverrides };
|
|
778
808
|
}
|
|
779
809
|
|
|
780
810
|
/**
|