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
|
@@ -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, {
|
|
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
|
-
|
|
341
|
-
|
|
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 {
|
|
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
|
package/tools/installer/ui.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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 };
|