bmad-method 6.2.3-next.26 → 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/ide/_config-driven.js +14 -7
- 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 +65 -49
- package/tools/installer/modules/official-modules.js +23 -1
- package/tools/installer/modules/registry-client.js +66 -0
- package/tools/installer/{external-official-modules.yaml → modules/registry-fallback.yaml} +3 -12
- package/tools/installer/ui.js +304 -67
|
@@ -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 };
|
|
@@ -4,64 +4,98 @@ const path = require('node:path');
|
|
|
4
4
|
const { execSync } = require('node:child_process');
|
|
5
5
|
const yaml = require('yaml');
|
|
6
6
|
const prompts = require('../prompts');
|
|
7
|
+
const { RegistryClient } = require('./registry-client');
|
|
8
|
+
|
|
9
|
+
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
|
10
|
+
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
|
-
* Manages
|
|
10
|
-
*
|
|
13
|
+
* Manages official modules from the remote BMad marketplace registry.
|
|
14
|
+
* Fetches registry/official.yaml from GitHub; falls back to the bundled
|
|
15
|
+
* external-official-modules.yaml when the network is unavailable.
|
|
11
16
|
*
|
|
12
17
|
* @class ExternalModuleManager
|
|
13
18
|
*/
|
|
14
19
|
class ExternalModuleManager {
|
|
15
20
|
constructor() {
|
|
16
|
-
this.
|
|
17
|
-
this.cachedModules = null;
|
|
21
|
+
this._client = new RegistryClient();
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
|
-
* Load
|
|
22
|
-
*
|
|
25
|
+
* Load the official modules registry from GitHub, falling back to the
|
|
26
|
+
* bundled YAML file if the fetch fails.
|
|
27
|
+
* @returns {Object} Parsed YAML content with modules array
|
|
23
28
|
*/
|
|
24
29
|
async loadExternalModulesConfig() {
|
|
25
30
|
if (this.cachedModules) {
|
|
26
31
|
return this.cachedModules;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
// Try remote registry first
|
|
35
|
+
try {
|
|
36
|
+
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
|
37
|
+
const config = yaml.parse(content);
|
|
38
|
+
if (config?.modules?.length) {
|
|
39
|
+
this.cachedModules = config;
|
|
40
|
+
return config;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Fall through to local fallback
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fallback to bundled file
|
|
29
47
|
try {
|
|
30
|
-
const content = await fs.readFile(
|
|
48
|
+
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
|
31
49
|
const config = yaml.parse(content);
|
|
32
50
|
this.cachedModules = config;
|
|
51
|
+
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
|
|
33
52
|
return config;
|
|
34
53
|
} catch (error) {
|
|
35
|
-
await prompts.log.warn(`Failed to load
|
|
36
|
-
return { modules:
|
|
54
|
+
await prompts.log.warn(`Failed to load modules config: ${error.message}`);
|
|
55
|
+
return { modules: [] };
|
|
37
56
|
}
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
/**
|
|
41
|
-
*
|
|
60
|
+
* Normalize a module entry from either the remote registry format
|
|
61
|
+
* (snake_case, array) or the legacy bundled format (kebab-case, object map).
|
|
62
|
+
* @param {Object} mod - Raw module config from YAML
|
|
63
|
+
* @param {string} [key] - Key name (only for legacy map format)
|
|
64
|
+
* @returns {Object} Normalized module info
|
|
65
|
+
*/
|
|
66
|
+
_normalizeModule(mod, key) {
|
|
67
|
+
return {
|
|
68
|
+
key: key || mod.name,
|
|
69
|
+
url: mod.repository || mod.url,
|
|
70
|
+
moduleDefinition: mod.module_definition || mod['module-definition'],
|
|
71
|
+
code: mod.code,
|
|
72
|
+
name: mod.display_name || mod.name,
|
|
73
|
+
description: mod.description || '',
|
|
74
|
+
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
|
75
|
+
type: mod.type || 'bmad-org',
|
|
76
|
+
npmPackage: mod.npm_package || mod.npmPackage || null,
|
|
77
|
+
builtIn: mod.built_in === true,
|
|
78
|
+
isExternal: mod.built_in !== true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get list of available modules from the registry
|
|
42
84
|
* @returns {Array<Object>} Array of module info objects
|
|
43
85
|
*/
|
|
44
86
|
async listAvailable() {
|
|
45
87
|
const config = await this.loadExternalModulesConfig();
|
|
46
|
-
const modules = [];
|
|
47
88
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
url: moduleConfig.url,
|
|
52
|
-
moduleDefinition: moduleConfig['module-definition'],
|
|
53
|
-
code: moduleConfig.code,
|
|
54
|
-
name: moduleConfig.name,
|
|
55
|
-
header: moduleConfig.header,
|
|
56
|
-
subheader: moduleConfig.subheader,
|
|
57
|
-
description: moduleConfig.description || '',
|
|
58
|
-
defaultSelected: moduleConfig.defaultSelected === true,
|
|
59
|
-
type: moduleConfig.type || 'community', // bmad-org or community
|
|
60
|
-
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
61
|
-
isExternal: true,
|
|
62
|
-
});
|
|
89
|
+
// Remote format: modules is an array
|
|
90
|
+
if (Array.isArray(config.modules)) {
|
|
91
|
+
return config.modules.map((mod) => this._normalizeModule(mod));
|
|
63
92
|
}
|
|
64
93
|
|
|
94
|
+
// Legacy bundled format: modules is an object map
|
|
95
|
+
const modules = [];
|
|
96
|
+
for (const [key, mod] of Object.entries(config.modules || {})) {
|
|
97
|
+
modules.push(this._normalizeModule(mod, key));
|
|
98
|
+
}
|
|
65
99
|
return modules;
|
|
66
100
|
}
|
|
67
101
|
|
|
@@ -81,27 +115,8 @@ class ExternalModuleManager {
|
|
|
81
115
|
* @returns {Object|null} Module info or null if not found
|
|
82
116
|
*/
|
|
83
117
|
async getModuleByKey(key) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (!moduleConfig) {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
key,
|
|
93
|
-
url: moduleConfig.url,
|
|
94
|
-
moduleDefinition: moduleConfig['module-definition'],
|
|
95
|
-
code: moduleConfig.code,
|
|
96
|
-
name: moduleConfig.name,
|
|
97
|
-
header: moduleConfig.header,
|
|
98
|
-
subheader: moduleConfig.subheader,
|
|
99
|
-
description: moduleConfig.description || '',
|
|
100
|
-
defaultSelected: moduleConfig.defaultSelected === true,
|
|
101
|
-
type: moduleConfig.type || 'community', // bmad-org or community
|
|
102
|
-
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
103
|
-
isExternal: true,
|
|
104
|
-
};
|
|
118
|
+
const modules = await this.listAvailable();
|
|
119
|
+
return modules.find((m) => m.key === key) || null;
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
/**
|
|
@@ -154,7 +169,7 @@ class ExternalModuleManager {
|
|
|
154
169
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
|
155
170
|
|
|
156
171
|
if (!moduleInfo) {
|
|
157
|
-
throw new Error(`External module '${moduleCode}' not found in
|
|
172
|
+
throw new Error(`External module '${moduleCode}' not found in the BMad registry`);
|
|
158
173
|
}
|
|
159
174
|
|
|
160
175
|
const cacheDir = this.getExternalCacheDir();
|
|
@@ -304,7 +319,7 @@ class ExternalModuleManager {
|
|
|
304
319
|
async findExternalModuleSource(moduleCode, options = {}) {
|
|
305
320
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
|
306
321
|
|
|
307
|
-
if (!moduleInfo) {
|
|
322
|
+
if (!moduleInfo || moduleInfo.builtIn) {
|
|
308
323
|
return null;
|
|
309
324
|
}
|
|
310
325
|
|
|
@@ -349,6 +364,7 @@ class ExternalModuleManager {
|
|
|
349
364
|
// Nothing found: return configured path (preserves old behavior for error messaging)
|
|
350
365
|
return path.dirname(configuredPath);
|
|
351
366
|
}
|
|
367
|
+
cachedModules = null;
|
|
352
368
|
}
|
|
353
369
|
|
|
354
370
|
module.exports = { ExternalModuleManager };
|
|
@@ -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 };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
1
|
+
# Fallback module registry — used only when the BMad Marketplace repo
|
|
2
|
+
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
|
|
3
|
+
# The remote registry/official.yaml is the source of truth.
|
|
3
4
|
|
|
4
5
|
modules:
|
|
5
6
|
bmad-builder:
|
|
@@ -41,13 +42,3 @@ modules:
|
|
|
41
42
|
defaultSelected: false
|
|
42
43
|
type: bmad-org
|
|
43
44
|
npmPackage: bmad-method-test-architecture-enterprise
|
|
44
|
-
|
|
45
|
-
whiteport-design-studio:
|
|
46
|
-
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
|
|
47
|
-
module-definition: src/module.yaml
|
|
48
|
-
code: wds
|
|
49
|
-
name: "Whiteport Design Studio (For UX Professionals)"
|
|
50
|
-
description: "Whiteport Design Studio (For UX Professionals)"
|
|
51
|
-
defaultSelected: false
|
|
52
|
-
type: community
|
|
53
|
-
npmPackage: bmad-method-wds-expansion
|