bmad-method 6.3.1-next.20 → 6.3.1-next.21
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/package.json +3 -2
- package/tools/installer/commands/install.js +13 -0
- package/tools/installer/core/config.js +4 -1
- package/tools/installer/core/installer.js +96 -9
- package/tools/installer/core/manifest-generator.js +16 -1
- package/tools/installer/core/manifest.js +28 -3
- package/tools/installer/modules/channel-plan.js +203 -0
- package/tools/installer/modules/channel-resolver.js +241 -0
- package/tools/installer/modules/community-manager.js +120 -18
- package/tools/installer/modules/custom-module-manager.js +160 -19
- package/tools/installer/modules/external-manager.js +231 -29
- package/tools/installer/modules/official-modules.js +56 -10
- package/tools/installer/modules/registry-fallback.yaml +8 -0
- package/tools/installer/ui.js +407 -3
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.3.1-next.
|
|
4
|
+
"version": "6.3.1-next.21",
|
|
5
5
|
"description": "Breakthrough Method of Agile AI-driven Development",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agile",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
|
42
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",
|
|
43
43
|
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
|
44
|
-
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
|
|
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",
|
|
45
|
+
"test:channels": "node test/test-installer-channels.js",
|
|
45
46
|
"test:install": "node test/test-installation-components.js",
|
|
46
47
|
"test:refs": "node test/test-file-refs-csv.js",
|
|
47
48
|
"validate:refs": "node tools/validate-file-refs.js --strict",
|
|
@@ -24,6 +24,19 @@ module.exports = {
|
|
|
24
24
|
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
|
|
25
25
|
['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'],
|
|
26
26
|
['-y, --yes', 'Accept all defaults and skip prompts where possible'],
|
|
27
|
+
[
|
|
28
|
+
'--channel <channel>',
|
|
29
|
+
'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.',
|
|
30
|
+
],
|
|
31
|
+
['--all-stable', 'Alias for --channel=stable. Resolves externals to the highest stable release tag.'],
|
|
32
|
+
['--all-next', 'Alias for --channel=next. Resolves externals to main HEAD.'],
|
|
33
|
+
['--next <code>', 'Install module <code> from main HEAD (next channel). Repeatable.', (value, prev) => [...(prev || []), value], []],
|
|
34
|
+
[
|
|
35
|
+
'--pin <spec>',
|
|
36
|
+
'Pin module to a specific tag: --pin CODE=TAG (e.g. --pin bmb=v1.7.0). Repeatable.',
|
|
37
|
+
(value, prev) => [...(prev || []), value],
|
|
38
|
+
[],
|
|
39
|
+
],
|
|
27
40
|
],
|
|
28
41
|
action: async (options) => {
|
|
29
42
|
try {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* User input comes from either UI answers or headless CLI flags.
|
|
4
4
|
*/
|
|
5
5
|
class Config {
|
|
6
|
-
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate }) {
|
|
6
|
+
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate, channelOptions }) {
|
|
7
7
|
this.directory = directory;
|
|
8
8
|
this.modules = Object.freeze([...modules]);
|
|
9
9
|
this.ides = Object.freeze([...ides]);
|
|
@@ -13,6 +13,8 @@ class Config {
|
|
|
13
13
|
this.coreConfig = coreConfig;
|
|
14
14
|
this.moduleConfigs = moduleConfigs;
|
|
15
15
|
this._quickUpdate = quickUpdate;
|
|
16
|
+
// channelOptions carry a Map + Set; don't deep-freeze.
|
|
17
|
+
this.channelOptions = channelOptions || null;
|
|
16
18
|
Object.freeze(this);
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -37,6 +39,7 @@ class Config {
|
|
|
37
39
|
coreConfig: userInput.coreConfig || {},
|
|
38
40
|
moduleConfigs: userInput.moduleConfigs || null,
|
|
39
41
|
quickUpdate: userInput._quickUpdate || false,
|
|
42
|
+
channelOptions: userInput.channelOptions || null,
|
|
40
43
|
});
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -601,22 +601,40 @@ class Installer {
|
|
|
601
601
|
moduleConfig: moduleConfig,
|
|
602
602
|
installer: this,
|
|
603
603
|
silent: true,
|
|
604
|
+
channelOptions: config.channelOptions,
|
|
604
605
|
},
|
|
605
606
|
);
|
|
606
607
|
|
|
607
608
|
// Get display name from source module.yaml and resolve the freshest version metadata we can find locally.
|
|
608
|
-
const sourcePath = await officialModules.findModuleSource(moduleName, {
|
|
609
|
+
const sourcePath = await officialModules.findModuleSource(moduleName, {
|
|
610
|
+
silent: true,
|
|
611
|
+
channelOptions: config.channelOptions,
|
|
612
|
+
});
|
|
609
613
|
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
|
|
610
614
|
const displayName = moduleInfo?.name || moduleName;
|
|
611
615
|
|
|
616
|
+
const externalResolution = officialModules.externalModuleManager.getResolution(moduleName);
|
|
617
|
+
let communityResolution = null;
|
|
618
|
+
if (!externalResolution) {
|
|
619
|
+
const { CommunityModuleManager } = require('../modules/community-manager');
|
|
620
|
+
communityResolution = new CommunityModuleManager().getResolution(moduleName);
|
|
621
|
+
}
|
|
622
|
+
const resolution = externalResolution || communityResolution;
|
|
612
623
|
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
|
|
613
624
|
const versionInfo = await resolveModuleVersion(moduleName, {
|
|
614
625
|
moduleSourcePath: sourcePath,
|
|
615
|
-
fallbackVersion: cachedResolution?.version,
|
|
626
|
+
fallbackVersion: resolution?.version || cachedResolution?.version,
|
|
616
627
|
marketplacePluginNames: cachedResolution?.pluginName ? [cachedResolution.pluginName] : [],
|
|
617
628
|
});
|
|
618
|
-
|
|
619
|
-
|
|
629
|
+
// Prefer the git tag recorded by the resolution (e.g. "v1.7.0") over
|
|
630
|
+
// the on-disk package.json (which may be ahead of the released tag).
|
|
631
|
+
const version = resolution?.version || versionInfo.version || '';
|
|
632
|
+
addResult(displayName, 'ok', '', {
|
|
633
|
+
moduleCode: moduleName,
|
|
634
|
+
newVersion: version,
|
|
635
|
+
newChannel: resolution?.channel || null,
|
|
636
|
+
newSha: resolution?.sha || null,
|
|
637
|
+
});
|
|
620
638
|
}
|
|
621
639
|
}
|
|
622
640
|
|
|
@@ -1091,12 +1109,30 @@ class Installer {
|
|
|
1091
1109
|
let detail = '';
|
|
1092
1110
|
if (r.moduleCode && r.newVersion) {
|
|
1093
1111
|
const oldVersion = preVersions.get(r.moduleCode);
|
|
1094
|
-
|
|
1095
|
-
|
|
1112
|
+
// Format a version label for display:
|
|
1113
|
+
// "main" → "main @ <short-sha>" (next channel shows what SHA landed)
|
|
1114
|
+
// "v1.7.0" or "1.7.0" → "v1.7.0" (prefix 'v' when missing)
|
|
1115
|
+
// anything else (legacy strings) → as-is
|
|
1116
|
+
const fmt = (v, sha) => {
|
|
1117
|
+
if (typeof v !== 'string' || !v) return '';
|
|
1118
|
+
if (v === 'main' || v === 'HEAD') return sha ? `main @ ${sha.slice(0, 7)}` : 'main';
|
|
1119
|
+
if (/^v?\d+\.\d+\.\d+/.test(v)) return v.startsWith('v') ? v : `v${v}`;
|
|
1120
|
+
return v;
|
|
1121
|
+
};
|
|
1122
|
+
const newV = fmt(r.newVersion, r.newSha);
|
|
1123
|
+
// 'main'/'HEAD' strings only identify the channel, not the commit, so
|
|
1124
|
+
// we can't assert "no change" without comparing SHAs — and preVersions
|
|
1125
|
+
// doesn't carry the old SHA. Render these as a refresh instead of a
|
|
1126
|
+
// false-negative "no change".
|
|
1127
|
+
const isMainLike = oldVersion === 'main' || oldVersion === 'HEAD';
|
|
1128
|
+
if (oldVersion && oldVersion === r.newVersion && !isMainLike) {
|
|
1129
|
+
detail = ` (${newV}, no change)`;
|
|
1130
|
+
} else if (oldVersion && isMainLike) {
|
|
1131
|
+
detail = ` (${newV}, refreshed)`;
|
|
1096
1132
|
} else if (oldVersion) {
|
|
1097
|
-
detail = ` (
|
|
1133
|
+
detail = ` (${fmt(oldVersion, r.newSha)} → ${newV})`;
|
|
1098
1134
|
} else {
|
|
1099
|
-
detail = ` (
|
|
1135
|
+
detail = ` (${newV}, installed)`;
|
|
1100
1136
|
}
|
|
1101
1137
|
} else if (r.detail) {
|
|
1102
1138
|
detail = ` (${r.detail})`;
|
|
@@ -1216,9 +1252,59 @@ class Installer {
|
|
|
1216
1252
|
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
|
|
1217
1253
|
}
|
|
1218
1254
|
|
|
1255
|
+
// Build channel options from the existing manifest FIRST so the config
|
|
1256
|
+
// collector below (which triggers external-module clones via
|
|
1257
|
+
// findModuleSource) knows each module's recorded channel and doesn't
|
|
1258
|
+
// silently redecide it. Without this, modules previously on 'next' or
|
|
1259
|
+
// 'pinned' would trigger a stable-channel tag lookup at config-collection
|
|
1260
|
+
// time, burning GitHub API quota and potentially failing.
|
|
1261
|
+
const manifestData = await this.manifest.read(bmadDir);
|
|
1262
|
+
const channelOptions = { global: null, nextSet: new Set(), pins: new Map(), warnings: [] };
|
|
1263
|
+
if (manifestData?.modulesDetailed) {
|
|
1264
|
+
const { fetchStableTags, classifyUpgrade, parseGitHubRepo } = require('../modules/channel-resolver');
|
|
1265
|
+
for (const entry of manifestData.modulesDetailed) {
|
|
1266
|
+
if (!entry?.name || !entry?.channel) continue;
|
|
1267
|
+
if (entry.channel === 'pinned' && entry.version) {
|
|
1268
|
+
channelOptions.pins.set(entry.name, entry.version);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (entry.channel === 'next') {
|
|
1272
|
+
channelOptions.nextSet.add(entry.name);
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
// Stable: classify the available upgrade. Patches and minors fall
|
|
1276
|
+
// through (stable default picks up the top tag). A major upgrade
|
|
1277
|
+
// requires opt-in, so under quick-update's non-interactive semantics
|
|
1278
|
+
// we pin to the current version to prevent a silent breaking jump.
|
|
1279
|
+
if (entry.channel === 'stable' && entry.version && entry.repoUrl) {
|
|
1280
|
+
const parsed = parseGitHubRepo(entry.repoUrl);
|
|
1281
|
+
if (!parsed) continue;
|
|
1282
|
+
try {
|
|
1283
|
+
const tags = await fetchStableTags(parsed.owner, parsed.repo);
|
|
1284
|
+
if (tags.length === 0) continue;
|
|
1285
|
+
const topTag = tags[0].tag;
|
|
1286
|
+
const cls = classifyUpgrade(entry.version, topTag);
|
|
1287
|
+
if (cls === 'major') {
|
|
1288
|
+
channelOptions.pins.set(entry.name, entry.version);
|
|
1289
|
+
await prompts.log.warn(
|
|
1290
|
+
`${entry.name} ${entry.version} → ${topTag} is a new major release; staying on ${entry.version}. ` +
|
|
1291
|
+
`Run \`bmad install\` (Modify) with \`--pin ${entry.name}=${topTag}\` to accept.`,
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
// Tag lookup failed (offline, rate-limited). Stay on the current
|
|
1296
|
+
// version rather than guessing — the existing cache is already
|
|
1297
|
+
// at that ref, so re-using it keeps the install stable.
|
|
1298
|
+
channelOptions.pins.set(entry.name, entry.version);
|
|
1299
|
+
await prompts.log.warn(`Could not check ${entry.name} for updates (${error.message}); staying on ${entry.version}.`);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1219
1305
|
// Load existing configs and collect new fields (if any)
|
|
1220
1306
|
await prompts.log.info('Checking for new configuration options...');
|
|
1221
|
-
const quickModules = new OfficialModules();
|
|
1307
|
+
const quickModules = new OfficialModules({ channelOptions });
|
|
1222
1308
|
await quickModules.loadExistingConfig(projectDir);
|
|
1223
1309
|
|
|
1224
1310
|
let promptedForNewFields = false;
|
|
@@ -1257,6 +1343,7 @@ class Installer {
|
|
|
1257
1343
|
_quickUpdate: true,
|
|
1258
1344
|
_preserveModules: skippedModules,
|
|
1259
1345
|
_existingModules: installedModules,
|
|
1346
|
+
channelOptions,
|
|
1260
1347
|
};
|
|
1261
1348
|
|
|
1262
1349
|
await this.install(installConfig);
|
|
@@ -349,7 +349,22 @@ class ManifestGenerator {
|
|
|
349
349
|
npmPackage: versionInfo.npmPackage,
|
|
350
350
|
repoUrl: versionInfo.repoUrl,
|
|
351
351
|
};
|
|
352
|
-
|
|
352
|
+
// Preserve channel/sha from the resolution (external/community/custom)
|
|
353
|
+
// or from the existing entry if this is a no-change rewrite.
|
|
354
|
+
const channel = versionInfo.channel ?? existing?.channel;
|
|
355
|
+
const sha = versionInfo.sha ?? existing?.sha;
|
|
356
|
+
if (channel) moduleEntry.channel = channel;
|
|
357
|
+
if (sha) moduleEntry.sha = sha;
|
|
358
|
+
if (versionInfo.localPath || existing?.localPath) {
|
|
359
|
+
moduleEntry.localPath = versionInfo.localPath || existing.localPath;
|
|
360
|
+
}
|
|
361
|
+
if (versionInfo.rawSource || existing?.rawSource) {
|
|
362
|
+
moduleEntry.rawSource = versionInfo.rawSource || existing.rawSource;
|
|
363
|
+
}
|
|
364
|
+
const regTag = versionInfo.registryApprovedTag ?? existing?.registryApprovedTag;
|
|
365
|
+
const regSha = versionInfo.registryApprovedSha ?? existing?.registryApprovedSha;
|
|
366
|
+
if (regTag) moduleEntry.registryApprovedTag = regTag;
|
|
367
|
+
if (regSha) moduleEntry.registryApprovedSha = regSha;
|
|
353
368
|
updatedModules.push(moduleEntry);
|
|
354
369
|
}
|
|
355
370
|
|
|
@@ -180,7 +180,12 @@ class Manifest {
|
|
|
180
180
|
npmPackage: options.npmPackage || null,
|
|
181
181
|
repoUrl: options.repoUrl || null,
|
|
182
182
|
};
|
|
183
|
+
if (options.channel) entry.channel = options.channel;
|
|
184
|
+
if (options.sha) entry.sha = options.sha;
|
|
183
185
|
if (options.localPath) entry.localPath = options.localPath;
|
|
186
|
+
if (options.rawSource) entry.rawSource = options.rawSource;
|
|
187
|
+
if (options.registryApprovedTag) entry.registryApprovedTag = options.registryApprovedTag;
|
|
188
|
+
if (options.registryApprovedSha) entry.registryApprovedSha = options.registryApprovedSha;
|
|
184
189
|
manifest.modules.push(entry);
|
|
185
190
|
} else {
|
|
186
191
|
// Module exists, update its version info
|
|
@@ -192,6 +197,11 @@ class Manifest {
|
|
|
192
197
|
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
|
|
193
198
|
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
|
|
194
199
|
localPath: options.localPath === undefined ? existing.localPath : options.localPath,
|
|
200
|
+
channel: options.channel === undefined ? existing.channel : options.channel,
|
|
201
|
+
sha: options.sha === undefined ? existing.sha : options.sha,
|
|
202
|
+
rawSource: options.rawSource === undefined ? existing.rawSource : options.rawSource,
|
|
203
|
+
registryApprovedTag: options.registryApprovedTag === undefined ? existing.registryApprovedTag : options.registryApprovedTag,
|
|
204
|
+
registryApprovedSha: options.registryApprovedSha === undefined ? existing.registryApprovedSha : options.registryApprovedSha,
|
|
195
205
|
lastUpdated: new Date().toISOString(),
|
|
196
206
|
};
|
|
197
207
|
}
|
|
@@ -275,12 +285,17 @@ class Manifest {
|
|
|
275
285
|
const moduleInfo = await extMgr.getModuleByCode(moduleName);
|
|
276
286
|
|
|
277
287
|
if (moduleInfo) {
|
|
288
|
+
const externalResolution = extMgr.getResolution(moduleName);
|
|
278
289
|
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
|
|
279
290
|
return {
|
|
280
|
-
|
|
291
|
+
// Git tag recorded during install trumps the on-disk package.json
|
|
292
|
+
// version, so the manifest carries "v1.7.0" instead of "1.7.0".
|
|
293
|
+
version: externalResolution?.version || versionInfo.version,
|
|
281
294
|
source: 'external',
|
|
282
295
|
npmPackage: moduleInfo.npmPackage || null,
|
|
283
296
|
repoUrl: moduleInfo.url || null,
|
|
297
|
+
channel: externalResolution?.channel || null,
|
|
298
|
+
sha: externalResolution?.sha || null,
|
|
284
299
|
};
|
|
285
300
|
}
|
|
286
301
|
|
|
@@ -289,15 +304,20 @@ class Manifest {
|
|
|
289
304
|
const communityMgr = new CommunityModuleManager();
|
|
290
305
|
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
|
291
306
|
if (communityInfo) {
|
|
307
|
+
const communityResolution = communityMgr.getResolution(moduleName);
|
|
292
308
|
const versionInfo = await resolveModuleVersion(moduleName, {
|
|
293
309
|
moduleSourcePath,
|
|
294
310
|
fallbackVersion: communityInfo.version,
|
|
295
311
|
});
|
|
296
312
|
return {
|
|
297
|
-
version: versionInfo.version || communityInfo.version,
|
|
313
|
+
version: communityResolution?.version || versionInfo.version || communityInfo.version,
|
|
298
314
|
source: 'community',
|
|
299
315
|
npmPackage: communityInfo.npmPackage || null,
|
|
300
316
|
repoUrl: communityInfo.url || null,
|
|
317
|
+
channel: communityResolution?.channel || null,
|
|
318
|
+
sha: communityResolution?.sha || null,
|
|
319
|
+
registryApprovedTag: communityResolution?.registryApprovedTag || null,
|
|
320
|
+
registryApprovedSha: communityResolution?.registryApprovedSha || null,
|
|
301
321
|
};
|
|
302
322
|
}
|
|
303
323
|
|
|
@@ -312,12 +332,17 @@ class Manifest {
|
|
|
312
332
|
fallbackVersion: resolved?.version,
|
|
313
333
|
marketplacePluginNames: resolved?.pluginName ? [resolved.pluginName] : [],
|
|
314
334
|
});
|
|
335
|
+
const hasGitClone = !!resolved?.repoUrl;
|
|
315
336
|
return {
|
|
316
|
-
|
|
337
|
+
// Prefer the git ref we actually cloned over the package.json version.
|
|
338
|
+
version: resolved?.cloneRef || (hasGitClone ? 'main' : versionInfo.version),
|
|
317
339
|
source: 'custom',
|
|
318
340
|
npmPackage: null,
|
|
319
341
|
repoUrl: resolved?.repoUrl || null,
|
|
320
342
|
localPath: resolved?.localPath || null,
|
|
343
|
+
channel: hasGitClone ? (resolved?.cloneRef ? 'pinned' : 'next') : null,
|
|
344
|
+
sha: resolved?.cloneSha || null,
|
|
345
|
+
rawSource: resolved?.rawInput || null,
|
|
321
346
|
};
|
|
322
347
|
}
|
|
323
348
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel plan: the per-module resolution decision applied at install time.
|
|
3
|
+
*
|
|
4
|
+
* A "plan entry" for a module is:
|
|
5
|
+
* { channel: 'stable'|'next'|'pinned', pin?: string }
|
|
6
|
+
*
|
|
7
|
+
* We build the plan from:
|
|
8
|
+
* 1. CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG)
|
|
9
|
+
* 2. Interactive answers (the "all stable?" gate + per-module picker)
|
|
10
|
+
* 3. Registry defaults (default_channel from registry-fallback.yaml / official.yaml)
|
|
11
|
+
* 4. Hardcoded fallback 'stable'
|
|
12
|
+
*
|
|
13
|
+
* Precedence: --pin > --next=CODE > --channel (global) > registry default > 'stable'.
|
|
14
|
+
*
|
|
15
|
+
* This module is pure. No prompts, no git, no filesystem.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const VALID_CHANNELS = new Set(['stable', 'next']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse raw commander options into a structured channel options object.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} options - raw command-line options
|
|
24
|
+
* @returns {{
|
|
25
|
+
* global: 'stable'|'next'|null,
|
|
26
|
+
* nextSet: Set<string>,
|
|
27
|
+
* pins: Map<string, string>,
|
|
28
|
+
* warnings: string[]
|
|
29
|
+
* }}
|
|
30
|
+
*/
|
|
31
|
+
function parseChannelOptions(options = {}) {
|
|
32
|
+
const warnings = [];
|
|
33
|
+
|
|
34
|
+
// Global channel from --channel / --all-stable / --all-next.
|
|
35
|
+
let global = null;
|
|
36
|
+
const aliases = [];
|
|
37
|
+
if (options.channel) aliases.push({ flag: '--channel', value: normalizeChannel(options.channel, warnings, '--channel') });
|
|
38
|
+
if (options.allStable) aliases.push({ flag: '--all-stable', value: 'stable' });
|
|
39
|
+
if (options.allNext) aliases.push({ flag: '--all-next', value: 'next' });
|
|
40
|
+
|
|
41
|
+
const distinct = new Set(aliases.map((a) => a.value).filter(Boolean));
|
|
42
|
+
if (distinct.size > 1) {
|
|
43
|
+
warnings.push(
|
|
44
|
+
`Conflicting channel flags: ${aliases
|
|
45
|
+
.filter((a) => a.value)
|
|
46
|
+
.map((a) => a.flag + '=' + a.value)
|
|
47
|
+
.join(', ')}. Using first: ${aliases.find((a) => a.value).flag}.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const firstValid = aliases.find((a) => a.value);
|
|
51
|
+
if (firstValid) global = firstValid.value;
|
|
52
|
+
|
|
53
|
+
// --next=CODE (repeatable)
|
|
54
|
+
const nextSet = new Set();
|
|
55
|
+
for (const code of options.next || []) {
|
|
56
|
+
const trimmed = String(code).trim();
|
|
57
|
+
if (!trimmed) continue;
|
|
58
|
+
nextSet.add(trimmed);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --pin CODE=TAG (repeatable)
|
|
62
|
+
const pins = new Map();
|
|
63
|
+
for (const spec of options.pin || []) {
|
|
64
|
+
const parsed = parsePinSpec(spec);
|
|
65
|
+
if (!parsed) {
|
|
66
|
+
warnings.push(`Ignoring malformed --pin value '${spec}'. Expected CODE=TAG.`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (pins.has(parsed.code)) {
|
|
70
|
+
warnings.push(`--pin specified multiple times for '${parsed.code}'. Using last: ${parsed.tag}.`);
|
|
71
|
+
}
|
|
72
|
+
pins.set(parsed.code, parsed.tag);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --yes auto-confirms the community-module curator-bypass prompt so
|
|
76
|
+
// headless installs with --next=/--pin for a community module don't hang.
|
|
77
|
+
const acceptBypass = options.yes === true || options.acceptBypass === true;
|
|
78
|
+
|
|
79
|
+
return { global, nextSet, pins, warnings, acceptBypass };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeChannel(raw, warnings, flagName) {
|
|
83
|
+
if (typeof raw !== 'string') return null;
|
|
84
|
+
const lower = raw.trim().toLowerCase();
|
|
85
|
+
if (VALID_CHANNELS.has(lower)) return lower;
|
|
86
|
+
warnings.push(`Ignoring invalid ${flagName} value '${raw}'. Expected one of: stable, next.`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parsePinSpec(spec) {
|
|
91
|
+
if (typeof spec !== 'string') return null;
|
|
92
|
+
const idx = spec.indexOf('=');
|
|
93
|
+
if (idx <= 0 || idx === spec.length - 1) return null;
|
|
94
|
+
const code = spec.slice(0, idx).trim();
|
|
95
|
+
const tag = spec.slice(idx + 1).trim();
|
|
96
|
+
if (!code || !tag) return null;
|
|
97
|
+
return { code, tag };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a per-module plan entry, applying precedence.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} args
|
|
104
|
+
* @param {string} args.code
|
|
105
|
+
* @param {Object} args.channelOptions - from parseChannelOptions
|
|
106
|
+
* @param {string} [args.registryDefault] - module's default_channel, if any
|
|
107
|
+
* @returns {{channel: 'stable'|'next'|'pinned', pin?: string, source: string}}
|
|
108
|
+
* source describes where the decision came from, for logging / debugging.
|
|
109
|
+
*/
|
|
110
|
+
function decideChannelForModule({ code, channelOptions, registryDefault }) {
|
|
111
|
+
const { global, nextSet, pins } = channelOptions || { nextSet: new Set(), pins: new Map() };
|
|
112
|
+
|
|
113
|
+
if (pins && pins.has(code)) {
|
|
114
|
+
return { channel: 'pinned', pin: pins.get(code), source: 'flag:--pin' };
|
|
115
|
+
}
|
|
116
|
+
if (nextSet && nextSet.has(code)) {
|
|
117
|
+
return { channel: 'next', source: 'flag:--next' };
|
|
118
|
+
}
|
|
119
|
+
if (global) {
|
|
120
|
+
return { channel: global, source: 'flag:--channel' };
|
|
121
|
+
}
|
|
122
|
+
if (registryDefault && VALID_CHANNELS.has(registryDefault)) {
|
|
123
|
+
return { channel: registryDefault, source: 'registry' };
|
|
124
|
+
}
|
|
125
|
+
return { channel: 'stable', source: 'default' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a full channel plan map for a set of modules.
|
|
130
|
+
*
|
|
131
|
+
* @param {Object} args
|
|
132
|
+
* @param {Array<{code: string, defaultChannel?: string, builtIn?: boolean}>} args.modules
|
|
133
|
+
* Only the modules that need a channel entry; callers should filter out
|
|
134
|
+
* bundled modules (core/bmm) before calling.
|
|
135
|
+
* @param {Object} args.channelOptions - from parseChannelOptions
|
|
136
|
+
* @returns {Map<string, {channel: string, pin?: string, source: string}>}
|
|
137
|
+
*/
|
|
138
|
+
function buildPlan({ modules, channelOptions }) {
|
|
139
|
+
const plan = new Map();
|
|
140
|
+
for (const mod of modules || []) {
|
|
141
|
+
plan.set(
|
|
142
|
+
mod.code,
|
|
143
|
+
decideChannelForModule({
|
|
144
|
+
code: mod.code,
|
|
145
|
+
channelOptions,
|
|
146
|
+
registryDefault: mod.defaultChannel,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return plan;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Report any --pin CODE=TAG entries that don't correspond to a selected module.
|
|
155
|
+
* These get warned about but don't abort the install.
|
|
156
|
+
*/
|
|
157
|
+
function orphanPinWarnings(channelOptions, selectedCodes) {
|
|
158
|
+
const warnings = [];
|
|
159
|
+
const selected = new Set(selectedCodes || []);
|
|
160
|
+
for (const code of channelOptions?.pins?.keys() || []) {
|
|
161
|
+
if (!selected.has(code)) {
|
|
162
|
+
warnings.push(`--pin for '${code}' has no effect (module not selected).`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const code of channelOptions?.nextSet || []) {
|
|
166
|
+
if (!selected.has(code)) {
|
|
167
|
+
warnings.push(`--next for '${code}' has no effect (module not selected).`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return warnings;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Warn when --pin / --next targets a bundled module (core, bmm). Those are
|
|
175
|
+
* shipped inside the installer binary — there's no git clone to override, so
|
|
176
|
+
* the flag has no effect. Users who actually want a prerelease core/bmm
|
|
177
|
+
* should use `npx bmad-method@next install`.
|
|
178
|
+
*/
|
|
179
|
+
function bundledTargetWarnings(channelOptions, bundledCodes) {
|
|
180
|
+
const warnings = [];
|
|
181
|
+
const bundled = new Set(bundledCodes || []);
|
|
182
|
+
const hint = '(bundled module; use `npx bmad-method@next install` for a prerelease)';
|
|
183
|
+
for (const code of channelOptions?.pins?.keys() || []) {
|
|
184
|
+
if (bundled.has(code)) {
|
|
185
|
+
warnings.push(`--pin for '${code}' has no effect ${hint}.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const code of channelOptions?.nextSet || []) {
|
|
189
|
+
if (bundled.has(code)) {
|
|
190
|
+
warnings.push(`--next for '${code}' has no effect ${hint}.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return warnings;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
parseChannelOptions,
|
|
198
|
+
decideChannelForModule,
|
|
199
|
+
buildPlan,
|
|
200
|
+
orphanPinWarnings,
|
|
201
|
+
bundledTargetWarnings,
|
|
202
|
+
parsePinSpec,
|
|
203
|
+
};
|