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)
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
if (this.installerConfig?.legacy_targets) {
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);
const legacyDirsExist = await Promise.all(
this.installerConfig.legacy_targets.map((d) =>
this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
),
);
if (legacyDirsExist.some(Boolean)) {
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 os = require('node:os');
const path = require('node:path');
const https = require('node:https');
const { execSync } = require('node:child_process');
const yaml = require('yaml');
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
* These are modules hosted in external repositories that can be installed
* Manages official modules from the remote BMad marketplace registry.
* Fetches registry/official.yaml from GitHub; falls back to the bundled
* external-official-modules.yaml when the network is unavailable.
*
* @class ExternalModuleManager
*/
class ExternalModuleManager {
constructor() {
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
this.cachedModules = null;
constructor() {}
/**
* 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
* @returns {Object} Parsed YAML content with modules object
* Load the official modules registry from GitHub, falling back to the
* bundled YAML file if the fetch fails.
* @returns {Object} Parsed YAML content with modules array
*/
async loadExternalModulesConfig() {
if (this.cachedModules) {
return this.cachedModules;
}
// Try remote registry first
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);
this.cachedModules = config;
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
return config;
} catch (error) {
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
return { modules: {} };
await prompts.log.warn(`Failed to load modules config: ${error.message}`);
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
*/
async listAvailable() {
const config = await this.loadExternalModulesConfig();
const modules = [];
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
modules.push({
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,
});
// Remote format: modules is an array
if (Array.isArray(config.modules)) {
return config.modules.map((mod) => this._normalizeModule(mod));
}
// 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;
}
@ -81,27 +142,8 @@ class ExternalModuleManager {
* @returns {Object|null} Module info or null if not found
*/
async getModuleByKey(key) {
const config = await this.loadExternalModulesConfig();
const moduleConfig = config.modules?.[key];
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,
};
const modules = await this.listAvailable();
return modules.find((m) => m.key === key) || null;
}
/**
@ -154,7 +196,7 @@ class ExternalModuleManager {
const moduleInfo = await this.getModuleByCode(moduleCode);
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();
@ -304,7 +346,7 @@ class ExternalModuleManager {
async findExternalModuleSource(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) {
if (!moduleInfo || moduleInfo.builtIn) {
return null;
}
@ -349,6 +391,7 @@ class ExternalModuleManager {
// Nothing found: return configured path (preserves old behavior for error messaging)
return path.dirname(configuredPath);
}
cachedModules = null;
}
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
# allowing us to keep the source of these projects in separate repos.
# Fallback module registry — used only when the BMad Marketplace repo
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
# The remote registry/official.yaml is the source of truth.
modules:
bmad-builder:
@ -41,13 +42,3 @@ modules:
defaultSelected: false
type: bmad-org
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)
*/
async selectAllModules(installedModuleIds = new Set()) {
const { OfficialModules } = require('./modules/official-modules');
const officialModulesSource = new OfficialModules();
const { modules: localModules } = await officialModulesSource.listAvailable();
// Get external modules
// Registry is the single source of truth for the module list
const externalManager = new ExternalModuleManager();
const externalModules = await externalManager.listAvailable();
const registryModules = await externalManager.listAvailable();
// Build flat options list with group hints for autocompleteMultiselect
const allOptions = [];
const initialValues = [];
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
const buildModuleEntry = async (mod, value, group) => {
const isInstalled = installedModuleIds.has(value);
const version = await getMarketplaceVersion(value);
const buildModuleEntry = async (mod) => {
const isInstalled = installedModuleIds.has(mod.code);
const version = await getMarketplaceVersion(mod.code);
const label = version ? `${mod.name} (v${version})` : mod.name;
return {
label,
value,
hint: mod.description || group,
// Pre-select only if already installed (not on fresh install)
value: mod.code,
hint: mod.description,
selected: isInstalled,
};
};
// Local modules (BMM, BMB, etc.)
const localEntries = [];
for (const mod of localModules) {
if (mod.id !== 'core') {
const entry = await buildModuleEntry(mod, mod.id, 'Local');
localEntries.push(entry);
if (entry.selected) {
initialValues.push(mod.id);
}
// Registry order is display order; core is always locked
for (const mod of registryModules) {
const entry = await buildModuleEntry(mod);
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
if (entry.selected) {
initialValues.push(mod.code);
}
}
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({
message: 'Select modules to install:',
@ -670,16 +629,14 @@ class UI {
* @returns {Array} Default module codes
*/
async getDefaultModules(installedModuleIds = new Set()) {
const { OfficialModules } = require('./modules/official-modules');
const officialModules = new OfficialModules();
const { modules: localModules } = await officialModules.listAvailable();
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
const defaultModules = [];
// Add default-selected local modules (typically BMM)
for (const mod of localModules) {
if (mod.defaultSelected === true || installedModuleIds.has(mod.id)) {
defaultModules.push(mod.id);
for (const mod of registryModules) {
if (mod.defaultSelected || installedModuleIds.has(mod.code)) {
defaultModules.push(mod.code);
}
}