const https = require('node:https'); const yaml = require('yaml'); /** * 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} 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); } 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')); }); }); } /** * Fetch a URL and parse the response as YAML. * @param {string} url - URL to fetch * @param {number} [timeout] - Timeout in ms * @returns {Promise} 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} 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} 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} 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); } 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')); }); }); } } module.exports = { RegistryClient };