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('');
|
||||
|
||||
// ============================================================
|
||||
// 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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
// 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
|
||||
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||
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 os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const https = require('node:https');
|
||||
const { execSync } = require('node:child_process');
|
||||
const yaml = require('yaml');
|
||||
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 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 {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 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'));
|
||||
});
|
||||
});
|
||||
constructor() {
|
||||
this._client = new RegistryClient();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,7 +33,7 @@ class ExternalModuleManager {
|
|||
|
||||
// Try remote registry first
|
||||
try {
|
||||
const content = await this._fetch(REGISTRY_RAW_URL);
|
||||
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
||||
const config = yaml.parse(content);
|
||||
if (config?.modules?.length) {
|
||||
this.cachedModules = config;
|
||||
|
|
|
|||
|
|
@ -202,6 +202,22 @@ class OfficialModules {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1131,7 +1147,13 @@ class OfficialModules {
|
|||
// Collect all answers (static + prompted)
|
||||
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
|
||||
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
||||
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.
|
||||
* Core is shown as locked but filtered from the result since it's always installed separately.
|
||||
* Select all modules across three tiers: official, community, and custom URL.
|
||||
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||
* @returns {Array} Selected module codes (excluding core)
|
||||
*/
|
||||
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 registryModules = await externalManager.listAvailable();
|
||||
|
||||
// Build flat options list with group hints for autocompleteMultiselect
|
||||
const allOptions = [];
|
||||
const initialValues = [];
|
||||
const lockedValues = ['core'];
|
||||
|
||||
// Helper to build module entry with proper sorting and selection
|
||||
const buildModuleEntry = async (mod) => {
|
||||
const isInstalled = installedModuleIds.has(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) {
|
||||
const entry = await buildModuleEntry(mod);
|
||||
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
|
||||
|
|
@ -601,7 +636,7 @@ class UI {
|
|||
}
|
||||
|
||||
const selected = await prompts.autocompleteMultiselect({
|
||||
message: 'Select modules to install:',
|
||||
message: 'Select official modules to install:',
|
||||
options: allOptions,
|
||||
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
||||
lockedValues,
|
||||
|
|
@ -611,18 +646,261 @@ class UI {
|
|||
|
||||
const result = selected ? [...selected] : [];
|
||||
|
||||
// Display selected modules as bulleted list
|
||||
if (result.length > 0) {
|
||||
const moduleLines = result.map((moduleId) => {
|
||||
const opt = allOptions.find((o) => o.value === 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {Set} installedModuleIds - Already installed module IDs
|
||||
|
|
@ -946,6 +1224,7 @@ class UI {
|
|||
// Group modules by source
|
||||
const builtIn = modules.filter((m) => m.source === 'built-in');
|
||||
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 unknown = modules.filter((m) => m.source === 'unknown');
|
||||
|
||||
|
|
@ -966,6 +1245,7 @@ class UI {
|
|||
|
||||
formatGroup(builtIn, 'Built-in Modules');
|
||||
formatGroup(external, 'External Modules (Official)');
|
||||
formatGroup(community, 'Community Modules');
|
||||
formatGroup(custom, 'Custom Modules');
|
||||
formatGroup(unknown, 'Other Modules');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue