BMAD-METHOD/tools/installer/modules/community-manager.js

608 lines
24 KiB
JavaScript

const fs = require('../fs-native');
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 { decideChannelForModule } = require('./channel-plan');
const { parseGitHubRepo, tagExists } = require('./channel-resolver');
const MARKETPLACE_OWNER = 'bmad-code-org';
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
const MARKETPLACE_REF = 'main';
/**
* 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.
*/
function quoteShellRef(ref) {
if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
}
return `"${ref}"`;
}
class CommunityModuleManager {
// moduleCode → { channel, version, sha, registryApprovedTag, registryApprovedSha, repoUrl, bypassedCurator }
// Shared across all instances; the manifest writer often uses a fresh instance.
static _resolutions = new Map();
// moduleCode → ResolvedModule (from PluginResolver) when the cloned repo ships
// a `.claude-plugin/marketplace.json`. Lets community installs reuse the same
// skill-level install pipeline as custom-source installs (installFromResolution).
static _pluginResolutions = new Map();
constructor() {
this._client = new RegistryClient();
this._cachedIndex = null;
this._cachedCategories = null;
}
/** Get the most recent channel resolution for a community module. */
getResolution(moduleCode) {
return CommunityModuleManager._resolutions.get(moduleCode) || null;
}
/** Get the marketplace.json-derived plugin resolution for a community module, if any. */
getPluginResolution(moduleCode) {
return CommunityModuleManager._pluginResolutions.get(moduleCode) || 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.fetchGitHubYaml(
MARKETPLACE_OWNER,
MARKETPLACE_REPO,
'registry/community-index.yaml',
MARKETPLACE_REF,
);
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.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
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();
};
// ─── Resolve channel plan ──────────────────────────────────────────────
// Default community behavior (stable channel) honors the curator's
// approved SHA. --next=CODE and --pin CODE=TAG override the curator; we
// warn the user before bypassing the approved version.
const planEntry = decideChannelForModule({
code: moduleCode,
channelOptions: options.channelOptions,
registryDefault: 'stable',
});
const approvedSha = moduleInfo.approvedSha;
const approvedTag = moduleInfo.approvedTag;
let bypassedCurator = false;
if (planEntry.channel !== 'stable') {
bypassedCurator = true;
if (!silent) {
const approvedLabel = approvedTag || approvedSha || 'curator-approved version';
await prompts.log.warn(
`WARNING: Installing '${moduleCode}' from ${
planEntry.channel === 'pinned' ? `tag ${planEntry.pin}` : 'main HEAD'
} bypasses the curator-approved ${approvedLabel}. Proceed only if you trust this source.`,
);
if (!options.channelOptions?.acceptBypass) {
const proceed = await prompts.confirm({
message: `Continue installing '${moduleCode}' with curator bypass?`,
default: false,
});
if (!proceed) {
throw new Error(`Install of community module '${moduleCode}' cancelled by user.`);
}
}
}
}
let needsDependencyInstall = false;
let wasNewClone = false;
if (await fs.pathExists(moduleCacheDir)) {
// Already cloned — refresh to the correct ref for the resolved channel.
// A pinned install must not reset to origin/HEAD (it would silently drift
// to main on every re-install). Stable + approvedSha is handled below
// by the curator-SHA checkout logic.
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' },
});
if (planEntry.channel === 'pinned') {
// Fetch the pin tag specifically and check it out.
execSync(`git fetch --depth 1 origin ${quoteShellRef(planEntry.pin)} --no-tags`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git checkout --quiet FETCH_HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
} else {
// stable (approvedSha path re-checks out below) and next: track main.
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 {
if (planEntry.channel === 'pinned') {
execSync(`git clone --depth 1 --branch ${quoteShellRef(planEntry.pin)} "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
} else {
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}`);
}
}
// ─── Check out the resolved ref per channel ──────────────────────────
if (planEntry.channel === 'stable' && approvedSha) {
// Default path: pin to the curator-approved SHA. Refuse install if the SHA
// is unreachable (tag may have been deleted or rewritten) — security requirement.
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (headSha !== approvedSha) {
try {
execSync(`git fetch --depth 1 origin ${quoteShellRef(approvedSha)}`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync(`git checkout ${quoteShellRef(approvedSha)}`, {
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 (${approvedSha}). ` +
`Installation refused for security. The module registry entry may need updating, ` +
`or use --next=${moduleCode} / --pin ${moduleCode}=<tag> to explicitly bypass.`,
);
}
}
} else if (planEntry.channel === 'stable' && !approvedSha) {
// Registry data gap: tag or SHA missing. Warn but proceed at HEAD (pre-existing behavior).
if (!silent) {
await prompts.log.warn(`Community module '${moduleCode}' has no curator-approved SHA in the registry; installing from main HEAD.`);
}
} else if (planEntry.channel === 'pinned') {
// We cloned the tag directly above (via --branch), but ensure HEAD matches.
// No additional checkout needed.
}
// else: 'next' channel — already at origin/HEAD from the fetch/reset above.
// Record the resolution so the manifest writer can pick up channel/version/sha.
const installedSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
const recordedVersion =
planEntry.channel === 'pinned' ? planEntry.pin : planEntry.channel === 'next' ? 'main' : approvedTag || installedSha.slice(0, 7);
CommunityModuleManager._resolutions.set(moduleCode, {
channel: planEntry.channel,
version: recordedVersion,
sha: installedSha,
registryApprovedTag: approvedTag || null,
registryApprovedSha: approvedSha || null,
repoUrl: moduleInfo.url,
bypassedCurator,
planSource: planEntry.source,
});
// If the repo ships a marketplace.json, route through PluginResolver so the
// skill-level install pipeline (installFromResolution) handles the copy.
// Repos without marketplace.json fall through to the legacy findModuleSource
// path unchanged.
await this._tryResolveMarketplacePlugin(moduleCacheDir, moduleInfo, {
channel: planEntry.channel,
version: recordedVersion,
sha: installedSha,
approvedTag,
approvedSha,
});
// 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;
}
// ─── Marketplace.json Resolution ──────────────────────────────────────────
/**
* Detect `.claude-plugin/marketplace.json` in a cloned community repo and
* route through PluginResolver. When successful, caches the resolution so
* OfficialModulesManager.install() can route the copy through
* installFromResolution() — the same path used by custom-source installs.
*
* Silent no-op when marketplace.json is absent or the resolver returns no
* matches; the legacy findModuleSource path then handles the install.
*
* @param {string} repoPath - Absolute path to the cloned repo
* @param {Object} moduleInfo - Normalized community module info
* @param {Object} resolution - Resolution metadata from cloneModule
* @param {string} resolution.channel - Channel ('stable' | 'next' | 'pinned')
* @param {string} resolution.version - Recorded version string
* @param {string} resolution.sha - Resolved git SHA
* @param {string|null} resolution.approvedTag - Registry approved tag
* @param {string|null} resolution.approvedSha - Registry approved SHA
*/
async _tryResolveMarketplacePlugin(repoPath, moduleInfo, resolution) {
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
if (!(await fs.pathExists(marketplacePath))) return;
let marketplaceData;
try {
marketplaceData = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
} catch {
// Malformed marketplace.json — fall through to legacy path.
return;
}
const plugins = Array.isArray(marketplaceData?.plugins) ? marketplaceData.plugins : [];
if (plugins.length === 0) return;
const plugin = this._selectPluginForModule(plugins, moduleInfo);
if (!plugin) {
await prompts.log
.warn?.(
`Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` +
`Falling back to legacy install path.`,
)
.catch(() => {});
return;
}
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
const resolved = await resolver.resolve(repoPath, plugin);
if (!resolved || resolved.length === 0) return;
// The registry registers a single code per module. If the resolver returns
// multiple modules (Strategy 4: multiple standalone skills), accept only
// the entry whose code matches the registry. Other entries are ignored —
// they belong to plugins not registered in the community catalog.
const matched = resolved.find((mod) => mod.code === moduleInfo.code) || (resolved.length === 1 ? resolved[0] : null);
if (!matched) return;
// Stamp registry/clone provenance so installFromResolution and downstream
// manifest writers see the same channel/sha/tag as the legacy path.
matched.code = moduleInfo.code;
matched.repoUrl = moduleInfo.url;
matched.cloneRef = resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null;
matched.cloneSha = resolution.sha;
matched.communitySource = true;
matched.communityChannel = resolution.channel;
matched.communityVersion = resolution.version;
matched.registryApprovedTag = resolution.approvedTag;
matched.registryApprovedSha = resolution.approvedSha;
CommunityModuleManager._pluginResolutions.set(moduleInfo.code, matched);
}
/**
* Pick which plugin entry from marketplace.json represents this community module.
* Precedence:
* 1. Exact match on `plugin.name === moduleInfo.code`
* 2. Trailing directory of `module_definition` matches `plugin.name`
* 3. Single plugin in marketplace.json — use it
* Otherwise null (caller falls back to legacy path).
*/
_selectPluginForModule(plugins, moduleInfo) {
const byCode = plugins.find((p) => p && p.name === moduleInfo.code);
if (byCode) return byCode;
if (moduleInfo.moduleDefinition) {
// module_definition like "src/skills/suno-setup/assets/module.yaml" →
// hint segment "suno-setup". Match that against plugin names.
const segments = moduleInfo.moduleDefinition.split('/').filter(Boolean);
const setupIdx = segments.findIndex((s) => s.endsWith('-setup'));
if (setupIdx !== -1) {
const hint = segments[setupIdx];
const byHint = plugins.find((p) => p && p.name === hint);
if (byHint) return byHint;
}
}
if (plugins.length === 1) return plugins[0];
return null;
}
// ─── 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 };