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 +1 -1
- package/tools/installer/core/manifest.js +38 -22
- package/tools/installer/ui.js +159 -21
package/package.json
CHANGED
|
@@ -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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
383
|
+
const { stdout } = await execFileAsync('npm', ['view', packageName, 'version'], {
|
|
372
384
|
encoding: 'utf8',
|
|
373
|
-
|
|
374
|
-
timeout: 10_000,
|
|
385
|
+
timeout: NPM_LOOKUP_TIMEOUT_MS,
|
|
375
386
|
});
|
|
376
|
-
return
|
|
387
|
+
return stdout.trim();
|
|
377
388
|
} catch {
|
|
378
389
|
// Fallback to npm registry API
|
|
379
|
-
return new Promise((resolve
|
|
380
|
-
https
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 {
|
package/tools/installer/ui.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
|
|
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
|
|
678
|
-
const
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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(
|
|
843
|
+
initialValues.push(code);
|
|
706
844
|
}
|
|
707
845
|
}
|
|
708
846
|
|