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:
parent
99b7940df7
commit
4fd882ffd7
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue