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:
parent
8a31b20774
commit
1d8333d502
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 || ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue