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.
@@ -15,6 +15,11 @@ class OfficialModules {
15
15
  // Tracked during interactive config collection so {directory_name}
16
16
  // placeholder defaults can be resolved in buildQuestion().
17
17
  this.currentProjectDir = null;
18
+ // Install-time channel flag state. Set by Config.build once, then used as
19
+ // the default for every findModuleSource/cloneExternalModule call so that
20
+ // pre-install config collection and the install step agree on which ref
21
+ // to clone.
22
+ this.channelOptions = options.channelOptions || null;
18
23
  }
19
24
 
20
25
  /**
@@ -38,7 +43,7 @@ class OfficialModules {
38
43
  * @returns {OfficialModules}
39
44
  */
40
45
  static async build(config, paths) {
41
- const instance = new OfficialModules();
46
+ const instance = new OfficialModules({ channelOptions: config.channelOptions });
42
47
 
43
48
  // Pre-collected by UI or quickUpdate — store and load existing for path-change detection
44
49
  if (config.moduleConfigs) {
@@ -196,6 +201,12 @@ class OfficialModules {
196
201
  * @returns {string|null} Path to the module source or null if not found
197
202
  */
198
203
  async findModuleSource(moduleCode, options = {}) {
204
+ // Inherit channelOptions from the install-scoped instance when the caller
205
+ // didn't pass one explicitly. Keeps pre-install config collection and the
206
+ // actual install step looking at the same git ref.
207
+ if (options.channelOptions === undefined && this.channelOptions) {
208
+ options = { ...options, channelOptions: this.channelOptions };
209
+ }
199
210
  const projectRoot = getProjectRoot();
200
211
 
201
212
  // Check for core module (directly under src/core-skills)
@@ -214,13 +225,13 @@ class OfficialModules {
214
225
  }
215
226
  }
216
227
 
217
- // Check external official modules
228
+ // Check external official modules (pass channelOptions so channel plan applies)
218
229
  const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
219
230
  if (externalSource) {
220
231
  return externalSource;
221
232
  }
222
233
 
223
- // Check community modules
234
+ // Check community modules (pass channelOptions for --next/--pin overrides)
224
235
  const { CommunityModuleManager } = require('./community-manager');
225
236
  const communityMgr = new CommunityModuleManager();
226
237
  const communitySource = await communityMgr.findModuleSource(moduleCode, options);
@@ -258,7 +269,10 @@ class OfficialModules {
258
269
  return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
259
270
  }
260
271
 
261
- const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
272
+ const sourcePath = await this.findModuleSource(moduleName, {
273
+ silent: options.silent,
274
+ channelOptions: options.channelOptions,
275
+ });
262
276
  const targetPath = path.join(bmadDir, moduleName);
263
277
 
264
278
  if (!sourcePath) {
@@ -281,11 +295,24 @@ class OfficialModules {
281
295
  const manifestObj = new Manifest();
282
296
  const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
283
297
 
298
+ // Pick up channel resolution recorded by whichever manager did the clone.
299
+ const externalResolution = this.externalModuleManager.getResolution(moduleName);
300
+ let communityResolution = null;
301
+ if (!externalResolution) {
302
+ const { CommunityModuleManager } = require('./community-manager');
303
+ communityResolution = new CommunityModuleManager().getResolution(moduleName);
304
+ }
305
+ const resolution = externalResolution || communityResolution;
306
+
284
307
  await manifestObj.addModule(bmadDir, moduleName, {
285
- version: versionInfo.version,
308
+ version: resolution?.version || versionInfo.version,
286
309
  source: versionInfo.source,
287
310
  npmPackage: versionInfo.npmPackage,
288
311
  repoUrl: versionInfo.repoUrl,
312
+ channel: resolution?.channel,
313
+ sha: resolution?.sha,
314
+ registryApprovedTag: communityResolution?.registryApprovedTag,
315
+ registryApprovedSha: communityResolution?.registryApprovedSha,
289
316
  });
290
317
 
291
318
  return { success: true, module: moduleName, path: targetPath, versionInfo };
@@ -333,18 +360,37 @@ class OfficialModules {
333
360
  await this.createModuleDirectories(resolved.code, bmadDir, options);
334
361
  }
335
362
 
336
- // Update manifest
363
+ // Update manifest. For custom modules, derive channel from the git ref:
364
+ // cloneRef present → pinned at that ref
365
+ // cloneRef absent → next (main HEAD)
366
+ // local path → no channel concept
337
367
  const { Manifest } = require('../core/manifest');
338
368
  const manifestObj = new Manifest();
339
369
 
340
- await manifestObj.addModule(bmadDir, resolved.code, {
341
- version: resolved.version || null,
370
+ const hasGitClone = !!resolved.repoUrl;
371
+ const manifestEntry = {
372
+ version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
342
373
  source: 'custom',
343
374
  npmPackage: null,
344
375
  repoUrl: resolved.repoUrl || null,
345
- });
376
+ };
377
+ if (hasGitClone) {
378
+ manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
379
+ if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
380
+ if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
381
+ }
382
+ if (resolved.localPath) manifestEntry.localPath = resolved.localPath;
383
+ await manifestObj.addModule(bmadDir, resolved.code, manifestEntry);
346
384
 
347
- return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } };
385
+ return {
386
+ success: true,
387
+ module: resolved.code,
388
+ path: targetPath,
389
+ // Match the manifestEntry.version expression above so downstream summary
390
+ // lines show the cloned ref (tag or 'main') instead of the on-disk
391
+ // package.json version for git-backed custom installs.
392
+ versionInfo: { version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || '') },
393
+ };
348
394
  }
349
395
 
350
396
  /**
@@ -1,6 +1,10 @@
1
1
  # Fallback module registry — used only when the BMad Marketplace repo
2
2
  # (bmad-code-org/bmad-plugins-marketplace) is unreachable.
3
3
  # The remote registry/official.yaml is the source of truth.
4
+ #
5
+ # default_channel (optional) — the install channel when the user does not
6
+ # override with --channel/--pin/--next. Valid values: stable | next.
7
+ # Omit to inherit the installer's hardcoded default (stable).
4
8
 
5
9
  modules:
6
10
  bmad-builder:
@@ -12,6 +16,7 @@ modules:
12
16
  defaultSelected: false
13
17
  type: bmad-org
14
18
  npmPackage: bmad-builder
19
+ default_channel: stable
15
20
 
16
21
  bmad-creative-intelligence-suite:
17
22
  url: https://github.com/bmad-code-org/bmad-module-creative-intelligence-suite
@@ -22,6 +27,7 @@ modules:
22
27
  defaultSelected: false
23
28
  type: bmad-org
24
29
  npmPackage: bmad-creative-intelligence-suite
30
+ default_channel: stable
25
31
 
26
32
  bmad-game-dev-studio:
27
33
  url: https://github.com/bmad-code-org/bmad-module-game-dev-studio.git
@@ -32,6 +38,7 @@ modules:
32
38
  defaultSelected: false
33
39
  type: bmad-org
34
40
  npmPackage: bmad-game-dev-studio
41
+ default_channel: stable
35
42
 
36
43
  bmad-method-test-architecture-enterprise:
37
44
  url: https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise
@@ -42,3 +49,4 @@ modules:
42
49
  defaultSelected: false
43
50
  type: bmad-org
44
51
  npmPackage: bmad-method-test-architecture-enterprise
52
+ default_channel: stable
@@ -4,6 +4,7 @@ const fs = require('./fs-native');
4
4
  const { CLIUtils } = require('./cli-utils');
5
5
  const { ExternalModuleManager } = require('./modules/external-manager');
6
6
  const { resolveModuleVersion } = require('./modules/version-resolver');
7
+ const { parseChannelOptions, buildPlan, orphanPinWarnings, bundledTargetWarnings } = require('./modules/channel-plan');
7
8
  const prompts = require('./prompts');
8
9
 
9
10
  /**
@@ -33,6 +34,13 @@ class UI {
33
34
  const messageLoader = new MessageLoader();
34
35
  await messageLoader.displayStartMessage();
35
36
 
37
+ // Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
38
+ // are surfaced immediately so the user sees them before any git ops run.
39
+ const channelOptions = parseChannelOptions(options);
40
+ for (const warning of channelOptions.warnings) {
41
+ await prompts.log.warn(warning);
42
+ }
43
+
36
44
  // Get directory from options or prompt
37
45
  let confirmedDirectory;
38
46
  if (options.directory) {
@@ -152,10 +160,38 @@ class UI {
152
160
  selectedModules.unshift('core');
153
161
  }
154
162
 
163
+ // For existing installs, resolve per-module update decisions BEFORE
164
+ // we clone anything. Reads the existing manifest's recorded channel
165
+ // per module and prompts the user on available upgrades (patch/minor
166
+ // default Y, major default N). Legacy entries with no channel are
167
+ // migrated here too. Mutates channelOptions.pins to lock rejections.
168
+ await this._resolveUpdateChannels({
169
+ bmadDir,
170
+ selectedModules,
171
+ channelOptions,
172
+ yes: options.yes || false,
173
+ });
174
+
155
175
  // Get tool selection
156
176
  const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
157
177
 
158
- const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
178
+ const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
179
+ ...options,
180
+ channelOptions,
181
+ });
182
+
183
+ // Warn about --pin/--next flags that refer to modules the user didn't
184
+ // select, or that target bundled modules (core/bmm) where channel
185
+ // flags don't apply.
186
+ {
187
+ const bundledCodes = await this._bundledModuleCodes();
188
+ for (const warning of [
189
+ ...orphanPinWarnings(channelOptions, selectedModules),
190
+ ...bundledTargetWarnings(channelOptions, bundledCodes),
191
+ ]) {
192
+ await prompts.log.warn(warning);
193
+ }
194
+ }
159
195
 
160
196
  return {
161
197
  actionType: 'update',
@@ -166,6 +202,7 @@ class UI {
166
202
  coreConfig: moduleConfigs.core || {},
167
203
  moduleConfigs: moduleConfigs,
168
204
  skipPrompts: options.yes || false,
205
+ channelOptions,
169
206
  };
170
207
  }
171
208
  }
@@ -205,8 +242,31 @@ class UI {
205
242
  if (!selectedModules.includes('core')) {
206
243
  selectedModules.unshift('core');
207
244
  }
245
+
246
+ // Interactive channel gate: "Ready to install (all stable)? [Y/n]"
247
+ // Only shown for fresh installs with no channel flags and an external module
248
+ // selected. Non-interactive installs skip this and fall through to the
249
+ // registry default (stable) or whatever flags were supplied.
250
+ await this._interactiveChannelGate({ options, channelOptions, selectedModules });
251
+
208
252
  let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
209
- const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
253
+ const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
254
+ ...options,
255
+ channelOptions,
256
+ });
257
+
258
+ // Warn about --pin/--next flags that refer to modules the user didn't
259
+ // select, or that target bundled modules (core/bmm) where channel
260
+ // flags don't apply.
261
+ {
262
+ const bundledCodes = await this._bundledModuleCodes();
263
+ for (const warning of [
264
+ ...orphanPinWarnings(channelOptions, selectedModules),
265
+ ...bundledTargetWarnings(channelOptions, bundledCodes),
266
+ ]) {
267
+ await prompts.log.warn(warning);
268
+ }
269
+ }
210
270
 
211
271
  return {
212
272
  actionType: 'install',
@@ -217,6 +277,7 @@ class UI {
217
277
  coreConfig: moduleConfigs.core || {},
218
278
  moduleConfigs: moduleConfigs,
219
279
  skipPrompts: options.yes || false,
280
+ channelOptions,
220
281
  };
221
282
  }
222
283
 
@@ -488,7 +549,7 @@ class UI {
488
549
  */
489
550
  async collectModuleConfigs(directory, modules, options = {}) {
490
551
  const { OfficialModules } = require('./modules/official-modules');
491
- const configCollector = new OfficialModules();
552
+ const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
492
553
 
493
554
  // Seed core config from CLI options if provided
494
555
  if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
@@ -1563,6 +1624,349 @@ class UI {
1563
1624
  });
1564
1625
  await prompts.log.message('Selected tools:\n' + toolLines.join('\n'));
1565
1626
  }
1627
+
1628
+ /**
1629
+ * Return the set of module codes the registry marks as built-in (core, bmm).
1630
+ * These ship with the installer binary and have no per-module channel.
1631
+ */
1632
+ async _bundledModuleCodes() {
1633
+ const externalManager = new ExternalModuleManager();
1634
+ try {
1635
+ const modules = await externalManager.listAvailable();
1636
+ return modules.filter((m) => m.builtIn).map((m) => m.code);
1637
+ } catch {
1638
+ // Registry unreachable — fall back to the known bundled codes.
1639
+ return ['core', 'bmm'];
1640
+ }
1641
+ }
1642
+
1643
+ /**
1644
+ * Fast-path channel gate: confirm "all stable" or open the per-module picker.
1645
+ *
1646
+ * Skipped when:
1647
+ * - running non-interactively (--yes)
1648
+ * - the user already passed channel flags (--channel / --pin / --next)
1649
+ * - no externals/community modules are selected
1650
+ *
1651
+ * Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.
1652
+ */
1653
+ async _interactiveChannelGate({ options, channelOptions, selectedModules }) {
1654
+ if (options.yes) return;
1655
+ // If the user already declared their channel intent via flags, trust them
1656
+ // and skip the gate.
1657
+ const haveFlagIntent = channelOptions.global || channelOptions.nextSet.size > 0 || channelOptions.pins.size > 0;
1658
+ if (haveFlagIntent) return;
1659
+
1660
+ // Figure out which selected modules actually get a channel (externals +
1661
+ // community modules). Bundled core/bmm and custom modules skip the picker.
1662
+ const externalManager = new ExternalModuleManager();
1663
+ const externals = await externalManager.listAvailable();
1664
+ const externalByCode = new Map(externals.map((m) => [m.code, m]));
1665
+
1666
+ const { CommunityModuleManager } = require('./modules/community-manager');
1667
+ const communityMgr = new CommunityModuleManager();
1668
+ const community = await communityMgr.listAll();
1669
+ const communityByCode = new Map(community.map((m) => [m.code, m]));
1670
+
1671
+ const channelSelectable = selectedModules.filter((code) => {
1672
+ const info = externalByCode.get(code) || communityByCode.get(code);
1673
+ return info && !info.builtIn;
1674
+ });
1675
+ if (channelSelectable.length === 0) return;
1676
+
1677
+ const fastPath = await prompts.confirm({
1678
+ message: `Ready to install (all stable)? Pick "n" to customize channels or pin versions.`,
1679
+ default: true,
1680
+ });
1681
+ if (fastPath) return; // stable for all, registry default applies
1682
+
1683
+ // Customize path: per-module picker.
1684
+ const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver');
1685
+
1686
+ for (const code of channelSelectable) {
1687
+ const info = externalByCode.get(code) || communityByCode.get(code);
1688
+ const repoUrl = info.url;
1689
+
1690
+ // Try to pre-resolve the top stable tag so we can surface it in the picker.
1691
+ let stableLabel = 'stable (released version)';
1692
+ try {
1693
+ const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
1694
+ if (parsed) {
1695
+ const tags = await fetchStableTags(parsed.owner, parsed.repo);
1696
+ if (tags.length > 0) {
1697
+ stableLabel = `stable ${tags[0].tag} (released version)`;
1698
+ }
1699
+ }
1700
+ } catch {
1701
+ // fall through with the generic label
1702
+ }
1703
+
1704
+ const choice = await prompts.select({
1705
+ message: `${code}: choose a channel`,
1706
+ choices: [
1707
+ { name: stableLabel, value: 'stable' },
1708
+ { name: 'next (main HEAD \u2014 current development)', value: 'next' },
1709
+ { name: 'pin (specific version)', value: 'pin' },
1710
+ ],
1711
+ default: 'stable',
1712
+ });
1713
+
1714
+ if (choice === 'next') {
1715
+ channelOptions.nextSet.add(code);
1716
+ } else if (choice === 'pin') {
1717
+ const pinValue = await prompts.text({
1718
+ message: `Enter a version tag for '${code}' (e.g. v1.6.0):`,
1719
+ validate: (value) => {
1720
+ if (!value || !/^[\w.\-+/]+$/.test(String(value).trim())) {
1721
+ return 'Must be a non-empty tag name (letters, digits, dots, hyphens).';
1722
+ }
1723
+ },
1724
+ });
1725
+ channelOptions.pins.set(code, String(pinValue).trim());
1726
+ }
1727
+ // 'stable' is the default; nothing to record.
1728
+ }
1729
+ }
1730
+
1731
+ /**
1732
+ * Resolve channel decisions for an update over an existing install.
1733
+ *
1734
+ * For each selected external/community module:
1735
+ * - Read the recorded channel from the existing manifest.
1736
+ * - On `stable`: query tags; if a newer stable exists, classify the diff
1737
+ * and prompt. Patch/minor default Y; major defaults N. `--yes` accepts
1738
+ * defaults (patches/minors) but NOT majors — a major under --yes stays
1739
+ * frozen unless the user also passes `--pin CODE=NEW_TAG`.
1740
+ * - On `next`: no prompt (pull HEAD).
1741
+ * - On `pinned`: no prompt (stays pinned).
1742
+ * - No channel recorded and `version: null`: one-time migration prompt
1743
+ * ("Switch to stable / Keep on next").
1744
+ *
1745
+ * Decisions that freeze the current version are applied by adding a pin to
1746
+ * `channelOptions.pins` so downstream clone logic honors them.
1747
+ */
1748
+ async _resolveUpdateChannels({ bmadDir, selectedModules, channelOptions, yes }) {
1749
+ const { Manifest } = require('./core/manifest');
1750
+ const manifestObj = new Manifest();
1751
+ const manifest = await manifestObj.read(bmadDir);
1752
+ const existingByName = new Map();
1753
+ for (const m of manifest?.modulesDetailed || []) {
1754
+ if (m?.name) existingByName.set(m.name, m);
1755
+ }
1756
+ if (existingByName.size === 0) return;
1757
+
1758
+ const externalManager = new ExternalModuleManager();
1759
+ const externals = await externalManager.listAvailable();
1760
+ const externalByCode = new Map(externals.map((m) => [m.code, m]));
1761
+
1762
+ const { CommunityModuleManager } = require('./modules/community-manager');
1763
+ const communityMgr = new CommunityModuleManager();
1764
+ const community = await communityMgr.listAll();
1765
+ const communityByCode = new Map(community.map((m) => [m.code, m]));
1766
+
1767
+ const { fetchStableTags, classifyUpgrade, releaseNotesUrl } = require('./modules/channel-resolver');
1768
+ const { parseGitHubRepo } = require('./modules/channel-resolver');
1769
+
1770
+ // Interactive-only: offer a one-time gate to review / switch channels for
1771
+ // selected modules that are already installed. Default N so normal Modify
1772
+ // flows (add/remove modules) aren't interrupted.
1773
+ let reviewChannels = false;
1774
+ if (!yes) {
1775
+ const existingWithChannel = selectedModules.filter((code) => {
1776
+ const prev = existingByName.get(code);
1777
+ if (!prev) return false;
1778
+ const info = externalByCode.get(code) || communityByCode.get(code);
1779
+ return info && !info.builtIn;
1780
+ });
1781
+ if (existingWithChannel.length > 0) {
1782
+ reviewChannels = await prompts.confirm({
1783
+ message: 'Review channel assignments (stable / next / pin) for your existing modules?',
1784
+ default: false,
1785
+ });
1786
+ }
1787
+ }
1788
+
1789
+ for (const code of selectedModules) {
1790
+ const prev = existingByName.get(code);
1791
+ if (!prev) continue;
1792
+
1793
+ const info = externalByCode.get(code) || communityByCode.get(code);
1794
+ if (!info) continue;
1795
+ // Bundled modules (core/bmm) ship with the installer binary itself —
1796
+ // their version is stapled to the CLI version, not a git tag. Skip
1797
+ // tag-API lookups for them; the "upgrade" mechanism is `npx bmad@X install`.
1798
+ if (info.builtIn) continue;
1799
+
1800
+ const repoUrl = info.url;
1801
+ const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
1802
+
1803
+ // Legacy migration: manifest carries no channel and a null/empty
1804
+ // version. Offer the one-time pick between stable and next.
1805
+ const recordedChannel = prev.channel || null;
1806
+ const needsMigration = !recordedChannel && (prev.version == null || prev.version === '');
1807
+ if (needsMigration) {
1808
+ if (yes) {
1809
+ // Conservative headless default: stable.
1810
+ continue;
1811
+ }
1812
+ const chosen = await prompts.select({
1813
+ message: `${code}: your existing install tracks the main branch. Switch to stable releases (recommended for production), or keep on main?`,
1814
+ choices: [
1815
+ { name: 'Switch to stable', value: 'stable' },
1816
+ { name: 'Keep on main (next)', value: 'next' },
1817
+ ],
1818
+ default: 'stable',
1819
+ });
1820
+ if (chosen === 'next') channelOptions.nextSet.add(code);
1821
+ continue;
1822
+ }
1823
+
1824
+ // Optional channel-switch offer. Fires only when the user opted in via
1825
+ // the gate above. 'keep' falls through to the existing per-channel
1826
+ // logic (which runs upgrade classification for stable). Any switch
1827
+ // records the new intent into channelOptions and skips upgrade prompts.
1828
+ if (reviewChannels && recordedChannel) {
1829
+ const switchChoices = [
1830
+ {
1831
+ name: `Keep on '${recordedChannel}'${prev.version ? ` @ ${prev.version}` : ''}`,
1832
+ value: 'keep',
1833
+ },
1834
+ ];
1835
+ if (recordedChannel !== 'stable') {
1836
+ switchChoices.push({ name: 'Switch to stable (released version)', value: 'stable' });
1837
+ }
1838
+ if (recordedChannel !== 'next') {
1839
+ switchChoices.push({ name: 'Switch to next (main HEAD)', value: 'next' });
1840
+ }
1841
+ switchChoices.push({ name: 'Pin to a specific version tag', value: 'pin' });
1842
+
1843
+ const choice = await prompts.select({
1844
+ message: `${code} channel:`,
1845
+ choices: switchChoices,
1846
+ default: 'keep',
1847
+ });
1848
+
1849
+ if (choice === 'next') {
1850
+ channelOptions.nextSet.add(code);
1851
+ continue;
1852
+ }
1853
+ if (choice === 'pin') {
1854
+ const pinValue = await prompts.text({
1855
+ message: `Enter a version tag for '${code}' (e.g. v1.6.0):`,
1856
+ validate: (value) => {
1857
+ if (!value || !/^[\w.\-+/]+$/.test(String(value).trim())) {
1858
+ return 'Must be a non-empty tag name (letters, digits, dots, hyphens).';
1859
+ }
1860
+ },
1861
+ });
1862
+ channelOptions.pins.set(code, String(pinValue).trim());
1863
+ continue;
1864
+ }
1865
+ if (choice === 'stable') {
1866
+ // Switch to stable: install at the top stable tag without an
1867
+ // upgrade-classification prompt (the user explicitly opted in).
1868
+ // Also warm the tag cache here so the actual clone step doesn't
1869
+ // need a second GitHub API call (can hit rate limits).
1870
+ if (parsed) {
1871
+ try {
1872
+ await fetchStableTags(parsed.owner, parsed.repo);
1873
+ } catch {
1874
+ // best effort; clone step will surface any failure
1875
+ }
1876
+ }
1877
+ continue;
1878
+ }
1879
+ // 'keep' → fall through with recordedChannel below.
1880
+ }
1881
+
1882
+ if (recordedChannel === 'pinned' || recordedChannel === 'next') {
1883
+ // Respect any explicit channel intent the user already expressed via
1884
+ // CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG) or
1885
+ // via the interactive review gate above. Only auto-re-assert the
1886
+ // recorded channel when the user hasn't opted into anything else —
1887
+ // otherwise --all-stable (or a review "switch to stable") would be
1888
+ // silently clobbered by the prior channel.
1889
+ const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
1890
+ if (!alreadyDecided) {
1891
+ if (recordedChannel === 'pinned' && prev.version) {
1892
+ channelOptions.pins.set(code, prev.version);
1893
+ } else if (recordedChannel === 'next') {
1894
+ channelOptions.nextSet.add(code);
1895
+ }
1896
+ }
1897
+ continue;
1898
+ }
1899
+
1900
+ // Stable channel: check for a newer released tag.
1901
+ if (!parsed) continue;
1902
+ // Respect explicit CLI intent (--pin / --next=CODE / --all-*) and any
1903
+ // choice the user already made in the earlier review gate. Without this
1904
+ // guard the upgrade classifier below would unconditionally call
1905
+ // `channelOptions.pins.set(code, prev.version)` on decline/major-refuse/
1906
+ // fetch-error, silently clobbering the user's override.
1907
+ const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
1908
+ if (alreadyDecided) continue;
1909
+ let tags;
1910
+ try {
1911
+ tags = await fetchStableTags(parsed.owner, parsed.repo);
1912
+ } catch (error) {
1913
+ await prompts.log.warn(`Could not check for updates on ${code} (${error.message}). Leaving at ${prev.version}.`);
1914
+ if (prev.version) channelOptions.pins.set(code, prev.version);
1915
+ continue;
1916
+ }
1917
+ if (!tags || tags.length === 0) continue;
1918
+ const topTag = tags[0].tag; // e.g. "v1.7.0"
1919
+ const currentTag = prev.version || '';
1920
+ const diffClass = classifyUpgrade(currentTag, topTag);
1921
+
1922
+ if (diffClass === 'none') continue; // already at or above top tag
1923
+
1924
+ const notes = releaseNotesUrl(repoUrl, topTag);
1925
+ let accept;
1926
+ if (diffClass === 'major') {
1927
+ if (yes) {
1928
+ // Major under --yes is refused by design.
1929
+ await prompts.log.warn(
1930
+ `${code} ${currentTag} → ${topTag} is a new major release; staying on ${currentTag}. ` +
1931
+ `To accept, rerun with --pin ${code}=${topTag}.`,
1932
+ );
1933
+ channelOptions.pins.set(code, currentTag);
1934
+ continue;
1935
+ }
1936
+ accept = await prompts.confirm({
1937
+ message:
1938
+ `${code} ${topTag} available — new major release (may change behavior).` +
1939
+ (notes ? ` Release notes: ${notes}.` : '') +
1940
+ ' Upgrade?',
1941
+ default: false,
1942
+ });
1943
+ } else if (diffClass === 'minor') {
1944
+ if (yes) {
1945
+ accept = true;
1946
+ } else {
1947
+ accept = await prompts.confirm({
1948
+ message: `${code} ${topTag} available (new features).` + (notes ? ` Release notes: ${notes}.` : '') + ' Upgrade?',
1949
+ default: true,
1950
+ });
1951
+ }
1952
+ } else {
1953
+ // patch
1954
+ if (yes) {
1955
+ accept = true;
1956
+ } else {
1957
+ accept = await prompts.confirm({
1958
+ message: `${code} ${topTag} available. Upgrade?`,
1959
+ default: true,
1960
+ });
1961
+ }
1962
+ }
1963
+
1964
+ if (!accept && currentTag) {
1965
+ // Freeze the current version by pinning it for this run.
1966
+ channelOptions.pins.set(code, currentTag);
1967
+ }
1968
+ }
1969
+ }
1566
1970
  }
1567
1971
 
1568
1972
  module.exports = { UI };