feat(installer): fully retire community catalog plumbing

Removes the last marketplace network connections from the installer.
The v6.7.0 first pass retired the official-registry fetch but left
CommunityModuleManager + RegistryClient in place, which still
fetched community-index.yaml and categories.yaml on every install
to support the channel-gate and update flows.

This commit:

- Deletes tools/installer/modules/community-manager.js and
  registry-client.js entirely.
- Strips CommunityModuleManager calls from ui.js (channel gate +
  update channels), core/manifest.js (getModuleVersionInfo),
  core/installer.js (resolution + installed-modules listing), and
  modules/official-modules.js (findModuleSource fallback +
  pre-install plugin resolution + post-install manifest entry).
- Simplifies installFromResolution: community branch removed; all
  non-external installs are now treated as custom-source.
- Removes corresponding test suites (CommunityModuleManager unit
  tests and the entire RegistryClient suite).
- Updates CHANGELOG with the migration note.

After this commit, grep confirms zero references to the bmad-plugins-
marketplace registry from the installer. The only remaining 'marketplace'
references are about per-repo .claude-plugin/marketplace.json files,
which the installer reads from cloned custom-source repos.
This commit is contained in:
Brian Madison 2026-05-17 17:20:25 -05:00
parent 8a31b20774
commit 1d8333d502
8 changed files with 19 additions and 1289 deletions

View File

@ -15,7 +15,7 @@ The shape of the toml customizations is still the same, so if you make them for
### 💥 Breaking Changes
* **Community modules picker removed from the interactive installer.** Previously installed community modules are preserved on update. Install community modules headlessly with `--custom-source <git-url-or-path>`, or wait for the forthcoming dedicated community installer.
* **Remote marketplace registry retired.** The installer no longer fetches `registry/official.yaml` from `bmad-code-org/bmad-plugins-marketplace`. The bundled module list, now at `bmad-modules.yaml` in the repo root (renamed from `tools/installer/modules/registry-fallback.yaml`), is the single source of truth for which official modules appear in the picker. Per-module version bumps continue to happen in each module's own repo.
* **Remote marketplace registry fully retired.** The installer makes zero network calls to `bmad-code-org/bmad-plugins-marketplace`. Both the official-registry fetch (`registry/official.yaml`) and the community-catalog fetch (`registry/community-index.yaml`, `categories.yaml`) are gone. `CommunityModuleManager` and `RegistryClient` are deleted. The bundled `bmad-modules.yaml` at the repo root is the single source of truth for which official modules appear in the picker. Per-module version bumps continue to happen in each module's own repo. **Migration note:** users with previously installed community modules will see them preserved in their manifest, but updates must be handled via `--custom-source <url>` going forward (a dedicated community installer is planned separately).
### 🎁 Features

View File

@ -1666,9 +1666,9 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 33: Community & Custom Module Managers
// Test Suite 33: Custom Module Managers
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
console.log(`${colors.yellow}Test Suite 33: Custom Module Managers${colors.reset}\n`);
// --- CustomModuleManager._normalizeCustomModule ---
{
@ -1690,288 +1690,6 @@ async function runTests() {
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');
}
console.log('');
// ============================================================
// Test Suite 34: RegistryClient GitHub API Cascade
// ============================================================
console.log(`${colors.yellow}Test Suite 34: RegistryClient GitHub API Cascade${colors.reset}\n`);
{
const { RegistryClient } = require('../tools/installer/modules/registry-client');
// Build a RegistryClient with stubbed fetch paths so we can assert on cascade behavior
// without making real network calls.
function createStubbedClient({ apiResult, rawResult }) {
const client = new RegistryClient();
const calls = [];
// Stub _fetchWithHeaders (GitHub API path)
client._fetchWithHeaders = async (url) => {
calls.push(`api:${url}`);
if (apiResult instanceof Error) throw apiResult;
return apiResult;
};
// Stub fetch (raw CDN path) — only intercept raw.githubusercontent.com calls
const originalFetch = client.fetch.bind(client);
client.fetch = async (url, timeout) => {
if (url.includes('raw.githubusercontent.com')) {
calls.push(`raw:${url}`);
if (rawResult instanceof Error) throw rawResult;
return rawResult;
}
return originalFetch(url, timeout);
};
return { client, calls };
}
// --- API success skips raw CDN ---
{
const { client, calls } = createStubbedClient({ apiResult: 'api-content', rawResult: 'raw-content' });
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
assert(result === 'api-content', 'RegistryClient API success returns API content');
assert(calls.length === 1, 'RegistryClient API success makes exactly one call');
assert(calls[0].startsWith('api:'), 'RegistryClient API success calls API endpoint');
}
// --- API failure falls back to raw CDN ---
{
const { client, calls } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: 'raw-content' });
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
assert(result === 'raw-content', 'RegistryClient API failure returns raw CDN content');
assert(calls.length === 2, 'RegistryClient API failure makes two calls');
assert(calls[0].startsWith('api:'), 'RegistryClient first call is to API');
assert(calls[1].startsWith('raw:'), 'RegistryClient second call is to raw CDN');
}
// --- Both endpoints failing throws ---
{
const { client } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: new Error('HTTP 404') });
let threw = false;
try {
await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
} catch {
threw = true;
}
assert(threw, 'RegistryClient both endpoints failing throws an error');
}
// --- API URL construction ---
{
const { client, calls } = createStubbedClient({ apiResult: 'content', rawResult: 'content' });
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
const apiCall = calls[0];
assert(
apiCall.includes('api.github.com/repos/bmad-code-org/bmad-plugins-marketplace/contents/registry/official.yaml'),
'RegistryClient API URL contains correct path',
);
assert(apiCall.includes('ref=main'), 'RegistryClient API URL contains ref parameter');
}
// --- Raw CDN URL construction ---
{
const { client, calls } = createStubbedClient({ apiResult: new Error('fail'), rawResult: 'content' });
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
const rawCall = calls[1];
assert(
rawCall.includes('raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'),
'RegistryClient raw CDN URL contains correct path',
);
}
// --- fetchGitHubYaml parses YAML ---
{
const yamlContent = 'modules:\n - name: test\n description: A test module\n';
const { client } = createStubbedClient({ apiResult: yamlContent, rawResult: yamlContent });
const result = await client.fetchGitHubYaml('owner', 'repo', 'file.yaml', 'main');
assert(Array.isArray(result.modules), 'fetchGitHubYaml parses YAML correctly');
assert(result.modules[0].name === 'test', 'fetchGitHubYaml preserves YAML values');
}
}
console.log('');
// ============================================================

View File

@ -640,13 +640,7 @@ class Installer {
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
const displayName = moduleInfo?.name || moduleName;
const externalResolution = officialModules.externalModuleManager.getResolution(moduleName);
let communityResolution = null;
if (!externalResolution) {
const { CommunityModuleManager } = require('../modules/community-manager');
communityResolution = new CommunityModuleManager().getResolution(moduleName);
}
const resolution = externalResolution || communityResolution;
const resolution = officialModules.externalModuleManager.getResolution(moduleName);
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath: sourcePath,
@ -1178,21 +1172,6 @@ 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();

View File

@ -310,28 +310,6 @@ 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 communityResolution = communityMgr.getResolution(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath,
fallbackVersion: communityInfo.version,
});
return {
version: communityResolution?.version || versionInfo.version || communityInfo.version,
source: 'community',
npmPackage: communityInfo.npmPackage || null,
repoUrl: communityInfo.url || null,
channel: communityResolution?.channel || null,
sha: communityResolution?.sha || null,
registryApprovedTag: communityResolution?.registryApprovedTag || null,
registryApprovedSha: communityResolution?.registryApprovedSha || null,
};
}
// Check if this is a custom module (from user-provided URL or local path)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();

View File

@ -1,709 +0,0 @@
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 selection = this._selectPluginForModule(plugins, moduleInfo);
if (!selection) {
await this._safeWarn(
`Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` +
`Falling back to legacy install path.`,
);
return;
}
if (selection.source === 'single-fallback') {
// Single-entry marketplace.json whose plugin name doesn't match the registry
// code or the module_definition hint. Most likely correct, but worth surfacing
// in case marketplace.json is misconfigured and we'd install the wrong plugin.
await this._safeWarn(
`Community module '${moduleInfo.code}' picked the only plugin in marketplace.json ('${selection.plugin?.name}') ` +
`because no name or module_definition match was found. Verify marketplace.json if the install looks wrong.`,
);
}
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
let resolved;
try {
resolved = await resolver.resolve(repoPath, selection.plugin);
} catch (error) {
// PluginResolver threw (malformed plugin entry, missing files, etc.).
// Honor the silent-fallthrough contract — warn and let the legacy
// findModuleSource path handle the install.
await this._safeWarn(
`PluginResolver failed for community module '${moduleInfo.code}': ${error.message}. ` + `Falling back to legacy install path.`,
);
return;
}
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;
// Shallow-clone before stamping provenance — the resolver may cache or reuse
// its return objects, and we don't want install-specific fields leaking back.
const stamped = {
...matched,
code: moduleInfo.code,
repoUrl: moduleInfo.url,
cloneRef: resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null,
cloneSha: resolution.sha,
communitySource: true,
communityChannel: resolution.channel,
communityVersion: resolution.version,
registryApprovedTag: resolution.approvedTag,
registryApprovedSha: resolution.approvedSha,
};
CommunityModuleManager._pluginResolutions.set(moduleInfo.code, stamped);
}
/**
* Lazy fallback: resolve marketplace.json straight from the on-disk cache
* when `_pluginResolutions` is empty (e.g. callers that reach `install()`
* without `cloneModule` having populated the cache earlier in this process).
*
* Reuses an existing channel resolution if present; otherwise synthesizes a
* minimal stable-channel stub from the registry entry + the cached repo's
* current HEAD. Returns the cached plugin resolution if one is produced,
* otherwise null (caller falls back to the legacy path).
*
* @param {string} moduleCode
* @returns {Promise<Object|null>}
*/
async resolveFromCache(moduleCode) {
const existing = this.getPluginResolution(moduleCode);
if (existing) return existing;
const cacheRepoDir = path.join(this.getCacheDir(), moduleCode);
const marketplacePath = path.join(cacheRepoDir, '.claude-plugin', 'marketplace.json');
if (!(await fs.pathExists(marketplacePath))) return null;
let moduleInfo;
try {
moduleInfo = await this.getModuleByCode(moduleCode);
} catch {
return null;
}
if (!moduleInfo) return null;
let channelResolution = this.getResolution(moduleCode);
if (!channelResolution) {
let sha = '';
try {
sha = execSync('git rev-parse HEAD', { cwd: cacheRepoDir, stdio: 'pipe' }).toString().trim();
} catch {
// Not a git repo or unreadable — give up and let the legacy path run.
return null;
}
channelResolution = {
channel: 'stable',
version: moduleInfo.approvedTag || sha.slice(0, 7),
sha,
registryApprovedTag: moduleInfo.approvedTag || null,
registryApprovedSha: moduleInfo.approvedSha || null,
};
}
await this._tryResolveMarketplacePlugin(cacheRepoDir, moduleInfo, {
channel: channelResolution.channel,
version: channelResolution.version,
sha: channelResolution.sha,
approvedTag: channelResolution.registryApprovedTag,
approvedSha: channelResolution.registryApprovedSha,
});
return this.getPluginResolution(moduleCode);
}
/**
* Best-effort warning emitter. `prompts.log.warn` may be undefined in some
* harnesses and may return a rejected promise swallow both cases so a
* fallthrough warning can never crash the install.
*/
async _safeWarn(message) {
try {
const result = prompts.log?.warn?.(message);
if (result && typeof result.then === 'function') await result;
} catch {
/* ignore */
}
}
/**
* 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 accepted with a warning so a
* mismatched-but-uniquely-named plugin doesn't install silently.
* Otherwise null (caller falls back to legacy path).
*
* @returns {{plugin: Object, source: 'name'|'hint'|'single-fallback'}|null}
*/
_selectPluginForModule(plugins, moduleInfo) {
if (moduleInfo.pluginName) {
const byPluginName = plugins.find((p) => p && p.name === moduleInfo.pluginName);
if (byPluginName) return { plugin: byPluginName, source: 'plugin-name' };
}
const byCode = plugins.find((p) => p && p.name === moduleInfo.code);
if (byCode) return { plugin: byCode, source: 'name' };
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 { plugin: byHint, source: 'hint' };
}
}
if (plugins.length === 1) return { plugin: plugins[0], source: 'single-fallback' };
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 };

View File

@ -231,14 +231,6 @@ class OfficialModules {
return externalSource;
}
// Check community modules (pass channelOptions for --next/--pin overrides)
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();
@ -269,21 +261,6 @@ class OfficialModules {
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
}
// Community modules whose cloned repo ships marketplace.json get the same
// skill-level install treatment as custom-source installs. If the in-process
// cache wasn't populated (e.g. caller skipped the pre-clone phase), fall
// back to resolving directly from `~/.bmad/cache/community-modules/<name>/`
// so we don't silently regress to the legacy half-install path.
const { CommunityModuleManager } = require('./community-manager');
const communityMgr = new CommunityModuleManager();
let communityResolved = communityMgr.getPluginResolution(moduleName);
if (!communityResolved) {
communityResolved = await communityMgr.resolveFromCache(moduleName);
}
if (communityResolved) {
return this.installFromResolution(communityResolved, bmadDir, fileTrackingCallback, options);
}
const sourcePath = await this.findModuleSource(moduleName, {
silent: options.silent,
channelOptions: options.channelOptions,
@ -310,14 +287,9 @@ class OfficialModules {
const manifestObj = new Manifest();
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
// Pick up channel resolution recorded by whichever manager did the clone.
const externalResolution = this.externalModuleManager.getResolution(moduleName);
let communityResolution = null;
if (!externalResolution) {
const { CommunityModuleManager } = require('./community-manager');
communityResolution = new CommunityModuleManager().getResolution(moduleName);
}
const resolution = externalResolution || communityResolution;
// Pick up channel resolution recorded by the external manager (the only
// manager that does pre-clone resolution now that community is retired).
const resolution = this.externalModuleManager.getResolution(moduleName);
await manifestObj.addModule(bmadDir, moduleName, {
version: resolution?.version || versionInfo.version,
@ -326,8 +298,6 @@ class OfficialModules {
repoUrl: versionInfo.repoUrl,
channel: resolution?.channel,
sha: resolution?.sha,
registryApprovedTag: communityResolution?.registryApprovedTag,
registryApprovedSha: communityResolution?.registryApprovedSha,
});
return { success: true, module: moduleName, path: targetPath, versionInfo };
@ -375,27 +345,19 @@ class OfficialModules {
await this.createModuleDirectories(resolved.code, bmadDir, options);
}
// Update manifest. For community installs we honor the channel resolved by
// CommunityModuleManager (stable/next/pinned) and propagate the registry's
// approved tag/sha. For custom-source installs we derive channel from the
// Update manifest. For custom-source installs we derive channel from the
// cloneRef (present → pinned, absent → next; local paths have no channel).
const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest();
const hasGitClone = !!resolved.repoUrl;
const isCommunity = resolved.communitySource === true;
const manifestEntry = {
version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
source: isCommunity ? 'community' : 'custom',
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
source: 'custom',
npmPackage: null,
repoUrl: resolved.repoUrl || null,
};
if (isCommunity) {
if (resolved.communityChannel) manifestEntry.channel = resolved.communityChannel;
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
if (resolved.registryApprovedTag) manifestEntry.registryApprovedTag = resolved.registryApprovedTag;
if (resolved.registryApprovedSha) manifestEntry.registryApprovedSha = resolved.registryApprovedSha;
} else if (hasGitClone) {
if (hasGitClone) {
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
@ -408,11 +370,10 @@ class OfficialModules {
module: resolved.code,
path: targetPath,
// Mirror the manifestEntry.version precedence above so downstream summary
// lines show the same string we just wrote to disk (community installs
// use the registry-approved tag via `communityVersion`; custom git-backed
// lines show the same string we just wrote to disk (custom git-backed
// installs show the cloned ref or 'main').
versionInfo: {
version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
},
};
}

View File

@ -1,187 +0,0 @@
const https = require('node:https');
const yaml = require('yaml');
/**
* Build a rich Error from a non-2xx response. Includes the URL, the GitHub
* JSON error message (or a truncated body snippet), rate-limit reset time,
* and Retry-After anything present that would help a user recover.
*/
function buildHttpError(url, res, body) {
const parts = [`HTTP ${res.statusCode} ${url}`];
if (body) {
try {
const parsed = JSON.parse(body);
if (parsed.message) parts.push(parsed.message);
if (parsed.documentation_url) parts.push(`(see ${parsed.documentation_url})`);
} catch {
const snippet = body.slice(0, 200).trim();
if (snippet) parts.push(snippet);
}
}
const remaining = res.headers['x-ratelimit-remaining'];
const reset = res.headers['x-ratelimit-reset'];
if (remaining === '0' && reset) {
parts.push(`rate limit exhausted; resets at ${new Date(Number(reset) * 1000).toISOString()}`);
}
const retryAfter = res.headers['retry-after'];
if (retryAfter) parts.push(`retry after ${retryAfter}`);
return new Error(parts.join(' — '));
}
/**
* 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 up to 3 redirects (GitHub sometimes 301s).
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms (overrides default)
* @param {number} [maxRedirects=3] - Maximum redirects to follow
* @returns {Promise<string>} Response body
*/
fetch(url, timeout, maxRedirects = 3) {
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) {
if (maxRedirects <= 0) {
return reject(new Error('Too many redirects'));
}
return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject);
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
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 file from a GitHub repo using the Contents API first,
* falling back to raw.githubusercontent.com if the API fails.
*
* The API endpoint (`api.github.com`) is tried first because corporate
* proxies commonly block `raw.githubusercontent.com` while allowing
* `api.github.com` under the "Software Development" category.
*
* @param {string} owner - Repository owner (e.g., 'bmad-code-org')
* @param {string} repo - Repository name (e.g., 'bmad-plugins-marketplace')
* @param {string} filePath - Path within the repo (e.g., 'registry/official.yaml')
* @param {string} ref - Git ref (branch, tag, or SHA; e.g., 'main')
* @param {number} [timeout] - Timeout in ms (overrides default)
* @returns {Promise<string>} Raw file content
*/
async fetchGitHubFile(owner, repo, filePath, ref, timeout) {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${ref}`;
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
// Try GitHub Contents API first (with raw content accept header)
try {
return await this._fetchWithHeaders(apiUrl, { Accept: 'application/vnd.github.raw+json' }, timeout);
} catch (apiError) {
// API failed — fall back to raw CDN
try {
return await this.fetch(rawUrl, timeout);
} catch (cdnError) {
throw new AggregateError([apiError, cdnError], `Both GitHub API and raw CDN failed for ${filePath}`);
}
}
}
/**
* Fetch a file from GitHub and parse as YAML.
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} filePath - Path within the repo
* @param {string} ref - Git ref
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed YAML content
*/
async fetchGitHubYaml(owner, repo, filePath, ref, timeout) {
const content = await this.fetchGitHubFile(owner, repo, filePath, ref, timeout);
return yaml.parse(content);
}
/**
* Fetch a URL with custom headers. Used for GitHub API requests.
* Follows up to 3 redirects.
* @param {string} url - URL to fetch
* @param {Object} headers - Request headers
* @param {number} [timeout] - Timeout in ms
* @param {number} [maxRedirects=3] - Maximum redirects to follow
* @returns {Promise<string>} Response body
* @private
*/
_fetchWithHeaders(url, headers, timeout, maxRedirects = 3) {
const timeoutMs = timeout || this.timeout;
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
timeout: timeoutMs,
headers: {
'User-Agent': 'bmad-installer',
...headers,
},
};
return new Promise((resolve, reject) => {
const req = https
.get(options, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (maxRedirects <= 0) {
return reject(new Error('Too many redirects'));
}
return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject);
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
resolve(data);
});
})
.on('error', reject)
.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
});
}
}
module.exports = { RegistryClient };

View File

@ -1711,19 +1711,14 @@ class UI {
const haveFlagIntent = channelOptions.global || channelOptions.nextSet.size > 0 || channelOptions.pins.size > 0;
if (haveFlagIntent) return;
// Figure out which selected modules actually get a channel (externals +
// community modules). Bundled core/bmm and custom modules skip the picker.
// Figure out which selected modules actually get a channel (externals only).
// Bundled core/bmm and custom modules skip the picker.
const externalManager = new ExternalModuleManager();
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
const community = await communityMgr.listAll();
const communityByCode = new Map(community.map((m) => [m.code, m]));
const channelSelectable = selectedModules.filter((code) => {
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
return info && !info.builtIn;
});
if (channelSelectable.length === 0) return;
@ -1738,7 +1733,7 @@ class UI {
const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver');
for (const code of channelSelectable) {
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
const repoUrl = info.url;
// Try to pre-resolve the top stable tag so we can surface it in the picker.
@ -1813,11 +1808,6 @@ class UI {
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
const community = await communityMgr.listAll();
const communityByCode = new Map(community.map((m) => [m.code, m]));
const { fetchStableTags, classifyUpgrade, releaseNotesUrl } = require('./modules/channel-resolver');
const { parseGitHubRepo } = require('./modules/channel-resolver');
@ -1829,7 +1819,7 @@ class UI {
const existingWithChannel = selectedModules.filter((code) => {
const prev = existingByName.get(code);
if (!prev) return false;
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
return info && !info.builtIn;
});
if (existingWithChannel.length > 0) {
@ -1844,7 +1834,7 @@ class UI {
const prev = existingByName.get(code);
if (!prev) continue;
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
if (!info) continue;
// Bundled modules (core/bmm) ship with the installer binary itself —
// their version is stapled to the CLI version, not a git tag. Skip