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
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 {
|
|
@@ -225,13 +225,20 @@ class ConfigDrivenIdeSetup {
|
|
|
225
225
|
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
|
|
226
226
|
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
|
|
227
227
|
if (this.installerConfig?.legacy_targets) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
228
|
+
const legacyDirsExist = await Promise.all(
|
|
229
|
+
this.installerConfig.legacy_targets.map((d) =>
|
|
230
|
+
this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
if (legacyDirsExist.some(Boolean)) {
|
|
234
|
+
if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
|
|
235
|
+
for (const legacyDir of this.installerConfig.legacy_targets) {
|
|
236
|
+
if (this.isGlobalPath(legacyDir)) {
|
|
237
|
+
await this.warnGlobalLegacy(legacyDir, options);
|
|
238
|
+
} else {
|
|
239
|
+
await this.cleanupTarget(projectDir, legacyDir, options, null);
|
|
240
|
+
await this.removeEmptyParents(projectDir, legacyDir);
|
|
241
|
+
}
|
|
235
242
|
}
|
|
236
243
|
}
|
|
237
244
|
}
|
|
@@ -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 };
|