bmad-method 6.3.1-next.21 → 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.21",
4
+ "version": "6.3.1-next.22",
5
5
  "description": "Breakthrough Method of Agile AI-driven Development",
6
6
  "keywords": [
7
7
  "agile",
@@ -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
@@ -362,35 +373,40 @@ class Manifest {
362
373
  * @returns {string|null} Latest version or null
363
374
  */
364
375
  async fetchNpmVersion(packageName) {
365
- try {
366
- const https = require('node:https');
367
- const { execSync } = require('node:child_process');
376
+ if (!isValidNpmPackageName(packageName)) {
377
+ return null;
378
+ }
368
379
 
380
+ try {
369
381
  // Try using npm view first (more reliable)
370
382
  try {
371
- const result = execSync(`npm view ${packageName} version`, {
383
+ const { stdout } = await execFileAsync('npm', ['view', packageName, 'version'], {
372
384
  encoding: 'utf8',
373
- stdio: 'pipe',
374
- timeout: 10_000,
385
+ timeout: NPM_LOOKUP_TIMEOUT_MS,
375
386
  });
376
- return result.trim();
387
+ return stdout.trim();
377
388
  } catch {
378
389
  // Fallback to npm registry API
379
- return new Promise((resolve, reject) => {
380
- https
381
- .get(`https://registry.npmjs.org/${packageName}`, (res) => {
382
- let data = '';
383
- res.on('data', (chunk) => (data += chunk));
384
- res.on('end', () => {
385
- try {
386
- const pkg = JSON.parse(data);
387
- resolve(pkg['dist-tags']?.latest || pkg.version || null);
388
- } catch {
389
- resolve(null);
390
- }
391
- });
392
- })
393
- .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));
394
410
  });
395
411
  }
396
412
  } catch {
@@ -1,20 +1,107 @@
1
1
  const path = require('node:path');
2
2
  const os = require('node:os');
3
+ const semver = require('semver');
3
4
  const fs = require('./fs-native');
4
5
  const { CLIUtils } = require('./cli-utils');
5
6
  const { ExternalModuleManager } = require('./modules/external-manager');
6
7
  const { resolveModuleVersion } = require('./modules/version-resolver');
7
- const { parseChannelOptions, buildPlan, orphanPinWarnings, bundledTargetWarnings } = require('./modules/channel-plan');
8
+ const { Manifest } = require('./core/manifest');
9
+ const {
10
+ parseChannelOptions,
11
+ buildPlan,
12
+ decideChannelForModule,
13
+ orphanPinWarnings,
14
+ bundledTargetWarnings,
15
+ } = require('./modules/channel-plan');
16
+ const channelResolver = require('./modules/channel-resolver');
8
17
  const prompts = require('./prompts');
9
18
 
19
+ const manifest = new Manifest();
20
+
21
+ /**
22
+ * Format a resolved version for display in installer labels.
23
+ * Semver-like values are normalized to a single leading "v".
24
+ * @param {string|null|undefined} version
25
+ * @returns {string}
26
+ */
27
+ function formatDisplayVersion(version) {
28
+ const trimmed = typeof version === 'string' ? version.trim() : '';
29
+ if (!trimmed) return '';
30
+
31
+ const normalized = semver.valid(semver.coerce(trimmed));
32
+ if (normalized) {
33
+ return `v${normalized}`;
34
+ }
35
+
36
+ return trimmed;
37
+ }
38
+
39
+ /**
40
+ * Build the display label for a module, showing an upgrade arrow when an
41
+ * installed semver differs from the latest resolvable semver.
42
+ * @param {string} name
43
+ * @param {string} latestVersion
44
+ * @param {string} installedVersion
45
+ * @returns {string}
46
+ */
47
+ function buildModuleLabel(name, latestVersion, installedVersion = '') {
48
+ const latestDisplay = formatDisplayVersion(latestVersion);
49
+ if (!latestDisplay) return name;
50
+
51
+ const installedDisplay = formatDisplayVersion(installedVersion);
52
+ const latestSemver = semver.valid(semver.coerce(latestVersion || ''));
53
+ const installedSemver = semver.valid(semver.coerce(installedVersion || ''));
54
+
55
+ if (installedDisplay && latestSemver && installedSemver && semver.neq(installedSemver, latestSemver)) {
56
+ return `${name} (${installedDisplay} → ${latestDisplay})`;
57
+ }
58
+
59
+ return `${name} (${latestDisplay})`;
60
+ }
61
+
10
62
  /**
11
- * Read a module version from the freshest local metadata available.
63
+ * Resolve the version to show for a module picker entry. External modules use
64
+ * the same channel/tag resolver as installs; bundled modules fall back to local
65
+ * source metadata.
12
66
  * @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
13
- * @returns {string} Version string or empty string
67
+ * @param {Object} options
68
+ * @param {string|null} [options.repoUrl] - Module repository URL for tag resolution
69
+ * @param {string|null} [options.registryDefault] - Registry default channel
70
+ * @param {Object|null} [options.channelOptions] - Parsed installer channel options
71
+ * @returns {Promise<{version: string, lookupAttempted: boolean, lookupSucceeded: boolean}>}
14
72
  */
15
- async function getModuleVersion(moduleCode) {
73
+ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault = null, channelOptions = null } = {}) {
74
+ if (repoUrl) {
75
+ const plan = decideChannelForModule({
76
+ code: moduleCode,
77
+ channelOptions,
78
+ registryDefault,
79
+ });
80
+
81
+ try {
82
+ const resolved = await channelResolver.resolveChannel({
83
+ channel: plan.channel,
84
+ pin: plan.pin,
85
+ repoUrl,
86
+ });
87
+ if (resolved?.version) {
88
+ return {
89
+ version: resolved.version,
90
+ lookupAttempted: plan.channel === 'stable',
91
+ lookupSucceeded: true,
92
+ };
93
+ }
94
+ } catch {
95
+ // Fall back to local metadata when tag resolution is unavailable.
96
+ }
97
+ }
98
+
16
99
  const versionInfo = await resolveModuleVersion(moduleCode);
17
- return versionInfo.version || '';
100
+ return {
101
+ version: versionInfo.version || '',
102
+ lookupAttempted: !!repoUrl,
103
+ lookupSucceeded: false,
104
+ };
18
105
  }
19
106
 
20
107
  /**
@@ -122,7 +209,7 @@ class UI {
122
209
  // Return early with modify configuration
123
210
  if (actionType === 'update') {
124
211
  // Get existing installation info
125
- const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
212
+ const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
126
213
 
127
214
  await prompts.log.message(`Found existing modules: ${[...installedModuleIds].join(', ')}`);
128
215
 
@@ -144,7 +231,7 @@ class UI {
144
231
  `Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`,
145
232
  );
146
233
  } else {
147
- selectedModules = await this.selectAllModules(installedModuleIds);
234
+ selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
148
235
  }
149
236
 
150
237
  // Resolve custom sources from --custom-source flag
@@ -208,7 +295,7 @@ class UI {
208
295
  }
209
296
 
210
297
  // This section is only for new installations (update returns early above)
211
- const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
298
+ const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
212
299
 
213
300
  // Unified module selection - all modules in one grouped multiselect
214
301
  let selectedModules;
@@ -227,7 +314,7 @@ class UI {
227
314
  selectedModules = await this.getDefaultModules(installedModuleIds);
228
315
  await prompts.log.info(`Using default modules (--yes flag): ${selectedModules.join(', ')}`);
229
316
  } else {
230
- selectedModules = await this.selectAllModules(installedModuleIds);
317
+ selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
231
318
  }
232
319
 
233
320
  // Resolve custom sources from --custom-source flag
@@ -526,7 +613,7 @@ class UI {
526
613
  /**
527
614
  * Get existing installation info and installed modules
528
615
  * @param {string} directory - Installation directory
529
- * @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
616
+ * @returns {Object} Object with existingInstall, installedModuleIds, installedModuleVersions, and bmadDir
530
617
  */
531
618
  async getExistingInstallation(directory) {
532
619
  const { ExistingInstall } = require('./core/existing-install');
@@ -535,8 +622,26 @@ class UI {
535
622
  const { bmadDir } = await installer.findBmadDir(directory);
536
623
  const existingInstall = await ExistingInstall.detect(bmadDir);
537
624
  const installedModuleIds = new Set(existingInstall.moduleIds);
625
+ const installedModuleVersions = new Map();
626
+ const manifestModules = await manifest.getAllModuleVersions(bmadDir);
538
627
 
539
- return { existingInstall, installedModuleIds, bmadDir };
628
+ for (const module of manifestModules) {
629
+ if (module?.name && module.version) {
630
+ installedModuleVersions.set(module.name, module.version);
631
+ }
632
+ }
633
+
634
+ for (const module of existingInstall.modules) {
635
+ if (module?.id && module.version && module.version !== 'unknown' && !installedModuleVersions.has(module.id)) {
636
+ installedModuleVersions.set(module.id, module.version);
637
+ }
638
+ }
639
+
640
+ if (existingInstall.hasCore && existingInstall.version && !installedModuleVersions.has('core')) {
641
+ installedModuleVersions.set('core', existingInstall.version);
642
+ }
643
+
644
+ return { existingInstall, installedModuleIds, installedModuleVersions, bmadDir };
540
645
  }
541
646
 
542
647
  /**
@@ -617,11 +722,13 @@ class UI {
617
722
  /**
618
723
  * Select all modules across three tiers: official, community, and custom URL.
619
724
  * @param {Set} installedModuleIds - Currently installed module IDs
725
+ * @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
726
+ * @param {Object|null} channelOptions - Parsed installer channel options
620
727
  * @returns {Array} Selected module codes (excluding core)
621
728
  */
622
- async selectAllModules(installedModuleIds = new Set()) {
729
+ async selectAllModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
623
730
  // Phase 1: Official modules
624
- const officialSelected = await this._selectOfficialModules(installedModuleIds);
731
+ const officialSelected = await this._selectOfficialModules(installedModuleIds, installedModuleVersions, channelOptions);
625
732
 
626
733
  // Determine which installed modules are NOT official (community or custom).
627
734
  // These must be preserved even if the user declines to browse community/custom.
@@ -657,9 +764,11 @@ class UI {
657
764
  * Select official modules using autocompleteMultiselect.
658
765
  * Extracted from the original selectAllModules - unchanged behavior.
659
766
  * @param {Set} installedModuleIds - Currently installed module IDs
767
+ * @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
768
+ * @param {Object|null} channelOptions - Parsed installer channel options
660
769
  * @returns {Array} Selected official module codes
661
770
  */
662
- async _selectOfficialModules(installedModuleIds = new Set()) {
771
+ async _selectOfficialModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
663
772
  // Built-in modules (core, bmm) come from local source, not the registry
664
773
  const { OfficialModules } = require('./modules/official-modules');
665
774
  const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
@@ -672,15 +781,18 @@ class UI {
672
781
  const initialValues = [];
673
782
  const lockedValues = ['core'];
674
783
 
675
- const buildModuleEntry = async (code, name, description, isDefault) => {
784
+ const buildModuleEntry = async (code, name, description, isDefault, repoUrl = null, registryDefault = null) => {
676
785
  const isInstalled = installedModuleIds.has(code);
677
- const version = await getModuleVersion(code);
678
- const label = version ? `${name} (v${version})` : name;
786
+ const installedVersion = installedModuleVersions.get(code) || '';
787
+ const versionState = await getModuleVersion(code, { repoUrl, registryDefault, channelOptions });
788
+ const label = buildModuleLabel(name, versionState.version, installedVersion);
679
789
  return {
680
790
  label,
681
791
  value: code,
682
792
  hint: description,
683
793
  selected: isInstalled || isDefault,
794
+ lookupAttempted: versionState.lookupAttempted,
795
+ lookupSucceeded: versionState.lookupSucceeded,
684
796
  };
685
797
  };
686
798
 
@@ -697,12 +809,38 @@ class UI {
697
809
  }
698
810
 
699
811
  // Add external registry modules (skip built-in duplicates)
700
- for (const mod of registryModules) {
701
- if (mod.builtIn || builtInCodes.has(mod.code)) continue;
702
- const entry = await buildModuleEntry(mod.code, mod.name, mod.description, mod.defaultSelected);
812
+ const externalRegistryModules = registryModules.filter((mod) => !mod.builtIn && !builtInCodes.has(mod.code));
813
+ let externalRegistryEntries = [];
814
+ if (externalRegistryModules.length > 0) {
815
+ const spinner = await prompts.spinner();
816
+ spinner.start('Checking latest module versions...');
817
+
818
+ externalRegistryEntries = await Promise.all(
819
+ externalRegistryModules.map(async (mod) => ({
820
+ code: mod.code,
821
+ entry: await buildModuleEntry(
822
+ mod.code,
823
+ mod.name,
824
+ mod.description,
825
+ mod.defaultSelected,
826
+ mod.url || null,
827
+ mod.defaultChannel || null,
828
+ ),
829
+ })),
830
+ );
831
+
832
+ spinner.stop('Checked latest module versions.');
833
+
834
+ const attemptedLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupAttempted).length;
835
+ const successfulLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupSucceeded).length;
836
+ if (attemptedLookups > 0 && successfulLookups === 0) {
837
+ await prompts.log.warn('Could not check latest module versions; showing cached/local versions.');
838
+ }
839
+ }
840
+ for (const { code, entry } of externalRegistryEntries) {
703
841
  allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
704
842
  if (entry.selected) {
705
- initialValues.push(mod.code);
843
+ initialValues.push(code);
706
844
  }
707
845
  }
708
846