bmad-method 6.2.3-next.27 → 6.2.3-next.28
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/installer.js +32 -0
- package/tools/installer/core/manifest.js +28 -0
- package/tools/installer/modules/community-manager.js +377 -0
- package/tools/installer/modules/custom-module-manager.js +308 -0
- package/tools/installer/modules/external-manager.js +4 -31
- package/tools/installer/modules/official-modules.js +23 -1
- package/tools/installer/modules/registry-client.js +66 -0
- package/tools/installer/ui.js +289 -9
package/package.json
CHANGED
|
@@ -1161,6 +1161,38 @@ class Installer {
|
|
|
1161
1161
|
}
|
|
1162
1162
|
}
|
|
1163
1163
|
|
|
1164
|
+
// Add installed community modules to available modules
|
|
1165
|
+
const { CommunityModuleManager } = require('../modules/community-manager');
|
|
1166
|
+
const communityMgr = new CommunityModuleManager();
|
|
1167
|
+
const communityModules = await communityMgr.listAll();
|
|
1168
|
+
for (const communityModule of communityModules) {
|
|
1169
|
+
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
|
|
1170
|
+
availableModules.push({
|
|
1171
|
+
id: communityModule.code,
|
|
1172
|
+
name: communityModule.displayName,
|
|
1173
|
+
isExternal: true,
|
|
1174
|
+
fromCommunity: true,
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Add installed custom modules to available modules
|
|
1180
|
+
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
|
1181
|
+
const customMgr = new CustomModuleManager();
|
|
1182
|
+
for (const moduleId of installedModules) {
|
|
1183
|
+
if (!availableModules.some((m) => m.id === moduleId)) {
|
|
1184
|
+
const customSource = await customMgr.findModuleSourceByCode(moduleId);
|
|
1185
|
+
if (customSource) {
|
|
1186
|
+
availableModules.push({
|
|
1187
|
+
id: moduleId,
|
|
1188
|
+
name: moduleId,
|
|
1189
|
+
isExternal: true,
|
|
1190
|
+
fromCustom: true,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1164
1196
|
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
|
1165
1197
|
|
|
1166
1198
|
// Only update modules that are BOTH installed AND available (we have source for)
|
|
@@ -818,6 +818,34 @@ class Manifest {
|
|
|
818
818
|
};
|
|
819
819
|
}
|
|
820
820
|
|
|
821
|
+
// Check if this is a community module
|
|
822
|
+
const { CommunityModuleManager } = require('../modules/community-manager');
|
|
823
|
+
const communityMgr = new CommunityModuleManager();
|
|
824
|
+
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
|
825
|
+
if (communityInfo) {
|
|
826
|
+
const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
|
827
|
+
return {
|
|
828
|
+
version: communityVersion || communityInfo.version,
|
|
829
|
+
source: 'community',
|
|
830
|
+
npmPackage: communityInfo.npmPackage || null,
|
|
831
|
+
repoUrl: communityInfo.url || null,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Check if this is a custom module (from user-provided URL)
|
|
836
|
+
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
|
837
|
+
const customMgr = new CustomModuleManager();
|
|
838
|
+
const customSource = await customMgr.findModuleSourceByCode(moduleName);
|
|
839
|
+
if (customSource) {
|
|
840
|
+
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
|
841
|
+
return {
|
|
842
|
+
version: customVersion,
|
|
843
|
+
source: 'custom',
|
|
844
|
+
npmPackage: null,
|
|
845
|
+
repoUrl: null,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
821
849
|
// Unknown module
|
|
822
850
|
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
|
823
851
|
return {
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execSync } = require('node:child_process');
|
|
5
|
+
const prompts = require('../prompts');
|
|
6
|
+
const { RegistryClient } = require('./registry-client');
|
|
7
|
+
|
|
8
|
+
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
|
|
9
|
+
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
|
|
10
|
+
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Manages community modules from the BMad marketplace registry.
|
|
14
|
+
* Fetches community-index.yaml and categories.yaml from GitHub.
|
|
15
|
+
* Returns empty results when the registry is unreachable.
|
|
16
|
+
* Community modules are pinned to approved SHA when set; uses HEAD otherwise.
|
|
17
|
+
*/
|
|
18
|
+
class CommunityModuleManager {
|
|
19
|
+
constructor() {
|
|
20
|
+
this._client = new RegistryClient();
|
|
21
|
+
this._cachedIndex = null;
|
|
22
|
+
this._cachedCategories = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Data Loading ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load the community module index from the marketplace repo.
|
|
29
|
+
* Returns empty when the registry is unreachable.
|
|
30
|
+
* @returns {Object} Parsed YAML with modules array
|
|
31
|
+
*/
|
|
32
|
+
async loadCommunityIndex() {
|
|
33
|
+
if (this._cachedIndex) return this._cachedIndex;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
|
|
37
|
+
if (config?.modules?.length) {
|
|
38
|
+
this._cachedIndex = config;
|
|
39
|
+
return config;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Registry unreachable - no community modules available
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { modules: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load categories from the marketplace repo.
|
|
50
|
+
* Returns empty when the registry is unreachable.
|
|
51
|
+
* @returns {Object} Parsed categories.yaml content
|
|
52
|
+
*/
|
|
53
|
+
async loadCategories() {
|
|
54
|
+
if (this._cachedCategories) return this._cachedCategories;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const config = await this._client.fetchYaml(CATEGORIES_URL);
|
|
58
|
+
if (config?.categories) {
|
|
59
|
+
this._cachedCategories = config;
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Registry unreachable - no categories available
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { categories: {} };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Listing & Filtering ──────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get all community modules, normalized.
|
|
73
|
+
* @returns {Array<Object>} Normalized community modules
|
|
74
|
+
*/
|
|
75
|
+
async listAll() {
|
|
76
|
+
const index = await this.loadCommunityIndex();
|
|
77
|
+
return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get community modules filtered to a category.
|
|
82
|
+
* @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
|
|
83
|
+
* @returns {Array<Object>} Filtered modules
|
|
84
|
+
*/
|
|
85
|
+
async listByCategory(categorySlug) {
|
|
86
|
+
const all = await this.listAll();
|
|
87
|
+
return all.filter((mod) => mod.category === categorySlug);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get promoted/featured community modules, sorted by rank.
|
|
92
|
+
* @returns {Array<Object>} Featured modules
|
|
93
|
+
*/
|
|
94
|
+
async listFeatured() {
|
|
95
|
+
const all = await this.listAll();
|
|
96
|
+
return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Search community modules by keyword.
|
|
101
|
+
* Matches against name, display name, description, and keywords array.
|
|
102
|
+
* @param {string} query - Search query
|
|
103
|
+
* @returns {Array<Object>} Matching modules
|
|
104
|
+
*/
|
|
105
|
+
async searchByKeyword(query) {
|
|
106
|
+
const all = await this.listAll();
|
|
107
|
+
const q = query.toLowerCase();
|
|
108
|
+
return all.filter((mod) => {
|
|
109
|
+
const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
|
|
110
|
+
return searchable.includes(q);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get categories with module counts for UI display.
|
|
116
|
+
* Only returns categories that have at least one community module.
|
|
117
|
+
* @returns {Array<Object>} Array of { slug, name, moduleCount }
|
|
118
|
+
*/
|
|
119
|
+
async getCategoryList() {
|
|
120
|
+
const all = await this.listAll();
|
|
121
|
+
const categoriesData = await this.loadCategories();
|
|
122
|
+
const categories = categoriesData.categories || {};
|
|
123
|
+
|
|
124
|
+
// Count modules per category
|
|
125
|
+
const counts = {};
|
|
126
|
+
for (const mod of all) {
|
|
127
|
+
counts[mod.category] = (counts[mod.category] || 0) + 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Build list with display names from categories.yaml
|
|
131
|
+
const result = [];
|
|
132
|
+
for (const [slug, count] of Object.entries(counts)) {
|
|
133
|
+
const catInfo = categories[slug];
|
|
134
|
+
result.push({
|
|
135
|
+
slug,
|
|
136
|
+
name: catInfo?.name || slug,
|
|
137
|
+
moduleCount: count,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Sort alphabetically by name
|
|
142
|
+
result.sort((a, b) => a.name.localeCompare(b.name));
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Module Lookup ────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get a community module by its code.
|
|
150
|
+
* @param {string} code - Module code (e.g., 'wds')
|
|
151
|
+
* @returns {Object|null} Normalized module or null
|
|
152
|
+
*/
|
|
153
|
+
async getModuleByCode(code) {
|
|
154
|
+
const all = await this.listAll();
|
|
155
|
+
return all.find((m) => m.code === code) || null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Clone with Tag Pinning ───────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get the cache directory for community modules.
|
|
162
|
+
* @returns {string} Path to the community modules cache directory
|
|
163
|
+
*/
|
|
164
|
+
getCacheDir() {
|
|
165
|
+
return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Clone a community module repository, pinned to its approved tag.
|
|
170
|
+
* @param {string} moduleCode - Module code
|
|
171
|
+
* @param {Object} [options] - Clone options
|
|
172
|
+
* @param {boolean} [options.silent] - Suppress spinner output
|
|
173
|
+
* @returns {string} Path to the cloned repository
|
|
174
|
+
*/
|
|
175
|
+
async cloneModule(moduleCode, options = {}) {
|
|
176
|
+
const moduleInfo = await this.getModuleByCode(moduleCode);
|
|
177
|
+
if (!moduleInfo) {
|
|
178
|
+
throw new Error(`Community module '${moduleCode}' not found in the registry`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cacheDir = this.getCacheDir();
|
|
182
|
+
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
|
183
|
+
const silent = options.silent || false;
|
|
184
|
+
|
|
185
|
+
await fs.ensureDir(cacheDir);
|
|
186
|
+
|
|
187
|
+
const createSpinner = async () => {
|
|
188
|
+
if (silent) {
|
|
189
|
+
return { start() {}, stop() {}, error() {}, message() {} };
|
|
190
|
+
}
|
|
191
|
+
return await prompts.spinner();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const sha = moduleInfo.approvedSha;
|
|
195
|
+
let needsDependencyInstall = false;
|
|
196
|
+
let wasNewClone = false;
|
|
197
|
+
|
|
198
|
+
if (await fs.pathExists(moduleCacheDir)) {
|
|
199
|
+
// Already cloned - update to latest HEAD
|
|
200
|
+
const fetchSpinner = await createSpinner();
|
|
201
|
+
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
|
|
202
|
+
try {
|
|
203
|
+
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
204
|
+
execSync('git fetch origin --depth 1', {
|
|
205
|
+
cwd: moduleCacheDir,
|
|
206
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
207
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
208
|
+
});
|
|
209
|
+
execSync('git reset --hard origin/HEAD', {
|
|
210
|
+
cwd: moduleCacheDir,
|
|
211
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
212
|
+
});
|
|
213
|
+
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
214
|
+
if (currentRef !== newRef) needsDependencyInstall = true;
|
|
215
|
+
fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
|
|
216
|
+
} catch {
|
|
217
|
+
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
|
|
218
|
+
await fs.remove(moduleCacheDir);
|
|
219
|
+
wasNewClone = true;
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
wasNewClone = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (wasNewClone) {
|
|
226
|
+
const fetchSpinner = await createSpinner();
|
|
227
|
+
fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
|
|
228
|
+
try {
|
|
229
|
+
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
|
230
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
231
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
232
|
+
});
|
|
233
|
+
fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
|
|
234
|
+
needsDependencyInstall = true;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
|
|
237
|
+
throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// If pinned to a specific SHA, check out that exact commit.
|
|
242
|
+
// Refuse to install if the approved SHA cannot be reached - security requirement.
|
|
243
|
+
if (sha) {
|
|
244
|
+
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
245
|
+
if (headSha !== sha) {
|
|
246
|
+
try {
|
|
247
|
+
execSync(`git fetch --depth 1 origin ${sha}`, {
|
|
248
|
+
cwd: moduleCacheDir,
|
|
249
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
250
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
251
|
+
});
|
|
252
|
+
execSync(`git checkout ${sha}`, {
|
|
253
|
+
cwd: moduleCacheDir,
|
|
254
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
255
|
+
});
|
|
256
|
+
needsDependencyInstall = true;
|
|
257
|
+
} catch {
|
|
258
|
+
await fs.remove(moduleCacheDir);
|
|
259
|
+
throw new Error(
|
|
260
|
+
`Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
|
|
261
|
+
`Installation refused for security. The module registry entry may need updating.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Install dependencies if needed
|
|
268
|
+
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
|
269
|
+
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
|
|
270
|
+
const installSpinner = await createSpinner();
|
|
271
|
+
installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
|
|
272
|
+
try {
|
|
273
|
+
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
274
|
+
cwd: moduleCacheDir,
|
|
275
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
276
|
+
timeout: 120_000,
|
|
277
|
+
});
|
|
278
|
+
installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
|
|
281
|
+
if (!silent) await prompts.log.warn(` ${error.message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return moduleCacheDir;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Source Finding ───────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Find the source path for a community module (clone + locate module.yaml).
|
|
292
|
+
* @param {string} moduleCode - Module code
|
|
293
|
+
* @param {Object} [options] - Options passed to cloneModule
|
|
294
|
+
* @returns {string|null} Path to the module source or null
|
|
295
|
+
*/
|
|
296
|
+
async findModuleSource(moduleCode, options = {}) {
|
|
297
|
+
const moduleInfo = await this.getModuleByCode(moduleCode);
|
|
298
|
+
if (!moduleInfo) return null;
|
|
299
|
+
|
|
300
|
+
const cloneDir = await this.cloneModule(moduleCode, options);
|
|
301
|
+
|
|
302
|
+
// Check configured module_definition path first
|
|
303
|
+
if (moduleInfo.moduleDefinition) {
|
|
304
|
+
const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
|
|
305
|
+
if (await fs.pathExists(configuredPath)) {
|
|
306
|
+
return path.dirname(configuredPath);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Fallback: search skills/ and src/ directories
|
|
311
|
+
for (const dir of ['skills', 'src']) {
|
|
312
|
+
const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
|
|
313
|
+
if (await fs.pathExists(rootCandidate)) {
|
|
314
|
+
return path.dirname(rootCandidate);
|
|
315
|
+
}
|
|
316
|
+
const dirPath = path.join(cloneDir, dir);
|
|
317
|
+
if (await fs.pathExists(dirPath)) {
|
|
318
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
|
322
|
+
if (await fs.pathExists(subCandidate)) {
|
|
323
|
+
return path.dirname(subCandidate);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check repo root
|
|
331
|
+
const rootCandidate = path.join(cloneDir, 'module.yaml');
|
|
332
|
+
if (await fs.pathExists(rootCandidate)) {
|
|
333
|
+
return path.dirname(rootCandidate);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─── Normalization ────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Normalize a community module entry to a consistent shape.
|
|
343
|
+
* @param {Object} mod - Raw module from community-index.yaml
|
|
344
|
+
* @returns {Object} Normalized module info
|
|
345
|
+
*/
|
|
346
|
+
_normalizeCommunityModule(mod) {
|
|
347
|
+
return {
|
|
348
|
+
key: mod.name,
|
|
349
|
+
code: mod.code,
|
|
350
|
+
name: mod.display_name || mod.name,
|
|
351
|
+
displayName: mod.display_name || mod.name,
|
|
352
|
+
description: mod.description || '',
|
|
353
|
+
url: mod.repository || mod.url,
|
|
354
|
+
moduleDefinition: mod.module_definition || mod['module-definition'],
|
|
355
|
+
npmPackage: mod.npm_package || mod.npmPackage || null,
|
|
356
|
+
author: mod.author || '',
|
|
357
|
+
license: mod.license || '',
|
|
358
|
+
type: 'community',
|
|
359
|
+
category: mod.category || '',
|
|
360
|
+
subcategory: mod.subcategory || '',
|
|
361
|
+
keywords: mod.keywords || [],
|
|
362
|
+
version: mod.version || null,
|
|
363
|
+
approvedTag: mod.approved_tag || null,
|
|
364
|
+
approvedSha: mod.approved_sha || null,
|
|
365
|
+
approvedDate: mod.approved_date || null,
|
|
366
|
+
reviewer: mod.reviewer || null,
|
|
367
|
+
trustTier: mod.trust_tier || 'unverified',
|
|
368
|
+
promoted: mod.promoted === true,
|
|
369
|
+
promotedRank: mod.promoted_rank || null,
|
|
370
|
+
defaultSelected: false,
|
|
371
|
+
builtIn: false,
|
|
372
|
+
isExternal: true,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { CommunityModuleManager };
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execSync } = require('node:child_process');
|
|
5
|
+
const prompts = require('../prompts');
|
|
6
|
+
const { RegistryClient } = require('./registry-client');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages custom modules installed from user-provided GitHub URLs.
|
|
10
|
+
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
|
|
11
|
+
*/
|
|
12
|
+
class CustomModuleManager {
|
|
13
|
+
constructor() {
|
|
14
|
+
this._client = new RegistryClient();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── URL Validation ───────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse and validate a GitHub repository URL.
|
|
21
|
+
* Supports HTTPS and SSH formats.
|
|
22
|
+
* @param {string} url - GitHub URL to validate
|
|
23
|
+
* @returns {Object} { owner, repo, isValid, error }
|
|
24
|
+
*/
|
|
25
|
+
validateGitHubUrl(url) {
|
|
26
|
+
if (!url || typeof url !== 'string') {
|
|
27
|
+
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const trimmed = url.trim();
|
|
31
|
+
|
|
32
|
+
// HTTPS format: https://github.com/owner/repo[.git]
|
|
33
|
+
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
34
|
+
if (httpsMatch) {
|
|
35
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// SSH format: git@github.com:owner/repo.git
|
|
39
|
+
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
40
|
+
if (sshMatch) {
|
|
41
|
+
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Discovery ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
|
|
51
|
+
* @param {string} repoUrl - GitHub repository URL
|
|
52
|
+
* @returns {Object} Parsed marketplace.json content
|
|
53
|
+
*/
|
|
54
|
+
async fetchMarketplaceJson(repoUrl) {
|
|
55
|
+
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
|
56
|
+
if (!isValid) throw new Error(error);
|
|
57
|
+
|
|
58
|
+
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return await this._client.fetchJson(rawUrl);
|
|
62
|
+
} catch (error_) {
|
|
63
|
+
if (error_.message.includes('404')) {
|
|
64
|
+
throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`);
|
|
65
|
+
}
|
|
66
|
+
if (error_.message.includes('403')) {
|
|
67
|
+
throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`);
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Discover modules from a GitHub repository's marketplace.json.
|
|
75
|
+
* @param {string} repoUrl - GitHub repository URL
|
|
76
|
+
* @returns {Array<Object>} Normalized plugin list
|
|
77
|
+
*/
|
|
78
|
+
async discoverModules(repoUrl) {
|
|
79
|
+
const data = await this.fetchMarketplaceJson(repoUrl);
|
|
80
|
+
const plugins = data?.plugins;
|
|
81
|
+
|
|
82
|
+
if (!Array.isArray(plugins) || plugins.length === 0) {
|
|
83
|
+
throw new Error('marketplace.json contains no plugins');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Clone ────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the cache directory for custom modules.
|
|
93
|
+
* @returns {string} Path to the custom modules cache directory
|
|
94
|
+
*/
|
|
95
|
+
getCacheDir() {
|
|
96
|
+
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Clone a custom module repository to cache.
|
|
101
|
+
* @param {string} repoUrl - GitHub repository URL
|
|
102
|
+
* @param {Object} [options] - Clone options
|
|
103
|
+
* @param {boolean} [options.silent] - Suppress spinner output
|
|
104
|
+
* @returns {string} Path to the cloned repository
|
|
105
|
+
*/
|
|
106
|
+
async cloneRepo(repoUrl, options = {}) {
|
|
107
|
+
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
|
108
|
+
if (!isValid) throw new Error(error);
|
|
109
|
+
|
|
110
|
+
const cacheDir = this.getCacheDir();
|
|
111
|
+
const repoCacheDir = path.join(cacheDir, owner, repo);
|
|
112
|
+
const silent = options.silent || false;
|
|
113
|
+
|
|
114
|
+
await fs.ensureDir(path.join(cacheDir, owner));
|
|
115
|
+
|
|
116
|
+
const createSpinner = async () => {
|
|
117
|
+
if (silent) {
|
|
118
|
+
return { start() {}, stop() {}, error() {} };
|
|
119
|
+
}
|
|
120
|
+
return await prompts.spinner();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (await fs.pathExists(repoCacheDir)) {
|
|
124
|
+
// Update existing clone
|
|
125
|
+
const fetchSpinner = await createSpinner();
|
|
126
|
+
fetchSpinner.start(`Updating ${owner}/${repo}...`);
|
|
127
|
+
try {
|
|
128
|
+
execSync('git fetch origin --depth 1', {
|
|
129
|
+
cwd: repoCacheDir,
|
|
130
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
131
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
132
|
+
});
|
|
133
|
+
execSync('git reset --hard origin/HEAD', {
|
|
134
|
+
cwd: repoCacheDir,
|
|
135
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
136
|
+
});
|
|
137
|
+
fetchSpinner.stop(`Updated ${owner}/${repo}`);
|
|
138
|
+
} catch {
|
|
139
|
+
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
|
|
140
|
+
await fs.remove(repoCacheDir);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!(await fs.pathExists(repoCacheDir))) {
|
|
145
|
+
const fetchSpinner = await createSpinner();
|
|
146
|
+
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
|
|
147
|
+
try {
|
|
148
|
+
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
|
|
149
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
150
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
151
|
+
});
|
|
152
|
+
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
|
|
153
|
+
} catch (error_) {
|
|
154
|
+
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
|
|
155
|
+
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Install dependencies if package.json exists
|
|
160
|
+
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
|
161
|
+
if (await fs.pathExists(packageJsonPath)) {
|
|
162
|
+
const installSpinner = await createSpinner();
|
|
163
|
+
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
|
|
164
|
+
try {
|
|
165
|
+
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
166
|
+
cwd: repoCacheDir,
|
|
167
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
168
|
+
timeout: 120_000,
|
|
169
|
+
});
|
|
170
|
+
installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
|
|
171
|
+
} catch (error_) {
|
|
172
|
+
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
|
|
173
|
+
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return repoCacheDir;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Source Finding ───────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find the module source path within a cloned custom repo.
|
|
184
|
+
* @param {string} repoUrl - GitHub repository URL (for cache location)
|
|
185
|
+
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
|
186
|
+
* @returns {string|null} Path to directory containing module.yaml
|
|
187
|
+
*/
|
|
188
|
+
async findModuleSource(repoUrl, pluginSource) {
|
|
189
|
+
const { owner, repo } = this.validateGitHubUrl(repoUrl);
|
|
190
|
+
const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
|
|
191
|
+
|
|
192
|
+
if (!(await fs.pathExists(repoCacheDir))) return null;
|
|
193
|
+
|
|
194
|
+
// Try plugin source path first (e.g., "./src/pro-skills")
|
|
195
|
+
if (pluginSource) {
|
|
196
|
+
const sourcePath = path.join(repoCacheDir, pluginSource);
|
|
197
|
+
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
|
198
|
+
if (await fs.pathExists(moduleYaml)) {
|
|
199
|
+
return sourcePath;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Fallback: search skills/ and src/ directories
|
|
204
|
+
for (const dir of ['skills', 'src']) {
|
|
205
|
+
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
|
|
206
|
+
if (await fs.pathExists(rootCandidate)) {
|
|
207
|
+
return path.dirname(rootCandidate);
|
|
208
|
+
}
|
|
209
|
+
const dirPath = path.join(repoCacheDir, dir);
|
|
210
|
+
if (await fs.pathExists(dirPath)) {
|
|
211
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
if (entry.isDirectory()) {
|
|
214
|
+
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
|
215
|
+
if (await fs.pathExists(subCandidate)) {
|
|
216
|
+
return path.dirname(subCandidate);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check repo root
|
|
224
|
+
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
|
|
225
|
+
if (await fs.pathExists(rootCandidate)) {
|
|
226
|
+
return repoCacheDir;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Find module source by module code, searching the custom cache.
|
|
234
|
+
* @param {string} moduleCode - Module code to search for
|
|
235
|
+
* @param {Object} [options] - Options
|
|
236
|
+
* @returns {string|null} Path to the module source or null
|
|
237
|
+
*/
|
|
238
|
+
async findModuleSourceByCode(moduleCode, options = {}) {
|
|
239
|
+
const cacheDir = this.getCacheDir();
|
|
240
|
+
if (!(await fs.pathExists(cacheDir))) return null;
|
|
241
|
+
|
|
242
|
+
// Search through all custom repo caches
|
|
243
|
+
try {
|
|
244
|
+
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
|
|
245
|
+
for (const ownerEntry of owners) {
|
|
246
|
+
if (!ownerEntry.isDirectory()) continue;
|
|
247
|
+
const ownerPath = path.join(cacheDir, ownerEntry.name);
|
|
248
|
+
const repos = await fs.readdir(ownerPath, { withFileTypes: true });
|
|
249
|
+
for (const repoEntry of repos) {
|
|
250
|
+
if (!repoEntry.isDirectory()) continue;
|
|
251
|
+
const repoPath = path.join(ownerPath, repoEntry.name);
|
|
252
|
+
|
|
253
|
+
// Check marketplace.json for matching module code
|
|
254
|
+
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
|
255
|
+
if (await fs.pathExists(marketplacePath)) {
|
|
256
|
+
try {
|
|
257
|
+
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
258
|
+
for (const plugin of data.plugins || []) {
|
|
259
|
+
if (plugin.name === moduleCode) {
|
|
260
|
+
// Found the module - find its source
|
|
261
|
+
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
|
|
262
|
+
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
|
263
|
+
if (await fs.pathExists(moduleYaml)) {
|
|
264
|
+
return sourcePath;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
// Skip malformed marketplace.json
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Cache doesn't exist or is inaccessible
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Normalization ────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Normalize a plugin from marketplace.json to a consistent shape.
|
|
285
|
+
* @param {Object} plugin - Plugin object from marketplace.json
|
|
286
|
+
* @param {string} repoUrl - Source repository URL
|
|
287
|
+
* @param {Object} data - Full marketplace.json data
|
|
288
|
+
* @returns {Object} Normalized module info
|
|
289
|
+
*/
|
|
290
|
+
_normalizeCustomModule(plugin, repoUrl, data) {
|
|
291
|
+
return {
|
|
292
|
+
code: plugin.name,
|
|
293
|
+
name: plugin.name,
|
|
294
|
+
displayName: plugin.name,
|
|
295
|
+
description: plugin.description || '',
|
|
296
|
+
version: plugin.version || null,
|
|
297
|
+
author: plugin.author || data.owner || '',
|
|
298
|
+
url: repoUrl,
|
|
299
|
+
source: plugin.source || null,
|
|
300
|
+
type: 'custom',
|
|
301
|
+
trustTier: 'unverified',
|
|
302
|
+
builtIn: false,
|
|
303
|
+
isExternal: true,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = { CustomModuleManager };
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
const fs = require('fs-extra');
|
|
2
2
|
const os = require('node:os');
|
|
3
3
|
const path = require('node:path');
|
|
4
|
-
const https = require('node:https');
|
|
5
4
|
const { execSync } = require('node:child_process');
|
|
6
5
|
const yaml = require('yaml');
|
|
7
6
|
const prompts = require('../prompts');
|
|
7
|
+
const { RegistryClient } = require('./registry-client');
|
|
8
8
|
|
|
9
9
|
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
|
10
10
|
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
|
@@ -17,35 +17,8 @@ const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
|
|
17
17
|
* @class ExternalModuleManager
|
|
18
18
|
*/
|
|
19
19
|
class ExternalModuleManager {
|
|
20
|
-
constructor() {
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Fetch a URL and return the response body as a string.
|
|
24
|
-
* @param {string} url - URL to fetch
|
|
25
|
-
* @param {number} timeout - Timeout in ms (default 10s)
|
|
26
|
-
* @returns {Promise<string>} Response body
|
|
27
|
-
*/
|
|
28
|
-
_fetch(url, timeout = 10_000) {
|
|
29
|
-
return new Promise((resolve, reject) => {
|
|
30
|
-
const req = https
|
|
31
|
-
.get(url, { timeout }, (res) => {
|
|
32
|
-
// Follow one redirect (GitHub sometimes 301s)
|
|
33
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
34
|
-
return this._fetch(res.headers.location, timeout).then(resolve, reject);
|
|
35
|
-
}
|
|
36
|
-
if (res.statusCode !== 200) {
|
|
37
|
-
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
38
|
-
}
|
|
39
|
-
let data = '';
|
|
40
|
-
res.on('data', (chunk) => (data += chunk));
|
|
41
|
-
res.on('end', () => resolve(data));
|
|
42
|
-
})
|
|
43
|
-
.on('error', reject)
|
|
44
|
-
.on('timeout', () => {
|
|
45
|
-
req.destroy();
|
|
46
|
-
reject(new Error('Request timed out'));
|
|
47
|
-
});
|
|
48
|
-
});
|
|
20
|
+
constructor() {
|
|
21
|
+
this._client = new RegistryClient();
|
|
49
22
|
}
|
|
50
23
|
|
|
51
24
|
/**
|
|
@@ -60,7 +33,7 @@ class ExternalModuleManager {
|
|
|
60
33
|
|
|
61
34
|
// Try remote registry first
|
|
62
35
|
try {
|
|
63
|
-
const content = await this.
|
|
36
|
+
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
|
64
37
|
const config = yaml.parse(content);
|
|
65
38
|
if (config?.modules?.length) {
|
|
66
39
|
this.cachedModules = config;
|
|
@@ -202,6 +202,22 @@ class OfficialModules {
|
|
|
202
202
|
return externalSource;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// Check community modules
|
|
206
|
+
const { CommunityModuleManager } = require('./community-manager');
|
|
207
|
+
const communityMgr = new CommunityModuleManager();
|
|
208
|
+
const communitySource = await communityMgr.findModuleSource(moduleCode, options);
|
|
209
|
+
if (communitySource) {
|
|
210
|
+
return communitySource;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check custom modules (from user-provided URLs, already cloned to cache)
|
|
214
|
+
const { CustomModuleManager } = require('./custom-module-manager');
|
|
215
|
+
const customMgr = new CustomModuleManager();
|
|
216
|
+
const customSource = await customMgr.findModuleSourceByCode(moduleCode, options);
|
|
217
|
+
if (customSource) {
|
|
218
|
+
return customSource;
|
|
219
|
+
}
|
|
220
|
+
|
|
205
221
|
return null;
|
|
206
222
|
}
|
|
207
223
|
|
|
@@ -1131,7 +1147,13 @@ class OfficialModules {
|
|
|
1131
1147
|
// Collect all answers (static + prompted)
|
|
1132
1148
|
let allAnswers = { ...staticAnswers };
|
|
1133
1149
|
|
|
1134
|
-
if (questions.length > 0) {
|
|
1150
|
+
if (questions.length > 0 && silentMode) {
|
|
1151
|
+
// In silent mode (quick update), use defaults for new fields instead of prompting
|
|
1152
|
+
for (const q of questions) {
|
|
1153
|
+
allAnswers[q.name] = typeof q.default === 'function' ? q.default({}) : q.default;
|
|
1154
|
+
}
|
|
1155
|
+
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured with defaults`);
|
|
1156
|
+
} else if (questions.length > 0) {
|
|
1135
1157
|
// Only show header if we actually have questions
|
|
1136
1158
|
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
|
1137
1159
|
await prompts.log.message('');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const https = require('node:https');
|
|
2
|
+
const yaml = require('yaml');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared HTTP client for fetching registry data from GitHub.
|
|
6
|
+
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
|
|
7
|
+
*/
|
|
8
|
+
class RegistryClient {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.timeout = options.timeout || 10_000;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetch a URL and return the response body as a string.
|
|
15
|
+
* Follows one redirect (GitHub sometimes 301s).
|
|
16
|
+
* @param {string} url - URL to fetch
|
|
17
|
+
* @param {number} [timeout] - Timeout in ms (overrides default)
|
|
18
|
+
* @returns {Promise<string>} Response body
|
|
19
|
+
*/
|
|
20
|
+
fetch(url, timeout) {
|
|
21
|
+
const timeoutMs = timeout || this.timeout;
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const req = https
|
|
24
|
+
.get(url, { timeout: timeoutMs }, (res) => {
|
|
25
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
26
|
+
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
|
|
27
|
+
}
|
|
28
|
+
if (res.statusCode !== 200) {
|
|
29
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
30
|
+
}
|
|
31
|
+
let data = '';
|
|
32
|
+
res.on('data', (chunk) => (data += chunk));
|
|
33
|
+
res.on('end', () => resolve(data));
|
|
34
|
+
})
|
|
35
|
+
.on('error', reject)
|
|
36
|
+
.on('timeout', () => {
|
|
37
|
+
req.destroy();
|
|
38
|
+
reject(new Error('Request timed out'));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch a URL and parse the response as YAML.
|
|
45
|
+
* @param {string} url - URL to fetch
|
|
46
|
+
* @param {number} [timeout] - Timeout in ms
|
|
47
|
+
* @returns {Promise<Object>} Parsed YAML content
|
|
48
|
+
*/
|
|
49
|
+
async fetchYaml(url, timeout) {
|
|
50
|
+
const content = await this.fetch(url, timeout);
|
|
51
|
+
return yaml.parse(content);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch a URL and parse the response as JSON.
|
|
56
|
+
* @param {string} url - URL to fetch
|
|
57
|
+
* @param {number} [timeout] - Timeout in ms
|
|
58
|
+
* @returns {Promise<Object>} Parsed JSON content
|
|
59
|
+
*/
|
|
60
|
+
async fetchJson(url, timeout) {
|
|
61
|
+
const content = await this.fetch(url, timeout);
|
|
62
|
+
return JSON.parse(content);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { RegistryClient };
|
package/tools/installer/ui.js
CHANGED
|
@@ -563,22 +563,58 @@ class UI {
|
|
|
563
563
|
}
|
|
564
564
|
|
|
565
565
|
/**
|
|
566
|
-
* Select all modules
|
|
567
|
-
* Core is shown as locked but filtered from the result since it's always installed separately.
|
|
566
|
+
* Select all modules across three tiers: official, community, and custom URL.
|
|
568
567
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
569
568
|
* @returns {Array} Selected module codes (excluding core)
|
|
570
569
|
*/
|
|
571
570
|
async selectAllModules(installedModuleIds = new Set()) {
|
|
572
|
-
//
|
|
571
|
+
// Phase 1: Official modules
|
|
572
|
+
const officialSelected = await this._selectOfficialModules(installedModuleIds);
|
|
573
|
+
|
|
574
|
+
// Determine which installed modules are NOT official (community or custom).
|
|
575
|
+
// These must be preserved even if the user declines to browse community/custom.
|
|
576
|
+
const officialCodes = new Set(officialSelected);
|
|
577
|
+
const externalManager = new ExternalModuleManager();
|
|
578
|
+
const registryModules = await externalManager.listAvailable();
|
|
579
|
+
const officialRegistryCodes = new Set(registryModules.map((m) => m.code));
|
|
580
|
+
const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
|
|
581
|
+
|
|
582
|
+
// Phase 2: Community modules (category drill-down)
|
|
583
|
+
// Returns { codes, didBrowse } so we know if the user entered the flow
|
|
584
|
+
const communityResult = await this._browseCommunityModules(installedModuleIds);
|
|
585
|
+
|
|
586
|
+
// Phase 3: Custom URL modules
|
|
587
|
+
const customSelected = await this._addCustomUrlModules(installedModuleIds);
|
|
588
|
+
|
|
589
|
+
// Merge all selections
|
|
590
|
+
const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]);
|
|
591
|
+
|
|
592
|
+
// Auto-include installed non-official modules that the user didn't get
|
|
593
|
+
// a chance to manage (they declined to browse). If they did browse,
|
|
594
|
+
// trust their selections - they could have deselected intentionally.
|
|
595
|
+
if (!communityResult.didBrowse) {
|
|
596
|
+
for (const code of installedNonOfficial) {
|
|
597
|
+
allSelected.add(code);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return [...allSelected];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Select official modules using autocompleteMultiselect.
|
|
606
|
+
* Extracted from the original selectAllModules - unchanged behavior.
|
|
607
|
+
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
608
|
+
* @returns {Array} Selected official module codes
|
|
609
|
+
*/
|
|
610
|
+
async _selectOfficialModules(installedModuleIds = new Set()) {
|
|
573
611
|
const externalManager = new ExternalModuleManager();
|
|
574
612
|
const registryModules = await externalManager.listAvailable();
|
|
575
613
|
|
|
576
|
-
// Build flat options list with group hints for autocompleteMultiselect
|
|
577
614
|
const allOptions = [];
|
|
578
615
|
const initialValues = [];
|
|
579
616
|
const lockedValues = ['core'];
|
|
580
617
|
|
|
581
|
-
// Helper to build module entry with proper sorting and selection
|
|
582
618
|
const buildModuleEntry = async (mod) => {
|
|
583
619
|
const isInstalled = installedModuleIds.has(mod.code);
|
|
584
620
|
const version = await getMarketplaceVersion(mod.code);
|
|
@@ -591,7 +627,6 @@ class UI {
|
|
|
591
627
|
};
|
|
592
628
|
};
|
|
593
629
|
|
|
594
|
-
// Registry order is display order; core is always locked
|
|
595
630
|
for (const mod of registryModules) {
|
|
596
631
|
const entry = await buildModuleEntry(mod);
|
|
597
632
|
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
|
|
@@ -601,7 +636,7 @@ class UI {
|
|
|
601
636
|
}
|
|
602
637
|
|
|
603
638
|
const selected = await prompts.autocompleteMultiselect({
|
|
604
|
-
message: 'Select modules to install:',
|
|
639
|
+
message: 'Select official modules to install:',
|
|
605
640
|
options: allOptions,
|
|
606
641
|
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
|
607
642
|
lockedValues,
|
|
@@ -611,18 +646,261 @@ class UI {
|
|
|
611
646
|
|
|
612
647
|
const result = selected ? [...selected] : [];
|
|
613
648
|
|
|
614
|
-
// Display selected modules as bulleted list
|
|
615
649
|
if (result.length > 0) {
|
|
616
650
|
const moduleLines = result.map((moduleId) => {
|
|
617
651
|
const opt = allOptions.find((o) => o.value === moduleId);
|
|
618
652
|
return ` \u2022 ${opt?.label || moduleId}`;
|
|
619
653
|
});
|
|
620
|
-
await prompts.log.message('Selected modules:\n' + moduleLines.join('\n'));
|
|
654
|
+
await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n'));
|
|
621
655
|
}
|
|
622
656
|
|
|
623
657
|
return result;
|
|
624
658
|
}
|
|
625
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Browse and select community modules using category drill-down.
|
|
662
|
+
* Featured/promoted modules appear at the top.
|
|
663
|
+
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
664
|
+
* @returns {Object} { codes: string[], didBrowse: boolean }
|
|
665
|
+
*/
|
|
666
|
+
async _browseCommunityModules(installedModuleIds = new Set()) {
|
|
667
|
+
const browseCommunity = await prompts.confirm({
|
|
668
|
+
message: 'Would you like to browse community modules?',
|
|
669
|
+
default: false,
|
|
670
|
+
});
|
|
671
|
+
if (!browseCommunity) return { codes: [], didBrowse: false };
|
|
672
|
+
|
|
673
|
+
const { CommunityModuleManager } = require('./modules/community-manager');
|
|
674
|
+
const communityMgr = new CommunityModuleManager();
|
|
675
|
+
|
|
676
|
+
const s = await prompts.spinner();
|
|
677
|
+
s.start('Loading community module catalog...');
|
|
678
|
+
|
|
679
|
+
let categories, featured, allCommunity;
|
|
680
|
+
try {
|
|
681
|
+
[categories, featured, allCommunity] = await Promise.all([
|
|
682
|
+
communityMgr.getCategoryList(),
|
|
683
|
+
communityMgr.listFeatured(),
|
|
684
|
+
communityMgr.listAll(),
|
|
685
|
+
]);
|
|
686
|
+
s.stop(`Community catalog loaded (${allCommunity.length} modules)`);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
s.error('Failed to load community catalog');
|
|
689
|
+
await prompts.log.warn(` ${error.message}`);
|
|
690
|
+
return { codes: [], didBrowse: false };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (allCommunity.length === 0) {
|
|
694
|
+
await prompts.log.info('No community modules are currently available.');
|
|
695
|
+
return { codes: [], didBrowse: false };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const selectedCodes = new Set();
|
|
699
|
+
let browsing = true;
|
|
700
|
+
|
|
701
|
+
while (browsing) {
|
|
702
|
+
const categoryChoices = [];
|
|
703
|
+
|
|
704
|
+
// Featured section at top
|
|
705
|
+
if (featured.length > 0) {
|
|
706
|
+
categoryChoices.push({
|
|
707
|
+
value: '__featured__',
|
|
708
|
+
label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Categories with module counts
|
|
713
|
+
for (const cat of categories) {
|
|
714
|
+
categoryChoices.push({
|
|
715
|
+
value: cat.slug,
|
|
716
|
+
label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Special actions at bottom
|
|
721
|
+
categoryChoices.push(
|
|
722
|
+
{ value: '__all__', label: '\u25CE View all community modules' },
|
|
723
|
+
{ value: '__search__', label: '\u25CE Search by keyword' },
|
|
724
|
+
{ value: '__done__', label: '\u2713 Done browsing' },
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const selectedCount = selectedCodes.size;
|
|
728
|
+
const categoryChoice = await prompts.select({
|
|
729
|
+
message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`,
|
|
730
|
+
choices: categoryChoices,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
if (categoryChoice === '__done__') {
|
|
734
|
+
browsing = false;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let modulesToShow;
|
|
739
|
+
switch (categoryChoice) {
|
|
740
|
+
case '__featured__': {
|
|
741
|
+
modulesToShow = featured;
|
|
742
|
+
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
case '__all__': {
|
|
746
|
+
modulesToShow = allCommunity;
|
|
747
|
+
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case '__search__': {
|
|
751
|
+
const query = await prompts.text({
|
|
752
|
+
message: 'Search community modules:',
|
|
753
|
+
placeholder: 'e.g., design, testing, game',
|
|
754
|
+
});
|
|
755
|
+
if (!query || query.trim() === '') continue;
|
|
756
|
+
modulesToShow = await communityMgr.searchByKeyword(query.trim());
|
|
757
|
+
if (modulesToShow.length === 0) {
|
|
758
|
+
await prompts.log.warn('No matching modules found.');
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
default: {
|
|
765
|
+
modulesToShow = await communityMgr.listByCategory(categoryChoice);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Build options for autocompleteMultiselect
|
|
770
|
+
const trustBadge = (tier) => {
|
|
771
|
+
if (tier === 'bmad-certified') return '\u2713';
|
|
772
|
+
if (tier === 'community-reviewed') return '\u25CB';
|
|
773
|
+
return '\u26A0';
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const options = modulesToShow.map((mod) => {
|
|
777
|
+
const versionStr = mod.version ? ` (v${mod.version})` : '';
|
|
778
|
+
const badge = trustBadge(mod.trustTier);
|
|
779
|
+
return {
|
|
780
|
+
label: `${mod.displayName}${versionStr} [${badge}]`,
|
|
781
|
+
value: mod.code,
|
|
782
|
+
hint: mod.description,
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// Pre-check modules that are already selected or installed
|
|
787
|
+
const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code);
|
|
788
|
+
|
|
789
|
+
const selected = await prompts.autocompleteMultiselect({
|
|
790
|
+
message: 'Select community modules:',
|
|
791
|
+
options,
|
|
792
|
+
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
|
793
|
+
required: false,
|
|
794
|
+
maxItems: Math.min(options.length, 10),
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Update accumulated selections: sync with what user selected in this view
|
|
798
|
+
const shownCodes = new Set(modulesToShow.map((m) => m.code));
|
|
799
|
+
for (const code of shownCodes) {
|
|
800
|
+
if (selected && selected.includes(code)) {
|
|
801
|
+
selectedCodes.add(code);
|
|
802
|
+
} else {
|
|
803
|
+
selectedCodes.delete(code);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (selectedCodes.size > 0) {
|
|
809
|
+
const moduleLines = [];
|
|
810
|
+
for (const code of selectedCodes) {
|
|
811
|
+
const mod = await communityMgr.getModuleByCode(code);
|
|
812
|
+
moduleLines.push(` \u2022 ${mod?.displayName || code}`);
|
|
813
|
+
}
|
|
814
|
+
await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n'));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return { codes: [...selectedCodes], didBrowse: true };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Prompt user to install modules from custom GitHub URLs.
|
|
822
|
+
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
823
|
+
* @returns {Array} Selected custom module code strings
|
|
824
|
+
*/
|
|
825
|
+
async _addCustomUrlModules(installedModuleIds = new Set()) {
|
|
826
|
+
const addCustom = await prompts.confirm({
|
|
827
|
+
message: 'Would you like to install from a custom GitHub URL?',
|
|
828
|
+
default: false,
|
|
829
|
+
});
|
|
830
|
+
if (!addCustom) return [];
|
|
831
|
+
|
|
832
|
+
const { CustomModuleManager } = require('./modules/custom-module-manager');
|
|
833
|
+
const customMgr = new CustomModuleManager();
|
|
834
|
+
const selectedModules = [];
|
|
835
|
+
|
|
836
|
+
let addMore = true;
|
|
837
|
+
while (addMore) {
|
|
838
|
+
const url = await prompts.text({
|
|
839
|
+
message: 'GitHub repository URL:',
|
|
840
|
+
placeholder: 'https://github.com/owner/repo',
|
|
841
|
+
validate: (input) => {
|
|
842
|
+
if (!input || input.trim() === '') return 'URL is required';
|
|
843
|
+
const result = customMgr.validateGitHubUrl(input.trim());
|
|
844
|
+
return result.isValid ? undefined : result.error;
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const s = await prompts.spinner();
|
|
849
|
+
s.start('Fetching module info...');
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
const plugins = await customMgr.discoverModules(url.trim());
|
|
853
|
+
s.stop('Module info loaded');
|
|
854
|
+
|
|
855
|
+
await prompts.log.warn(
|
|
856
|
+
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
for (const plugin of plugins) {
|
|
860
|
+
const versionStr = plugin.version ? ` v${plugin.version}` : '';
|
|
861
|
+
await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const confirmInstall = await prompts.confirm({
|
|
865
|
+
message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`,
|
|
866
|
+
default: false,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
if (confirmInstall) {
|
|
870
|
+
// Pre-clone the repo so it's cached for the install pipeline
|
|
871
|
+
s.start('Cloning repository...');
|
|
872
|
+
try {
|
|
873
|
+
await customMgr.cloneRepo(url.trim());
|
|
874
|
+
s.stop('Repository cloned');
|
|
875
|
+
} catch (cloneError) {
|
|
876
|
+
s.error('Failed to clone repository');
|
|
877
|
+
await prompts.log.error(` ${cloneError.message}`);
|
|
878
|
+
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
for (const plugin of plugins) {
|
|
883
|
+
selectedModules.push(plugin.code);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
} catch (error) {
|
|
887
|
+
s.error('Failed to load module info');
|
|
888
|
+
await prompts.log.error(` ${error.message}`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
addMore = await prompts.confirm({
|
|
892
|
+
message: 'Add another custom module?',
|
|
893
|
+
default: false,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (selectedModules.length > 0) {
|
|
898
|
+
await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n'));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return selectedModules;
|
|
902
|
+
}
|
|
903
|
+
|
|
626
904
|
/**
|
|
627
905
|
* Get default modules for non-interactive mode
|
|
628
906
|
* @param {Set} installedModuleIds - Already installed module IDs
|
|
@@ -946,6 +1224,7 @@ class UI {
|
|
|
946
1224
|
// Group modules by source
|
|
947
1225
|
const builtIn = modules.filter((m) => m.source === 'built-in');
|
|
948
1226
|
const external = modules.filter((m) => m.source === 'external');
|
|
1227
|
+
const community = modules.filter((m) => m.source === 'community');
|
|
949
1228
|
const custom = modules.filter((m) => m.source === 'custom');
|
|
950
1229
|
const unknown = modules.filter((m) => m.source === 'unknown');
|
|
951
1230
|
|
|
@@ -966,6 +1245,7 @@ class UI {
|
|
|
966
1245
|
|
|
967
1246
|
formatGroup(builtIn, 'Built-in Modules');
|
|
968
1247
|
formatGroup(external, 'External Modules (Official)');
|
|
1248
|
+
formatGroup(community, 'Community Modules');
|
|
969
1249
|
formatGroup(custom, 'Custom Modules');
|
|
970
1250
|
formatGroup(unknown, 'Other Modules');
|
|
971
1251
|
|