bmad-method 6.3.1-next.20 → 6.3.1-next.22

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.22",
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
 
@@ -1,9 +1,20 @@
1
1
  const path = require('node:path');
2
+ const https = require('node:https');
3
+ const { execFile } = require('node:child_process');
4
+ const { promisify } = require('node:util');
2
5
  const fs = require('../fs-native');
3
6
  const crypto = require('node:crypto');
4
7
  const { resolveModuleVersion } = require('../modules/version-resolver');
5
8
  const prompts = require('../prompts');
6
9
 
10
+ const execFileAsync = promisify(execFile);
11
+ const NPM_LOOKUP_TIMEOUT_MS = 10_000;
12
+ const NPM_PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
13
+
14
+ function isValidNpmPackageName(packageName) {
15
+ return typeof packageName === 'string' && NPM_PACKAGE_NAME_PATTERN.test(packageName);
16
+ }
17
+
7
18
  class Manifest {
8
19
  /**
9
20
  * Create a new manifest
@@ -180,7 +191,12 @@ class Manifest {
180
191
  npmPackage: options.npmPackage || null,
181
192
  repoUrl: options.repoUrl || null,
182
193
  };
194
+ if (options.channel) entry.channel = options.channel;
195
+ if (options.sha) entry.sha = options.sha;
183
196
  if (options.localPath) entry.localPath = options.localPath;
197
+ if (options.rawSource) entry.rawSource = options.rawSource;
198
+ if (options.registryApprovedTag) entry.registryApprovedTag = options.registryApprovedTag;
199
+ if (options.registryApprovedSha) entry.registryApprovedSha = options.registryApprovedSha;
184
200
  manifest.modules.push(entry);
185
201
  } else {
186
202
  // Module exists, update its version info
@@ -192,6 +208,11 @@ class Manifest {
192
208
  npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
193
209
  repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
194
210
  localPath: options.localPath === undefined ? existing.localPath : options.localPath,
211
+ channel: options.channel === undefined ? existing.channel : options.channel,
212
+ sha: options.sha === undefined ? existing.sha : options.sha,
213
+ rawSource: options.rawSource === undefined ? existing.rawSource : options.rawSource,
214
+ registryApprovedTag: options.registryApprovedTag === undefined ? existing.registryApprovedTag : options.registryApprovedTag,
215
+ registryApprovedSha: options.registryApprovedSha === undefined ? existing.registryApprovedSha : options.registryApprovedSha,
195
216
  lastUpdated: new Date().toISOString(),
196
217
  };
197
218
  }
@@ -275,12 +296,17 @@ class Manifest {
275
296
  const moduleInfo = await extMgr.getModuleByCode(moduleName);
276
297
 
277
298
  if (moduleInfo) {
299
+ const externalResolution = extMgr.getResolution(moduleName);
278
300
  const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
279
301
  return {
280
- version: versionInfo.version,
302
+ // Git tag recorded during install trumps the on-disk package.json
303
+ // version, so the manifest carries "v1.7.0" instead of "1.7.0".
304
+ version: externalResolution?.version || versionInfo.version,
281
305
  source: 'external',
282
306
  npmPackage: moduleInfo.npmPackage || null,
283
307
  repoUrl: moduleInfo.url || null,
308
+ channel: externalResolution?.channel || null,
309
+ sha: externalResolution?.sha || null,
284
310
  };
285
311
  }
286
312
 
@@ -289,15 +315,20 @@ class Manifest {
289
315
  const communityMgr = new CommunityModuleManager();
290
316
  const communityInfo = await communityMgr.getModuleByCode(moduleName);
291
317
  if (communityInfo) {
318
+ const communityResolution = communityMgr.getResolution(moduleName);
292
319
  const versionInfo = await resolveModuleVersion(moduleName, {
293
320
  moduleSourcePath,
294
321
  fallbackVersion: communityInfo.version,
295
322
  });
296
323
  return {
297
- version: versionInfo.version || communityInfo.version,
324
+ version: communityResolution?.version || versionInfo.version || communityInfo.version,
298
325
  source: 'community',
299
326
  npmPackage: communityInfo.npmPackage || null,
300
327
  repoUrl: communityInfo.url || null,
328
+ channel: communityResolution?.channel || null,
329
+ sha: communityResolution?.sha || null,
330
+ registryApprovedTag: communityResolution?.registryApprovedTag || null,
331
+ registryApprovedSha: communityResolution?.registryApprovedSha || null,
301
332
  };
302
333
  }
303
334
 
@@ -312,12 +343,17 @@ class Manifest {
312
343
  fallbackVersion: resolved?.version,
313
344
  marketplacePluginNames: resolved?.pluginName ? [resolved.pluginName] : [],
314
345
  });
346
+ const hasGitClone = !!resolved?.repoUrl;
315
347
  return {
316
- version: versionInfo.version,
348
+ // Prefer the git ref we actually cloned over the package.json version.
349
+ version: resolved?.cloneRef || (hasGitClone ? 'main' : versionInfo.version),
317
350
  source: 'custom',
318
351
  npmPackage: null,
319
352
  repoUrl: resolved?.repoUrl || null,
320
353
  localPath: resolved?.localPath || null,
354
+ channel: hasGitClone ? (resolved?.cloneRef ? 'pinned' : 'next') : null,
355
+ sha: resolved?.cloneSha || null,
356
+ rawSource: resolved?.rawInput || null,
321
357
  };
322
358
  }
323
359
 
@@ -337,35 +373,40 @@ class Manifest {
337
373
  * @returns {string|null} Latest version or null
338
374
  */
339
375
  async fetchNpmVersion(packageName) {
340
- try {
341
- const https = require('node:https');
342
- const { execSync } = require('node:child_process');
376
+ if (!isValidNpmPackageName(packageName)) {
377
+ return null;
378
+ }
343
379
 
380
+ try {
344
381
  // Try using npm view first (more reliable)
345
382
  try {
346
- const result = execSync(`npm view ${packageName} version`, {
383
+ const { stdout } = await execFileAsync('npm', ['view', packageName, 'version'], {
347
384
  encoding: 'utf8',
348
- stdio: 'pipe',
349
- timeout: 10_000,
385
+ timeout: NPM_LOOKUP_TIMEOUT_MS,
350
386
  });
351
- return result.trim();
387
+ return stdout.trim();
352
388
  } catch {
353
389
  // Fallback to npm registry API
354
- return new Promise((resolve, reject) => {
355
- https
356
- .get(`https://registry.npmjs.org/${packageName}`, (res) => {
357
- let data = '';
358
- res.on('data', (chunk) => (data += chunk));
359
- res.on('end', () => {
360
- try {
361
- const pkg = JSON.parse(data);
362
- resolve(pkg['dist-tags']?.latest || pkg.version || null);
363
- } catch {
364
- resolve(null);
365
- }
366
- });
367
- })
368
- .on('error', () => resolve(null));
390
+ return new Promise((resolve) => {
391
+ const request = https.get(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, (res) => {
392
+ let data = '';
393
+ res.on('data', (chunk) => (data += chunk));
394
+ res.on('end', () => {
395
+ try {
396
+ const pkg = JSON.parse(data);
397
+ resolve(pkg['dist-tags']?.latest || pkg.version || null);
398
+ } catch {
399
+ resolve(null);
400
+ }
401
+ });
402
+ });
403
+
404
+ request.setTimeout(NPM_LOOKUP_TIMEOUT_MS, () => {
405
+ request.destroy();
406
+ resolve(null);
407
+ });
408
+
409
+ request.on('error', () => resolve(null));
369
410
  });
370
411
  }
371
412
  } catch {
@@ -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
+ };