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 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.20",
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, { silent: true });
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
- const version = versionInfo.version || '';
619
- addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
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
- if (oldVersion && oldVersion === r.newVersion) {
1095
- detail = ` (v${r.newVersion}, no change)`;
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 = ` (v${oldVersion} → v${r.newVersion})`;
1133
+ detail = ` (${fmt(oldVersion, r.newSha)} → ${newV})`;
1098
1134
  } else {
1099
- detail = ` (v${r.newVersion}, installed)`;
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
- if (versionInfo.localPath) moduleEntry.localPath = versionInfo.localPath;
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
- version: versionInfo.version,
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
- version: versionInfo.version,
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
+ };