diff --git a/tools/installer/ide/_config-driven.js b/tools/installer/ide/_config-driven.js index 15791e112..9c7df4bc5 100644 --- a/tools/installer/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -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); + } } } } diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index fceb94e22..db70a6678 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -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} 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} 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 }; diff --git a/tools/installer/external-official-modules.yaml b/tools/installer/modules/registry-fallback.yaml similarity index 71% rename from tools/installer/external-official-modules.yaml rename to tools/installer/modules/registry-fallback.yaml index b62f3dc21..29b2cc07d 100644 --- a/tools/installer/external-official-modules.yaml +++ b/tools/installer/modules/registry-fallback.yaml @@ -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 diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 9b8812f8a..2c5c34479 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -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); } }