feat(installer): community module browser and custom URL support (#2229)
* feat(installer): add community module browser and custom URL support Three-tier module selection: official, community (category drill-down with featured/search), and custom GitHub URL. - Add RegistryClient shared fetch utility - Add CommunityModuleManager with SHA-pinned cloning (refuses install if approved SHA cannot be reached; uses HEAD when no SHA set) - Add CustomModuleManager for arbitrary GitHub repo installation - Extend findModuleSource chain with community and custom fallthrough - Extend manifest to detect community and custom source types - Add Config.customModulesMeta for custom module metadata * fix: resolve review findings for community/custom module support - Remove redundant CommunityModuleManager instantiation in UI display - Remove dead customModulesMeta field from Config (never populated) - Add 35 unit tests for CustomModuleManager and CommunityModuleManager pure functions: URL validation, normalization, search, featured, categories * fix: preserve installed community/custom modules in modify flow When a user does "Modify Installation" and declines to browse community modules, previously installed community/custom modules are now auto-kept. If the user does browse, their selections are trusted (they can deselect). Also fix stale docs: class doc for SHA pinning, JSDoc return type. * fix: include community and custom modules in quick update Quick update now checks community registry and custom cache so installed community/custom modules are updated instead of skipped. * fix: use defaults for new config fields during quick update When quick update encounters new config fields (e.g., from a newly supported community module), use schema defaults silently instead of prompting the user. Quick update should be non-interactive. * test: add unit tests for SHA pinning, category filtering, and URL edge cases Cover SHA normalization (set vs null/trusted), listByCategory, getModuleByCode, and URL validation edge cases (HTTP, trailing slash, SSH without .git). Total: 243 tests.
This commit is contained in:
parent
5e038a8ce4
commit
b744408783
|
|
@ -1723,6 +1723,258 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 33: Community & Custom Module Managers
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
|
||||||
|
|
||||||
|
// --- CustomModuleManager.validateGitHubUrl ---
|
||||||
|
{
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const mgr = new CustomModuleManager();
|
||||||
|
|
||||||
|
const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo');
|
||||||
|
assert(https1.isValid === true, 'validateGitHubUrl accepts HTTPS URL');
|
||||||
|
assert(https1.owner === 'owner' && https1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from HTTPS');
|
||||||
|
|
||||||
|
const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git');
|
||||||
|
assert(https2.isValid === true, 'validateGitHubUrl accepts HTTPS URL with .git');
|
||||||
|
assert(https2.repo === 'repo', 'validateGitHubUrl strips .git suffix');
|
||||||
|
|
||||||
|
const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git');
|
||||||
|
assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL');
|
||||||
|
assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH');
|
||||||
|
|
||||||
|
const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo');
|
||||||
|
assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL');
|
||||||
|
|
||||||
|
const bad2 = mgr.validateGitHubUrl('');
|
||||||
|
assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string');
|
||||||
|
|
||||||
|
const bad3 = mgr.validateGitHubUrl(null);
|
||||||
|
assert(bad3.isValid === false, 'validateGitHubUrl rejects null');
|
||||||
|
|
||||||
|
const bad4 = mgr.validateGitHubUrl('https://github.com/owner');
|
||||||
|
assert(bad4.isValid === false, 'validateGitHubUrl rejects URL without repo');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CustomModuleManager._normalizeCustomModule ---
|
||||||
|
{
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const mgr = new CustomModuleManager();
|
||||||
|
|
||||||
|
const plugin = { name: 'test-plugin', description: 'A test', version: '1.0.0', author: 'tester', source: './src' };
|
||||||
|
const data = { owner: 'Fallback Owner' };
|
||||||
|
const result = mgr._normalizeCustomModule(plugin, 'https://github.com/o/r', data);
|
||||||
|
|
||||||
|
assert(result.code === 'test-plugin', 'normalizeCustomModule sets code from plugin name');
|
||||||
|
assert(result.type === 'custom', 'normalizeCustomModule sets type to custom');
|
||||||
|
assert(result.trustTier === 'unverified', 'normalizeCustomModule sets trustTier to unverified');
|
||||||
|
assert(result.version === '1.0.0', 'normalizeCustomModule preserves version');
|
||||||
|
assert(result.author === 'tester', 'normalizeCustomModule uses plugin author over data.owner');
|
||||||
|
|
||||||
|
const pluginNoAuthor = { name: 'x', description: '', version: null };
|
||||||
|
const result2 = mgr._normalizeCustomModule(pluginNoAuthor, 'https://github.com/o/r', data);
|
||||||
|
assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager._normalizeCommunityModule ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
const mod = {
|
||||||
|
name: 'test-mod',
|
||||||
|
display_name: 'Test Module',
|
||||||
|
code: 'tm',
|
||||||
|
description: 'desc',
|
||||||
|
repository: 'https://github.com/o/r',
|
||||||
|
module_definition: 'src/module.yaml',
|
||||||
|
category: 'software-development',
|
||||||
|
subcategory: 'dev-tools',
|
||||||
|
trust_tier: 'bmad-certified',
|
||||||
|
version: '2.0.0',
|
||||||
|
approved_sha: 'abc123',
|
||||||
|
promoted: true,
|
||||||
|
promoted_rank: 1,
|
||||||
|
keywords: ['test', 'module'],
|
||||||
|
};
|
||||||
|
const result = mgr._normalizeCommunityModule(mod);
|
||||||
|
|
||||||
|
assert(result.code === 'tm', 'normalizeCommunityModule sets code');
|
||||||
|
assert(result.displayName === 'Test Module', 'normalizeCommunityModule sets displayName from display_name');
|
||||||
|
assert(result.type === 'community', 'normalizeCommunityModule sets type to community');
|
||||||
|
assert(result.category === 'software-development', 'normalizeCommunityModule preserves category');
|
||||||
|
assert(result.trustTier === 'bmad-certified', 'normalizeCommunityModule maps trust_tier');
|
||||||
|
assert(result.approvedSha === 'abc123', 'normalizeCommunityModule maps approved_sha');
|
||||||
|
assert(result.promoted === true, 'normalizeCommunityModule maps promoted');
|
||||||
|
assert(result.promotedRank === 1, 'normalizeCommunityModule maps promoted_rank');
|
||||||
|
assert(result.builtIn === false, 'normalizeCommunityModule sets builtIn false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.searchByKeyword (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
// Inject cached index to avoid network call
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'mod-a', display_name: 'Alpha', code: 'a', description: 'testing tools', category: 'dev', keywords: ['test'] },
|
||||||
|
{ name: 'mod-b', display_name: 'Beta', code: 'b', description: 'design suite', category: 'design', keywords: ['ux'] },
|
||||||
|
{ name: 'mod-c', display_name: 'Gamma', code: 'c', description: 'game engine', category: 'game', keywords: ['unity'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const r1 = await mgr.searchByKeyword('test');
|
||||||
|
assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword');
|
||||||
|
|
||||||
|
const r2 = await mgr.searchByKeyword('design');
|
||||||
|
assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description');
|
||||||
|
|
||||||
|
const r3 = await mgr.searchByKeyword('alpha');
|
||||||
|
assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name');
|
||||||
|
|
||||||
|
const r4 = await mgr.searchByKeyword('xyz');
|
||||||
|
assert(r4.length === 0, 'searchByKeyword returns empty for no match');
|
||||||
|
|
||||||
|
const r5 = await mgr.searchByKeyword('UNITY');
|
||||||
|
assert(r5.length === 1 && r5[0].code === 'c', 'searchByKeyword is case-insensitive');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.listFeatured (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', promoted: true, promoted_rank: 3 },
|
||||||
|
{ name: 'b', code: 'b', promoted: false },
|
||||||
|
{ name: 'c', code: 'c', promoted: true, promoted_rank: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const featured = await mgr.listFeatured();
|
||||||
|
assert(featured.length === 2, 'listFeatured returns only promoted modules');
|
||||||
|
assert(featured[0].code === 'c' && featured[1].code === 'a', 'listFeatured sorts by promoted_rank ascending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.getCategoryList (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', category: 'software-development' },
|
||||||
|
{ name: 'b', code: 'b', category: 'design-and-creative' },
|
||||||
|
{ name: 'c', code: 'c', category: 'software-development' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mgr._cachedCategories = {
|
||||||
|
categories: {
|
||||||
|
'software-development': { name: 'Software Development' },
|
||||||
|
'design-and-creative': { name: 'Design & Creative' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cats = await mgr.getCategoryList();
|
||||||
|
assert(cats.length === 2, 'getCategoryList returns categories with modules');
|
||||||
|
const swDev = cats.find((c) => c.slug === 'software-development');
|
||||||
|
assert(swDev && swDev.moduleCount === 2, 'getCategoryList counts modules per category');
|
||||||
|
assert(cats[0].name === 'Design & Creative', 'getCategoryList sorts alphabetically');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager SHA pinning normalization ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
// Module with SHA set
|
||||||
|
const withSha = mgr._normalizeCommunityModule({
|
||||||
|
name: 'pinned-mod',
|
||||||
|
code: 'pm',
|
||||||
|
approved_sha: 'abc123def456',
|
||||||
|
approved_tag: 'v1.0.0',
|
||||||
|
});
|
||||||
|
assert(withSha.approvedSha === 'abc123def456', 'SHA is preserved when set');
|
||||||
|
assert(withSha.approvedTag === 'v1.0.0', 'Tag is preserved as metadata');
|
||||||
|
|
||||||
|
// Module with null SHA (trusted contributor)
|
||||||
|
const noSha = mgr._normalizeCommunityModule({
|
||||||
|
name: 'trusted-mod',
|
||||||
|
code: 'tm',
|
||||||
|
approved_sha: null,
|
||||||
|
});
|
||||||
|
assert(noSha.approvedSha === null, 'Null SHA means no pinning (trusted contributor)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.listByCategory (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', category: 'design-and-creative' },
|
||||||
|
{ name: 'b', code: 'b', category: 'software-development' },
|
||||||
|
{ name: 'c', code: 'c', category: 'design-and-creative' },
|
||||||
|
{ name: 'd', code: 'd', category: 'game-development' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const design = await mgr.listByCategory('design-and-creative');
|
||||||
|
assert(design.length === 2, 'listByCategory filters to matching category');
|
||||||
|
assert(
|
||||||
|
design.every((m) => m.category === 'design-and-creative'),
|
||||||
|
'listByCategory returns only matching modules',
|
||||||
|
);
|
||||||
|
|
||||||
|
const empty = await mgr.listByCategory('nonexistent');
|
||||||
|
assert(empty.length === 0, 'listByCategory returns empty for unknown category');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.getModuleByCode (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'test-mod', code: 'tm', display_name: 'Test Module' },
|
||||||
|
{ name: 'other-mod', code: 'om', display_name: 'Other Module' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const found = await mgr.getModuleByCode('tm');
|
||||||
|
assert(found !== null && found.code === 'tm', 'getModuleByCode finds existing module');
|
||||||
|
|
||||||
|
const notFound = await mgr.getModuleByCode('xyz');
|
||||||
|
assert(notFound === null, 'getModuleByCode returns null for unknown code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CustomModuleManager URL edge cases ---
|
||||||
|
{
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const mgr = new CustomModuleManager();
|
||||||
|
|
||||||
|
// HTTP (not HTTPS) should work
|
||||||
|
const http = mgr.validateGitHubUrl('http://github.com/owner/repo');
|
||||||
|
assert(http.isValid === true, 'validateGitHubUrl accepts HTTP URL');
|
||||||
|
|
||||||
|
// Trailing slash should be rejected (strict matching)
|
||||||
|
const trailing = mgr.validateGitHubUrl('https://github.com/owner/repo/');
|
||||||
|
assert(trailing.isValid === false, 'validateGitHubUrl rejects trailing slash');
|
||||||
|
|
||||||
|
// SSH without .git should work
|
||||||
|
const sshNoDotGit = mgr.validateGitHubUrl('git@github.com:owner/repo');
|
||||||
|
assert(sshNoDotGit.isValid === true, 'validateGitHubUrl accepts SSH without .git');
|
||||||
|
assert(sshNoDotGit.repo === 'repo', 'validateGitHubUrl extracts repo from SSH without .git');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -1161,6 +1161,38 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add installed community modules to available modules
|
||||||
|
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||||
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
const communityModules = await communityMgr.listAll();
|
||||||
|
for (const communityModule of communityModules) {
|
||||||
|
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
|
||||||
|
availableModules.push({
|
||||||
|
id: communityModule.code,
|
||||||
|
name: communityModule.displayName,
|
||||||
|
isExternal: true,
|
||||||
|
fromCommunity: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add installed custom modules to available modules
|
||||||
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
for (const moduleId of installedModules) {
|
||||||
|
if (!availableModules.some((m) => m.id === moduleId)) {
|
||||||
|
const customSource = await customMgr.findModuleSourceByCode(moduleId);
|
||||||
|
if (customSource) {
|
||||||
|
availableModules.push({
|
||||||
|
id: moduleId,
|
||||||
|
name: moduleId,
|
||||||
|
isExternal: true,
|
||||||
|
fromCustom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
||||||
|
|
||||||
// Only update modules that are BOTH installed AND available (we have source for)
|
// Only update modules that are BOTH installed AND available (we have source for)
|
||||||
|
|
|
||||||
|
|
@ -818,6 +818,34 @@ class Manifest {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a community module
|
||||||
|
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||||
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
||||||
|
if (communityInfo) {
|
||||||
|
const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
|
return {
|
||||||
|
version: communityVersion || communityInfo.version,
|
||||||
|
source: 'community',
|
||||||
|
npmPackage: communityInfo.npmPackage || null,
|
||||||
|
repoUrl: communityInfo.url || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a custom module (from user-provided URL)
|
||||||
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
const customSource = await customMgr.findModuleSourceByCode(moduleName);
|
||||||
|
if (customSource) {
|
||||||
|
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
|
return {
|
||||||
|
version: customVersion,
|
||||||
|
source: 'custom',
|
||||||
|
npmPackage: null,
|
||||||
|
repoUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown module
|
// Unknown module
|
||||||
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
|
||||||
|
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
|
||||||
|
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages community modules from the BMad marketplace registry.
|
||||||
|
* Fetches community-index.yaml and categories.yaml from GitHub.
|
||||||
|
* Returns empty results when the registry is unreachable.
|
||||||
|
* Community modules are pinned to approved SHA when set; uses HEAD otherwise.
|
||||||
|
*/
|
||||||
|
class CommunityModuleManager {
|
||||||
|
constructor() {
|
||||||
|
this._client = new RegistryClient();
|
||||||
|
this._cachedIndex = null;
|
||||||
|
this._cachedCategories = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the community module index from the marketplace repo.
|
||||||
|
* Returns empty when the registry is unreachable.
|
||||||
|
* @returns {Object} Parsed YAML with modules array
|
||||||
|
*/
|
||||||
|
async loadCommunityIndex() {
|
||||||
|
if (this._cachedIndex) return this._cachedIndex;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
|
||||||
|
if (config?.modules?.length) {
|
||||||
|
this._cachedIndex = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Registry unreachable - no community modules available
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modules: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load categories from the marketplace repo.
|
||||||
|
* Returns empty when the registry is unreachable.
|
||||||
|
* @returns {Object} Parsed categories.yaml content
|
||||||
|
*/
|
||||||
|
async loadCategories() {
|
||||||
|
if (this._cachedCategories) return this._cachedCategories;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this._client.fetchYaml(CATEGORIES_URL);
|
||||||
|
if (config?.categories) {
|
||||||
|
this._cachedCategories = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Registry unreachable - no categories available
|
||||||
|
}
|
||||||
|
|
||||||
|
return { categories: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Listing & Filtering ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all community modules, normalized.
|
||||||
|
* @returns {Array<Object>} Normalized community modules
|
||||||
|
*/
|
||||||
|
async listAll() {
|
||||||
|
const index = await this.loadCommunityIndex();
|
||||||
|
return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get community modules filtered to a category.
|
||||||
|
* @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
|
||||||
|
* @returns {Array<Object>} Filtered modules
|
||||||
|
*/
|
||||||
|
async listByCategory(categorySlug) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.filter((mod) => mod.category === categorySlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get promoted/featured community modules, sorted by rank.
|
||||||
|
* @returns {Array<Object>} Featured modules
|
||||||
|
*/
|
||||||
|
async listFeatured() {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search community modules by keyword.
|
||||||
|
* Matches against name, display name, description, and keywords array.
|
||||||
|
* @param {string} query - Search query
|
||||||
|
* @returns {Array<Object>} Matching modules
|
||||||
|
*/
|
||||||
|
async searchByKeyword(query) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return all.filter((mod) => {
|
||||||
|
const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
|
||||||
|
return searchable.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories with module counts for UI display.
|
||||||
|
* Only returns categories that have at least one community module.
|
||||||
|
* @returns {Array<Object>} Array of { slug, name, moduleCount }
|
||||||
|
*/
|
||||||
|
async getCategoryList() {
|
||||||
|
const all = await this.listAll();
|
||||||
|
const categoriesData = await this.loadCategories();
|
||||||
|
const categories = categoriesData.categories || {};
|
||||||
|
|
||||||
|
// Count modules per category
|
||||||
|
const counts = {};
|
||||||
|
for (const mod of all) {
|
||||||
|
counts[mod.category] = (counts[mod.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list with display names from categories.yaml
|
||||||
|
const result = [];
|
||||||
|
for (const [slug, count] of Object.entries(counts)) {
|
||||||
|
const catInfo = categories[slug];
|
||||||
|
result.push({
|
||||||
|
slug,
|
||||||
|
name: catInfo?.name || slug,
|
||||||
|
moduleCount: count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by name
|
||||||
|
result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Module Lookup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a community module by its code.
|
||||||
|
* @param {string} code - Module code (e.g., 'wds')
|
||||||
|
* @returns {Object|null} Normalized module or null
|
||||||
|
*/
|
||||||
|
async getModuleByCode(code) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.find((m) => m.code === code) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clone with Tag Pinning ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for community modules.
|
||||||
|
* @returns {string} Path to the community modules cache directory
|
||||||
|
*/
|
||||||
|
getCacheDir() {
|
||||||
|
return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a community module repository, pinned to its approved tag.
|
||||||
|
* @param {string} moduleCode - Module code
|
||||||
|
* @param {Object} [options] - Clone options
|
||||||
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneModule(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
if (!moduleInfo) {
|
||||||
|
throw new Error(`Community module '${moduleCode}' not found in the registry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
await fs.ensureDir(cacheDir);
|
||||||
|
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return { start() {}, stop() {}, error() {}, message() {} };
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sha = moduleInfo.approvedSha;
|
||||||
|
let needsDependencyInstall = false;
|
||||||
|
let wasNewClone = false;
|
||||||
|
|
||||||
|
if (await fs.pathExists(moduleCacheDir)) {
|
||||||
|
// Already cloned - update to latest HEAD
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
if (currentRef !== newRef) needsDependencyInstall = true;
|
||||||
|
fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasNewClone) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
} catch (error) {
|
||||||
|
fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
|
||||||
|
throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pinned to a specific SHA, check out that exact commit.
|
||||||
|
// Refuse to install if the approved SHA cannot be reached - security requirement.
|
||||||
|
if (sha) {
|
||||||
|
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
if (headSha !== sha) {
|
||||||
|
try {
|
||||||
|
execSync(`git fetch --depth 1 origin ${sha}`, {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync(`git checkout ${sha}`, {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
} catch {
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
throw new Error(
|
||||||
|
`Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
|
||||||
|
`Installation refused for security. The module registry entry may need updating.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if needed
|
||||||
|
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
||||||
|
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the source path for a community module (clone + locate module.yaml).
|
||||||
|
* @param {string} moduleCode - Module code
|
||||||
|
* @param {Object} [options] - Options passed to cloneModule
|
||||||
|
* @returns {string|null} Path to the module source or null
|
||||||
|
*/
|
||||||
|
async findModuleSource(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
if (!moduleInfo) return null;
|
||||||
|
|
||||||
|
const cloneDir = await this.cloneModule(moduleCode, options);
|
||||||
|
|
||||||
|
// Check configured module_definition path first
|
||||||
|
if (moduleInfo.moduleDefinition) {
|
||||||
|
const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
|
||||||
|
if (await fs.pathExists(configuredPath)) {
|
||||||
|
return path.dirname(configuredPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search skills/ and src/ directories
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
const dirPath = path.join(cloneDir, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(subCandidate)) {
|
||||||
|
return path.dirname(subCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repo root
|
||||||
|
const rootCandidate = path.join(cloneDir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a community module entry to a consistent shape.
|
||||||
|
* @param {Object} mod - Raw module from community-index.yaml
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeCommunityModule(mod) {
|
||||||
|
return {
|
||||||
|
key: mod.name,
|
||||||
|
code: mod.code,
|
||||||
|
name: mod.display_name || mod.name,
|
||||||
|
displayName: mod.display_name || mod.name,
|
||||||
|
description: mod.description || '',
|
||||||
|
url: mod.repository || mod.url,
|
||||||
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
author: mod.author || '',
|
||||||
|
license: mod.license || '',
|
||||||
|
type: 'community',
|
||||||
|
category: mod.category || '',
|
||||||
|
subcategory: mod.subcategory || '',
|
||||||
|
keywords: mod.keywords || [],
|
||||||
|
version: mod.version || null,
|
||||||
|
approvedTag: mod.approved_tag || null,
|
||||||
|
approvedSha: mod.approved_sha || null,
|
||||||
|
approvedDate: mod.approved_date || null,
|
||||||
|
reviewer: mod.reviewer || null,
|
||||||
|
trustTier: mod.trust_tier || 'unverified',
|
||||||
|
promoted: mod.promoted === true,
|
||||||
|
promotedRank: mod.promoted_rank || null,
|
||||||
|
defaultSelected: false,
|
||||||
|
builtIn: false,
|
||||||
|
isExternal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CommunityModuleManager };
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages custom modules installed from user-provided GitHub URLs.
|
||||||
|
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
|
||||||
|
*/
|
||||||
|
class CustomModuleManager {
|
||||||
|
constructor() {
|
||||||
|
this._client = new RegistryClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── URL Validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a GitHub repository URL.
|
||||||
|
* Supports HTTPS and SSH formats.
|
||||||
|
* @param {string} url - GitHub URL to validate
|
||||||
|
* @returns {Object} { owner, repo, isValid, error }
|
||||||
|
*/
|
||||||
|
validateGitHubUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
|
||||||
|
// HTTPS format: https://github.com/owner/repo[.git]
|
||||||
|
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (httpsMatch) {
|
||||||
|
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH format: git@github.com:owner/repo.git
|
||||||
|
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (sshMatch) {
|
||||||
|
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Discovery ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @returns {Object} Parsed marketplace.json content
|
||||||
|
*/
|
||||||
|
async fetchMarketplaceJson(repoUrl) {
|
||||||
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
|
if (!isValid) throw new Error(error);
|
||||||
|
|
||||||
|
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._client.fetchJson(rawUrl);
|
||||||
|
} catch (error_) {
|
||||||
|
if (error_.message.includes('404')) {
|
||||||
|
throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`);
|
||||||
|
}
|
||||||
|
if (error_.message.includes('403')) {
|
||||||
|
throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover modules from a GitHub repository's marketplace.json.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @returns {Array<Object>} Normalized plugin list
|
||||||
|
*/
|
||||||
|
async discoverModules(repoUrl) {
|
||||||
|
const data = await this.fetchMarketplaceJson(repoUrl);
|
||||||
|
const plugins = data?.plugins;
|
||||||
|
|
||||||
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
||||||
|
throw new Error('marketplace.json contains no plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clone ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for custom modules.
|
||||||
|
* @returns {string} Path to the custom modules cache directory
|
||||||
|
*/
|
||||||
|
getCacheDir() {
|
||||||
|
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a custom module repository to cache.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @param {Object} [options] - Clone options
|
||||||
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneRepo(repoUrl, options = {}) {
|
||||||
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
|
if (!isValid) throw new Error(error);
|
||||||
|
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
const repoCacheDir = path.join(cacheDir, owner, repo);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
await fs.ensureDir(path.join(cacheDir, owner));
|
||||||
|
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return { start() {}, stop() {}, error() {} };
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await fs.pathExists(repoCacheDir)) {
|
||||||
|
// Update existing clone
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Updating ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Updated ${owner}/${repo}`);
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
|
||||||
|
await fs.remove(repoCacheDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(repoCacheDir))) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
|
||||||
|
} catch (error_) {
|
||||||
|
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
|
||||||
|
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if package.json exists
|
||||||
|
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
||||||
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
|
||||||
|
} catch (error_) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repoCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the module source path within a cloned custom repo.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL (for cache location)
|
||||||
|
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
||||||
|
* @returns {string|null} Path to directory containing module.yaml
|
||||||
|
*/
|
||||||
|
async findModuleSource(repoUrl, pluginSource) {
|
||||||
|
const { owner, repo } = this.validateGitHubUrl(repoUrl);
|
||||||
|
const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(repoCacheDir))) return null;
|
||||||
|
|
||||||
|
// Try plugin source path first (e.g., "./src/pro-skills")
|
||||||
|
if (pluginSource) {
|
||||||
|
const sourcePath = path.join(repoCacheDir, pluginSource);
|
||||||
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search skills/ and src/ directories
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
const dirPath = path.join(repoCacheDir, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(subCandidate)) {
|
||||||
|
return path.dirname(subCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repo root
|
||||||
|
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return repoCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find module source by module code, searching the custom cache.
|
||||||
|
* @param {string} moduleCode - Module code to search for
|
||||||
|
* @param {Object} [options] - Options
|
||||||
|
* @returns {string|null} Path to the module source or null
|
||||||
|
*/
|
||||||
|
async findModuleSourceByCode(moduleCode, options = {}) {
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
if (!(await fs.pathExists(cacheDir))) return null;
|
||||||
|
|
||||||
|
// Search through all custom repo caches
|
||||||
|
try {
|
||||||
|
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||||
|
for (const ownerEntry of owners) {
|
||||||
|
if (!ownerEntry.isDirectory()) continue;
|
||||||
|
const ownerPath = path.join(cacheDir, ownerEntry.name);
|
||||||
|
const repos = await fs.readdir(ownerPath, { withFileTypes: true });
|
||||||
|
for (const repoEntry of repos) {
|
||||||
|
if (!repoEntry.isDirectory()) continue;
|
||||||
|
const repoPath = path.join(ownerPath, repoEntry.name);
|
||||||
|
|
||||||
|
// Check marketplace.json for matching module code
|
||||||
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
||||||
|
if (await fs.pathExists(marketplacePath)) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||||
|
for (const plugin of data.plugins || []) {
|
||||||
|
if (plugin.name === moduleCode) {
|
||||||
|
// Found the module - find its source
|
||||||
|
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
|
||||||
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed marketplace.json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache doesn't exist or is inaccessible
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a plugin from marketplace.json to a consistent shape.
|
||||||
|
* @param {Object} plugin - Plugin object from marketplace.json
|
||||||
|
* @param {string} repoUrl - Source repository URL
|
||||||
|
* @param {Object} data - Full marketplace.json data
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeCustomModule(plugin, repoUrl, data) {
|
||||||
|
return {
|
||||||
|
code: plugin.name,
|
||||||
|
name: plugin.name,
|
||||||
|
displayName: plugin.name,
|
||||||
|
description: plugin.description || '',
|
||||||
|
version: plugin.version || null,
|
||||||
|
author: plugin.author || data.owner || '',
|
||||||
|
url: repoUrl,
|
||||||
|
source: plugin.source || null,
|
||||||
|
type: 'custom',
|
||||||
|
trustTier: 'unverified',
|
||||||
|
builtIn: false,
|
||||||
|
isExternal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CustomModuleManager };
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const https = require('node:https');
|
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync } = require('node:child_process');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
||||||
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
||||||
|
|
@ -17,35 +17,8 @@ const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
||||||
* @class ExternalModuleManager
|
* @class ExternalModuleManager
|
||||||
*/
|
*/
|
||||||
class ExternalModuleManager {
|
class ExternalModuleManager {
|
||||||
constructor() {}
|
constructor() {
|
||||||
|
this._client = new RegistryClient();
|
||||||
/**
|
|
||||||
* Fetch a URL and return the response body as a string.
|
|
||||||
* @param {string} url - URL to fetch
|
|
||||||
* @param {number} timeout - Timeout in ms (default 10s)
|
|
||||||
* @returns {Promise<string>} Response body
|
|
||||||
*/
|
|
||||||
_fetch(url, timeout = 10_000) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = https
|
|
||||||
.get(url, { timeout }, (res) => {
|
|
||||||
// Follow one redirect (GitHub sometimes 301s)
|
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
||||||
return this._fetch(res.headers.location, timeout).then(resolve, reject);
|
|
||||||
}
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
let data = '';
|
|
||||||
res.on('data', (chunk) => (data += chunk));
|
|
||||||
res.on('end', () => resolve(data));
|
|
||||||
})
|
|
||||||
.on('error', reject)
|
|
||||||
.on('timeout', () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error('Request timed out'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,7 +33,7 @@ class ExternalModuleManager {
|
||||||
|
|
||||||
// Try remote registry first
|
// Try remote registry first
|
||||||
try {
|
try {
|
||||||
const content = await this._fetch(REGISTRY_RAW_URL);
|
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
||||||
const config = yaml.parse(content);
|
const config = yaml.parse(content);
|
||||||
if (config?.modules?.length) {
|
if (config?.modules?.length) {
|
||||||
this.cachedModules = config;
|
this.cachedModules = config;
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,22 @@ class OfficialModules {
|
||||||
return externalSource;
|
return externalSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check community modules
|
||||||
|
const { CommunityModuleManager } = require('./community-manager');
|
||||||
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
const communitySource = await communityMgr.findModuleSource(moduleCode, options);
|
||||||
|
if (communitySource) {
|
||||||
|
return communitySource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom modules (from user-provided URLs, already cloned to cache)
|
||||||
|
const { CustomModuleManager } = require('./custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
const customSource = await customMgr.findModuleSourceByCode(moduleCode, options);
|
||||||
|
if (customSource) {
|
||||||
|
return customSource;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1131,7 +1147,13 @@ class OfficialModules {
|
||||||
// Collect all answers (static + prompted)
|
// Collect all answers (static + prompted)
|
||||||
let allAnswers = { ...staticAnswers };
|
let allAnswers = { ...staticAnswers };
|
||||||
|
|
||||||
if (questions.length > 0) {
|
if (questions.length > 0 && silentMode) {
|
||||||
|
// In silent mode (quick update), use defaults for new fields instead of prompting
|
||||||
|
for (const q of questions) {
|
||||||
|
allAnswers[q.name] = typeof q.default === 'function' ? q.default({}) : q.default;
|
||||||
|
}
|
||||||
|
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured with defaults`);
|
||||||
|
} else if (questions.length > 0) {
|
||||||
// Only show header if we actually have questions
|
// Only show header if we actually have questions
|
||||||
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
||||||
await prompts.log.message('');
|
await prompts.log.message('');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
const https = require('node:https');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared HTTP client for fetching registry data from GitHub.
|
||||||
|
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
|
||||||
|
*/
|
||||||
|
class RegistryClient {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.timeout = options.timeout || 10_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and return the response body as a string.
|
||||||
|
* Follows one redirect (GitHub sometimes 301s).
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms (overrides default)
|
||||||
|
* @returns {Promise<string>} Response body
|
||||||
|
*/
|
||||||
|
fetch(url, timeout) {
|
||||||
|
const timeoutMs = timeout || this.timeout;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https
|
||||||
|
.get(url, { timeout: timeoutMs }, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
})
|
||||||
|
.on('error', reject)
|
||||||
|
.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timed out'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and parse the response as YAML.
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @returns {Promise<Object>} Parsed YAML content
|
||||||
|
*/
|
||||||
|
async fetchYaml(url, timeout) {
|
||||||
|
const content = await this.fetch(url, timeout);
|
||||||
|
return yaml.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and parse the response as JSON.
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @returns {Promise<Object>} Parsed JSON content
|
||||||
|
*/
|
||||||
|
async fetchJson(url, timeout) {
|
||||||
|
const content = await this.fetch(url, timeout);
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { RegistryClient };
|
||||||
|
|
@ -563,22 +563,58 @@ class UI {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select all modules (official + community) using grouped multiselect.
|
* Select all modules across three tiers: official, community, and custom URL.
|
||||||
* Core is shown as locked but filtered from the result since it's always installed separately.
|
|
||||||
* @param {Set} installedModuleIds - Currently installed module IDs
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||||
* @returns {Array} Selected module codes (excluding core)
|
* @returns {Array} Selected module codes (excluding core)
|
||||||
*/
|
*/
|
||||||
async selectAllModules(installedModuleIds = new Set()) {
|
async selectAllModules(installedModuleIds = new Set()) {
|
||||||
// Registry is the single source of truth for the module list
|
// Phase 1: Official modules
|
||||||
|
const officialSelected = await this._selectOfficialModules(installedModuleIds);
|
||||||
|
|
||||||
|
// Determine which installed modules are NOT official (community or custom).
|
||||||
|
// These must be preserved even if the user declines to browse community/custom.
|
||||||
|
const officialCodes = new Set(officialSelected);
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
const registryModules = await externalManager.listAvailable();
|
||||||
|
const officialRegistryCodes = new Set(registryModules.map((m) => m.code));
|
||||||
|
const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
|
||||||
|
|
||||||
|
// Phase 2: Community modules (category drill-down)
|
||||||
|
// Returns { codes, didBrowse } so we know if the user entered the flow
|
||||||
|
const communityResult = await this._browseCommunityModules(installedModuleIds);
|
||||||
|
|
||||||
|
// Phase 3: Custom URL modules
|
||||||
|
const customSelected = await this._addCustomUrlModules(installedModuleIds);
|
||||||
|
|
||||||
|
// Merge all selections
|
||||||
|
const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]);
|
||||||
|
|
||||||
|
// Auto-include installed non-official modules that the user didn't get
|
||||||
|
// a chance to manage (they declined to browse). If they did browse,
|
||||||
|
// trust their selections - they could have deselected intentionally.
|
||||||
|
if (!communityResult.didBrowse) {
|
||||||
|
for (const code of installedNonOfficial) {
|
||||||
|
allSelected.add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...allSelected];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select official modules using autocompleteMultiselect.
|
||||||
|
* Extracted from the original selectAllModules - unchanged behavior.
|
||||||
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||||
|
* @returns {Array} Selected official module codes
|
||||||
|
*/
|
||||||
|
async _selectOfficialModules(installedModuleIds = new Set()) {
|
||||||
const externalManager = new ExternalModuleManager();
|
const externalManager = new ExternalModuleManager();
|
||||||
const registryModules = await externalManager.listAvailable();
|
const registryModules = await externalManager.listAvailable();
|
||||||
|
|
||||||
// Build flat options list with group hints for autocompleteMultiselect
|
|
||||||
const allOptions = [];
|
const allOptions = [];
|
||||||
const initialValues = [];
|
const initialValues = [];
|
||||||
const lockedValues = ['core'];
|
const lockedValues = ['core'];
|
||||||
|
|
||||||
// Helper to build module entry with proper sorting and selection
|
|
||||||
const buildModuleEntry = async (mod) => {
|
const buildModuleEntry = async (mod) => {
|
||||||
const isInstalled = installedModuleIds.has(mod.code);
|
const isInstalled = installedModuleIds.has(mod.code);
|
||||||
const version = await getMarketplaceVersion(mod.code);
|
const version = await getMarketplaceVersion(mod.code);
|
||||||
|
|
@ -591,7 +627,6 @@ class UI {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Registry order is display order; core is always locked
|
|
||||||
for (const mod of registryModules) {
|
for (const mod of registryModules) {
|
||||||
const entry = await buildModuleEntry(mod);
|
const entry = await buildModuleEntry(mod);
|
||||||
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
|
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
|
||||||
|
|
@ -601,7 +636,7 @@ class UI {
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = await prompts.autocompleteMultiselect({
|
const selected = await prompts.autocompleteMultiselect({
|
||||||
message: 'Select modules to install:',
|
message: 'Select official modules to install:',
|
||||||
options: allOptions,
|
options: allOptions,
|
||||||
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
||||||
lockedValues,
|
lockedValues,
|
||||||
|
|
@ -611,18 +646,261 @@ class UI {
|
||||||
|
|
||||||
const result = selected ? [...selected] : [];
|
const result = selected ? [...selected] : [];
|
||||||
|
|
||||||
// Display selected modules as bulleted list
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
const moduleLines = result.map((moduleId) => {
|
const moduleLines = result.map((moduleId) => {
|
||||||
const opt = allOptions.find((o) => o.value === moduleId);
|
const opt = allOptions.find((o) => o.value === moduleId);
|
||||||
return ` \u2022 ${opt?.label || moduleId}`;
|
return ` \u2022 ${opt?.label || moduleId}`;
|
||||||
});
|
});
|
||||||
await prompts.log.message('Selected modules:\n' + moduleLines.join('\n'));
|
await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse and select community modules using category drill-down.
|
||||||
|
* Featured/promoted modules appear at the top.
|
||||||
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||||
|
* @returns {Object} { codes: string[], didBrowse: boolean }
|
||||||
|
*/
|
||||||
|
async _browseCommunityModules(installedModuleIds = new Set()) {
|
||||||
|
const browseCommunity = await prompts.confirm({
|
||||||
|
message: 'Would you like to browse community modules?',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
if (!browseCommunity) return { codes: [], didBrowse: false };
|
||||||
|
|
||||||
|
const { CommunityModuleManager } = require('./modules/community-manager');
|
||||||
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
const s = await prompts.spinner();
|
||||||
|
s.start('Loading community module catalog...');
|
||||||
|
|
||||||
|
let categories, featured, allCommunity;
|
||||||
|
try {
|
||||||
|
[categories, featured, allCommunity] = await Promise.all([
|
||||||
|
communityMgr.getCategoryList(),
|
||||||
|
communityMgr.listFeatured(),
|
||||||
|
communityMgr.listAll(),
|
||||||
|
]);
|
||||||
|
s.stop(`Community catalog loaded (${allCommunity.length} modules)`);
|
||||||
|
} catch (error) {
|
||||||
|
s.error('Failed to load community catalog');
|
||||||
|
await prompts.log.warn(` ${error.message}`);
|
||||||
|
return { codes: [], didBrowse: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allCommunity.length === 0) {
|
||||||
|
await prompts.log.info('No community modules are currently available.');
|
||||||
|
return { codes: [], didBrowse: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCodes = new Set();
|
||||||
|
let browsing = true;
|
||||||
|
|
||||||
|
while (browsing) {
|
||||||
|
const categoryChoices = [];
|
||||||
|
|
||||||
|
// Featured section at top
|
||||||
|
if (featured.length > 0) {
|
||||||
|
categoryChoices.push({
|
||||||
|
value: '__featured__',
|
||||||
|
label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories with module counts
|
||||||
|
for (const cat of categories) {
|
||||||
|
categoryChoices.push({
|
||||||
|
value: cat.slug,
|
||||||
|
label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special actions at bottom
|
||||||
|
categoryChoices.push(
|
||||||
|
{ value: '__all__', label: '\u25CE View all community modules' },
|
||||||
|
{ value: '__search__', label: '\u25CE Search by keyword' },
|
||||||
|
{ value: '__done__', label: '\u2713 Done browsing' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCount = selectedCodes.size;
|
||||||
|
const categoryChoice = await prompts.select({
|
||||||
|
message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`,
|
||||||
|
choices: categoryChoices,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (categoryChoice === '__done__') {
|
||||||
|
browsing = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modulesToShow;
|
||||||
|
switch (categoryChoice) {
|
||||||
|
case '__featured__': {
|
||||||
|
modulesToShow = featured;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '__all__': {
|
||||||
|
modulesToShow = allCommunity;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '__search__': {
|
||||||
|
const query = await prompts.text({
|
||||||
|
message: 'Search community modules:',
|
||||||
|
placeholder: 'e.g., design, testing, game',
|
||||||
|
});
|
||||||
|
if (!query || query.trim() === '') continue;
|
||||||
|
modulesToShow = await communityMgr.searchByKeyword(query.trim());
|
||||||
|
if (modulesToShow.length === 0) {
|
||||||
|
await prompts.log.warn('No matching modules found.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
modulesToShow = await communityMgr.listByCategory(categoryChoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build options for autocompleteMultiselect
|
||||||
|
const trustBadge = (tier) => {
|
||||||
|
if (tier === 'bmad-certified') return '\u2713';
|
||||||
|
if (tier === 'community-reviewed') return '\u25CB';
|
||||||
|
return '\u26A0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = modulesToShow.map((mod) => {
|
||||||
|
const versionStr = mod.version ? ` (v${mod.version})` : '';
|
||||||
|
const badge = trustBadge(mod.trustTier);
|
||||||
|
return {
|
||||||
|
label: `${mod.displayName}${versionStr} [${badge}]`,
|
||||||
|
value: mod.code,
|
||||||
|
hint: mod.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-check modules that are already selected or installed
|
||||||
|
const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code);
|
||||||
|
|
||||||
|
const selected = await prompts.autocompleteMultiselect({
|
||||||
|
message: 'Select community modules:',
|
||||||
|
options,
|
||||||
|
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
||||||
|
required: false,
|
||||||
|
maxItems: Math.min(options.length, 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update accumulated selections: sync with what user selected in this view
|
||||||
|
const shownCodes = new Set(modulesToShow.map((m) => m.code));
|
||||||
|
for (const code of shownCodes) {
|
||||||
|
if (selected && selected.includes(code)) {
|
||||||
|
selectedCodes.add(code);
|
||||||
|
} else {
|
||||||
|
selectedCodes.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCodes.size > 0) {
|
||||||
|
const moduleLines = [];
|
||||||
|
for (const code of selectedCodes) {
|
||||||
|
const mod = await communityMgr.getModuleByCode(code);
|
||||||
|
moduleLines.push(` \u2022 ${mod?.displayName || code}`);
|
||||||
|
}
|
||||||
|
await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { codes: [...selectedCodes], didBrowse: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt user to install modules from custom GitHub URLs.
|
||||||
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||||
|
* @returns {Array} Selected custom module code strings
|
||||||
|
*/
|
||||||
|
async _addCustomUrlModules(installedModuleIds = new Set()) {
|
||||||
|
const addCustom = await prompts.confirm({
|
||||||
|
message: 'Would you like to install from a custom GitHub URL?',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
if (!addCustom) return [];
|
||||||
|
|
||||||
|
const { CustomModuleManager } = require('./modules/custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
const selectedModules = [];
|
||||||
|
|
||||||
|
let addMore = true;
|
||||||
|
while (addMore) {
|
||||||
|
const url = await prompts.text({
|
||||||
|
message: 'GitHub repository URL:',
|
||||||
|
placeholder: 'https://github.com/owner/repo',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input || input.trim() === '') return 'URL is required';
|
||||||
|
const result = customMgr.validateGitHubUrl(input.trim());
|
||||||
|
return result.isValid ? undefined : result.error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = await prompts.spinner();
|
||||||
|
s.start('Fetching module info...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plugins = await customMgr.discoverModules(url.trim());
|
||||||
|
s.stop('Module info loaded');
|
||||||
|
|
||||||
|
await prompts.log.warn(
|
||||||
|
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
const versionStr = plugin.version ? ` v${plugin.version}` : '';
|
||||||
|
await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmInstall = await prompts.confirm({
|
||||||
|
message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmInstall) {
|
||||||
|
// Pre-clone the repo so it's cached for the install pipeline
|
||||||
|
s.start('Cloning repository...');
|
||||||
|
try {
|
||||||
|
await customMgr.cloneRepo(url.trim());
|
||||||
|
s.stop('Repository cloned');
|
||||||
|
} catch (cloneError) {
|
||||||
|
s.error('Failed to clone repository');
|
||||||
|
await prompts.log.error(` ${cloneError.message}`);
|
||||||
|
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
selectedModules.push(plugin.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
s.error('Failed to load module info');
|
||||||
|
await prompts.log.error(` ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMore = await prompts.confirm({
|
||||||
|
message: 'Add another custom module?',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedModules.length > 0) {
|
||||||
|
await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedModules;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default modules for non-interactive mode
|
* Get default modules for non-interactive mode
|
||||||
* @param {Set} installedModuleIds - Already installed module IDs
|
* @param {Set} installedModuleIds - Already installed module IDs
|
||||||
|
|
@ -946,6 +1224,7 @@ class UI {
|
||||||
// Group modules by source
|
// Group modules by source
|
||||||
const builtIn = modules.filter((m) => m.source === 'built-in');
|
const builtIn = modules.filter((m) => m.source === 'built-in');
|
||||||
const external = modules.filter((m) => m.source === 'external');
|
const external = modules.filter((m) => m.source === 'external');
|
||||||
|
const community = modules.filter((m) => m.source === 'community');
|
||||||
const custom = modules.filter((m) => m.source === 'custom');
|
const custom = modules.filter((m) => m.source === 'custom');
|
||||||
const unknown = modules.filter((m) => m.source === 'unknown');
|
const unknown = modules.filter((m) => m.source === 'unknown');
|
||||||
|
|
||||||
|
|
@ -966,6 +1245,7 @@ class UI {
|
||||||
|
|
||||||
formatGroup(builtIn, 'Built-in Modules');
|
formatGroup(builtIn, 'Built-in Modules');
|
||||||
formatGroup(external, 'External Modules (Official)');
|
formatGroup(external, 'External Modules (Official)');
|
||||||
|
formatGroup(community, 'Community Modules');
|
||||||
formatGroup(custom, 'Custom Modules');
|
formatGroup(custom, 'Custom Modules');
|
||||||
formatGroup(unknown, 'Other Modules');
|
formatGroup(unknown, 'Other Modules');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue