feat(installer): fetch module list from marketplace registry

Switch module list source of truth from bundled
external-official-modules.yaml to the remote marketplace registry
(registry/official.yaml) fetched via raw.githubusercontent.com.

- Rewrite ExternalModuleManager to fetch from GitHub with local fallback
- Simplify selectAllModules/getDefaultModules to use registry as single source
- Registry order controls display order; built_in flag prevents cloning
- Rename fallback file to registry-fallback.yaml in modules/
- Only show legacy migration message when legacy dirs actually exist
This commit is contained in:
Brian Madison 2026-04-07 22:40:06 -05:00
parent 99b7940df7
commit 4fd882ffd7
4 changed files with 128 additions and 130 deletions

View File

@ -225,13 +225,20 @@ class ConfigDrivenIdeSetup {
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents) // Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet) // Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
if (this.installerConfig?.legacy_targets) { if (this.installerConfig?.legacy_targets) {
if (!options.silent) await prompts.log.message(' Migrating legacy directories...'); const legacyDirsExist = await Promise.all(
for (const legacyDir of this.installerConfig.legacy_targets) { this.installerConfig.legacy_targets.map((d) =>
if (this.isGlobalPath(legacyDir)) { this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
await this.warnGlobalLegacy(legacyDir, options); ),
} else { );
await this.cleanupTarget(projectDir, legacyDir, options, null); if (legacyDirsExist.some(Boolean)) {
await this.removeEmptyParents(projectDir, legacyDir); if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
for (const legacyDir of this.installerConfig.legacy_targets) {
if (this.isGlobalPath(legacyDir)) {
await this.warnGlobalLegacy(legacyDir, options);
} else {
await this.cleanupTarget(projectDir, legacyDir, options, null);
await this.removeEmptyParents(projectDir, legacyDir);
}
} }
} }
} }

View File

@ -1,67 +1,128 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const https = require('node:https');
const { execSync } = require('node:child_process'); const { execSync } = require('node:child_process');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../prompts'); const prompts = require('../prompts');
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
/** /**
* Manages external official modules defined in external-official-modules.yaml * Manages official modules from the remote BMad marketplace registry.
* These are modules hosted in external repositories that can be installed * Fetches registry/official.yaml from GitHub; falls back to the bundled
* external-official-modules.yaml when the network is unavailable.
* *
* @class ExternalModuleManager * @class ExternalModuleManager
*/ */
class ExternalModuleManager { class ExternalModuleManager {
constructor() { constructor() {}
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
this.cachedModules = null; /**
* Fetch a URL and return the response body as a string.
* @param {string} url - URL to fetch
* @param {number} timeout - Timeout in ms (default 10s)
* @returns {Promise<string>} Response body
*/
_fetch(url, timeout = 10_000) {
return new Promise((resolve, reject) => {
const req = https
.get(url, { timeout }, (res) => {
// Follow one redirect (GitHub sometimes 301s)
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return this._fetch(res.headers.location, timeout).then(resolve, reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data));
})
.on('error', reject)
.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
});
} }
/** /**
* Load and parse the external-official-modules.yaml file * Load the official modules registry from GitHub, falling back to the
* @returns {Object} Parsed YAML content with modules object * bundled YAML file if the fetch fails.
* @returns {Object} Parsed YAML content with modules array
*/ */
async loadExternalModulesConfig() { async loadExternalModulesConfig() {
if (this.cachedModules) { if (this.cachedModules) {
return this.cachedModules; return this.cachedModules;
} }
// Try remote registry first
try { try {
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8'); const content = await this._fetch(REGISTRY_RAW_URL);
const config = yaml.parse(content);
if (config?.modules?.length) {
this.cachedModules = config;
return config;
}
} catch {
// Fall through to local fallback
}
// Fallback to bundled file
try {
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
const config = yaml.parse(content); const config = yaml.parse(content);
this.cachedModules = config; this.cachedModules = config;
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
return config; return config;
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to load external modules config: ${error.message}`); await prompts.log.warn(`Failed to load modules config: ${error.message}`);
return { modules: {} }; return { modules: [] };
} }
} }
/** /**
* Get list of available external modules * Normalize a module entry from either the remote registry format
* (snake_case, array) or the legacy bundled format (kebab-case, object map).
* @param {Object} mod - Raw module config from YAML
* @param {string} [key] - Key name (only for legacy map format)
* @returns {Object} Normalized module info
*/
_normalizeModule(mod, key) {
return {
key: key || mod.name,
url: mod.repository || mod.url,
moduleDefinition: mod.module_definition || mod['module-definition'],
code: mod.code,
name: mod.display_name || mod.name,
description: mod.description || '',
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
type: mod.type || 'bmad-org',
npmPackage: mod.npm_package || mod.npmPackage || null,
builtIn: mod.built_in === true,
isExternal: mod.built_in !== true,
};
}
/**
* Get list of available modules from the registry
* @returns {Array<Object>} Array of module info objects * @returns {Array<Object>} Array of module info objects
*/ */
async listAvailable() { async listAvailable() {
const config = await this.loadExternalModulesConfig(); const config = await this.loadExternalModulesConfig();
const modules = [];
for (const [key, moduleConfig] of Object.entries(config.modules || {})) { // Remote format: modules is an array
modules.push({ if (Array.isArray(config.modules)) {
key, return config.modules.map((mod) => this._normalizeModule(mod));
url: moduleConfig.url,
moduleDefinition: moduleConfig['module-definition'],
code: moduleConfig.code,
name: moduleConfig.name,
header: moduleConfig.header,
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
isExternal: true,
});
} }
// Legacy bundled format: modules is an object map
const modules = [];
for (const [key, mod] of Object.entries(config.modules || {})) {
modules.push(this._normalizeModule(mod, key));
}
return modules; return modules;
} }
@ -81,27 +142,8 @@ class ExternalModuleManager {
* @returns {Object|null} Module info or null if not found * @returns {Object|null} Module info or null if not found
*/ */
async getModuleByKey(key) { async getModuleByKey(key) {
const config = await this.loadExternalModulesConfig(); const modules = await this.listAvailable();
const moduleConfig = config.modules?.[key]; return modules.find((m) => m.key === key) || null;
if (!moduleConfig) {
return null;
}
return {
key,
url: moduleConfig.url,
moduleDefinition: moduleConfig['module-definition'],
code: moduleConfig.code,
name: moduleConfig.name,
header: moduleConfig.header,
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
isExternal: true,
};
} }
/** /**
@ -154,7 +196,7 @@ class ExternalModuleManager {
const moduleInfo = await this.getModuleByCode(moduleCode); const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) { if (!moduleInfo) {
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`); throw new Error(`External module '${moduleCode}' not found in the BMad registry`);
} }
const cacheDir = this.getExternalCacheDir(); const cacheDir = this.getExternalCacheDir();
@ -304,7 +346,7 @@ class ExternalModuleManager {
async findExternalModuleSource(moduleCode, options = {}) { async findExternalModuleSource(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode); const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) { if (!moduleInfo || moduleInfo.builtIn) {
return null; return null;
} }
@ -349,6 +391,7 @@ class ExternalModuleManager {
// Nothing found: return configured path (preserves old behavior for error messaging) // Nothing found: return configured path (preserves old behavior for error messaging)
return path.dirname(configuredPath); return path.dirname(configuredPath);
} }
cachedModules = null;
} }
module.exports = { ExternalModuleManager }; module.exports = { ExternalModuleManager };

View File

@ -1,5 +1,6 @@
# This file allows these modules under bmad-code-org to also be installed with the bmad method installer, while # Fallback module registry — used only when the BMad Marketplace repo
# allowing us to keep the source of these projects in separate repos. # (bmad-code-org/bmad-plugins-marketplace) is unreachable.
# The remote registry/official.yaml is the source of truth.
modules: modules:
bmad-builder: bmad-builder:
@ -41,13 +42,3 @@ modules:
defaultSelected: false defaultSelected: false
type: bmad-org type: bmad-org
npmPackage: bmad-method-test-architecture-enterprise npmPackage: bmad-method-test-architecture-enterprise
whiteport-design-studio:
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
module-definition: src/module.yaml
code: wds
name: "Whiteport Design Studio (For UX Professionals)"
description: "Whiteport Design Studio (For UX Professionals)"
defaultSelected: false
type: community
npmPackage: bmad-method-wds-expansion

View File

@ -569,77 +569,36 @@ class UI {
* @returns {Array} Selected module codes (excluding core) * @returns {Array} Selected module codes (excluding core)
*/ */
async selectAllModules(installedModuleIds = new Set()) { async selectAllModules(installedModuleIds = new Set()) {
const { OfficialModules } = require('./modules/official-modules'); // Registry is the single source of truth for the module list
const officialModulesSource = new OfficialModules();
const { modules: localModules } = await officialModulesSource.listAvailable();
// Get external modules
const externalManager = new ExternalModuleManager(); const externalManager = new ExternalModuleManager();
const externalModules = await externalManager.listAvailable(); const registryModules = await externalManager.listAvailable();
// Build flat options list with group hints for autocompleteMultiselect // Build flat options list with group hints for autocompleteMultiselect
const allOptions = []; const allOptions = [];
const initialValues = []; const initialValues = [];
const lockedValues = ['core']; const lockedValues = ['core'];
// Core module is always installed — show it locked at the top
const coreVersion = await getMarketplaceVersion('core');
const coreLabel = coreVersion ? `BMad Core Module (v${coreVersion})` : 'BMad Core Module';
allOptions.push({ label: coreLabel, value: 'core', hint: 'Core configuration and shared resources' });
initialValues.push('core');
// Helper to build module entry with proper sorting and selection // Helper to build module entry with proper sorting and selection
const buildModuleEntry = async (mod, value, group) => { const buildModuleEntry = async (mod) => {
const isInstalled = installedModuleIds.has(value); const isInstalled = installedModuleIds.has(mod.code);
const version = await getMarketplaceVersion(value); const version = await getMarketplaceVersion(mod.code);
const label = version ? `${mod.name} (v${version})` : mod.name; const label = version ? `${mod.name} (v${version})` : mod.name;
return { return {
label, label,
value, value: mod.code,
hint: mod.description || group, hint: mod.description,
// Pre-select only if already installed (not on fresh install)
selected: isInstalled, selected: isInstalled,
}; };
}; };
// Local modules (BMM, BMB, etc.) // Registry order is display order; core is always locked
const localEntries = []; for (const mod of registryModules) {
for (const mod of localModules) { const entry = await buildModuleEntry(mod);
if (mod.id !== 'core') { allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
const entry = await buildModuleEntry(mod, mod.id, 'Local'); if (entry.selected) {
localEntries.push(entry); initialValues.push(mod.code);
if (entry.selected) {
initialValues.push(mod.id);
}
} }
} }
allOptions.push(...localEntries.map(({ label, value, hint }) => ({ label, value, hint })));
// Group 2: BMad Official Modules (type: bmad-org)
const officialModules = [];
for (const mod of externalModules) {
if (mod.type === 'bmad-org') {
const entry = await buildModuleEntry(mod, mod.code, 'Official');
officialModules.push(entry);
if (entry.selected) {
initialValues.push(mod.code);
}
}
}
allOptions.push(...officialModules.map(({ label, value, hint }) => ({ label, value, hint })));
// Group 3: Community Modules (type: community)
const communityModules = [];
for (const mod of externalModules) {
if (mod.type === 'community') {
const entry = await buildModuleEntry(mod, mod.code, 'Community');
communityModules.push(entry);
if (entry.selected) {
initialValues.push(mod.code);
}
}
}
allOptions.push(...communityModules.map(({ label, value, hint }) => ({ label, value, hint })));
const selected = await prompts.autocompleteMultiselect({ const selected = await prompts.autocompleteMultiselect({
message: 'Select modules to install:', message: 'Select modules to install:',
@ -670,16 +629,14 @@ class UI {
* @returns {Array} Default module codes * @returns {Array} Default module codes
*/ */
async getDefaultModules(installedModuleIds = new Set()) { async getDefaultModules(installedModuleIds = new Set()) {
const { OfficialModules } = require('./modules/official-modules'); const externalManager = new ExternalModuleManager();
const officialModules = new OfficialModules(); const registryModules = await externalManager.listAvailable();
const { modules: localModules } = await officialModules.listAvailable();
const defaultModules = []; const defaultModules = [];
// Add default-selected local modules (typically BMM) for (const mod of registryModules) {
for (const mod of localModules) { if (mod.defaultSelected || installedModuleIds.has(mod.code)) {
if (mod.defaultSelected === true || installedModuleIds.has(mod.id)) { defaultModules.push(mod.code);
defaultModules.push(mod.id);
} }
} }