From 1d8333d502821012d4e22538a916defec60eeab4 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Sun, 17 May 2026 17:20:25 -0500 Subject: [PATCH] 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. --- CHANGELOG.md | 2 +- test/test-installation-components.js | 286 +------- tools/installer/core/installer.js | 23 +- tools/installer/core/manifest.js | 22 - tools/installer/modules/community-manager.js | 709 ------------------- tools/installer/modules/official-modules.js | 57 +- tools/installer/modules/registry-client.js | 187 ----- tools/installer/ui.js | 22 +- 8 files changed, 19 insertions(+), 1289 deletions(-) delete mode 100644 tools/installer/modules/community-manager.js delete mode 100644 tools/installer/modules/registry-client.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 89d07e96b..6872e1d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, 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 ` going forward (a dedicated community installer is planned separately). ### 🎁 Features diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 0a5ebed5b..808ee6faa 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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(''); // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index ed04b07d1..e2962a5df 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -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(); diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index d604bf2fe..a7931bf32 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -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(); diff --git a/tools/installer/modules/community-manager.js b/tools/installer/modules/community-manager.js deleted file mode 100644 index f2109684a..000000000 --- a/tools/installer/modules/community-manager.js +++ /dev/null @@ -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} 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} 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} 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} 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} 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}= 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} - */ - 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 }; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 615daba86..e80b0a56e 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -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//` - // 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 || ''), }, }; } diff --git a/tools/installer/modules/registry-client.js b/tools/installer/modules/registry-client.js deleted file mode 100644 index 31a38f8d3..000000000 --- a/tools/installer/modules/registry-client.js +++ /dev/null @@ -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} 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} 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} 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} 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} 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 }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index c593dc483..618a2145b 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -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