Skip to content

Commit d09363b

Browse files
alexeyvclaude
andauthored
feat(installer): use GitHub API as primary fetch with raw CDN fallback (#2248)
* feat(installer): use GitHub API as primary fetch with raw CDN fallback Corporate proxies commonly block raw.githubusercontent.com while allowing api.github.com. Add fetchGitHubFile() to RegistryClient that tries the GitHub Contents API first, falling back to the raw CDN transparently. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(installer): cap redirect depth and preserve dual-fallback errors Add maxRedirects parameter to fetch() and _fetchWithHeaders() to prevent unbounded redirect recursion. Wrap CDN fallback in try/catch and throw AggregateError with both API and CDN errors for better diagnostics. Extract marketplace repo coordinates into named constants in external-manager. * chore(installer): drop unused fetchJson and fetchGitHubJson Neither method has any callers. Also drop the corresponding test. * refactor(test): fold registry tests into test-installation-components No reason for RegistryClient tests to be a separate runner — the same file already tests the registry consumers in Suite 33. Drop test:registry from package.json scripts and quality gate. * fix(installer): include URL, API message, and rate-limit info in HTTP errors Non-2xx responses previously yielded bare `HTTP 403`. Now surface the request URL, GitHub's JSON error message (or body snippet), X-RateLimit-Reset when quota is exhausted, and Retry-After. Turns a mystery 403 into 'rate limit exhausted; resets at 2026-04-15T18:00:00Z' — the difference between 'try GITHUB_TOKEN' and a wild goose chase. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6b964ac commit d09363b

4 files changed

Lines changed: 259 additions & 15 deletions

File tree

test/test-installation-components.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1926,6 +1926,112 @@ async function runTests() {
19261926

19271927
console.log('');
19281928

1929+
// ============================================================
1930+
// Test Suite 34: RegistryClient GitHub API Cascade
1931+
// ============================================================
1932+
console.log(`${colors.yellow}Test Suite 34: RegistryClient GitHub API Cascade${colors.reset}\n`);
1933+
1934+
{
1935+
const { RegistryClient } = require('../tools/installer/modules/registry-client');
1936+
1937+
// Build a RegistryClient with stubbed fetch paths so we can assert on cascade behavior
1938+
// without making real network calls.
1939+
function createStubbedClient({ apiResult, rawResult }) {
1940+
const client = new RegistryClient();
1941+
const calls = [];
1942+
1943+
// Stub _fetchWithHeaders (GitHub API path)
1944+
client._fetchWithHeaders = async (url) => {
1945+
calls.push(`api:${url}`);
1946+
if (apiResult instanceof Error) throw apiResult;
1947+
return apiResult;
1948+
};
1949+
1950+
// Stub fetch (raw CDN path) — only intercept raw.githubusercontent.com calls
1951+
const originalFetch = client.fetch.bind(client);
1952+
client.fetch = async (url, timeout) => {
1953+
if (url.includes('raw.githubusercontent.com')) {
1954+
calls.push(`raw:${url}`);
1955+
if (rawResult instanceof Error) throw rawResult;
1956+
return rawResult;
1957+
}
1958+
return originalFetch(url, timeout);
1959+
};
1960+
1961+
return { client, calls };
1962+
}
1963+
1964+
// --- API success skips raw CDN ---
1965+
{
1966+
const { client, calls } = createStubbedClient({ apiResult: 'api-content', rawResult: 'raw-content' });
1967+
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
1968+
1969+
assert(result === 'api-content', 'RegistryClient API success returns API content');
1970+
assert(calls.length === 1, 'RegistryClient API success makes exactly one call');
1971+
assert(calls[0].startsWith('api:'), 'RegistryClient API success calls API endpoint');
1972+
}
1973+
1974+
// --- API failure falls back to raw CDN ---
1975+
{
1976+
const { client, calls } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: 'raw-content' });
1977+
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
1978+
1979+
assert(result === 'raw-content', 'RegistryClient API failure returns raw CDN content');
1980+
assert(calls.length === 2, 'RegistryClient API failure makes two calls');
1981+
assert(calls[0].startsWith('api:'), 'RegistryClient first call is to API');
1982+
assert(calls[1].startsWith('raw:'), 'RegistryClient second call is to raw CDN');
1983+
}
1984+
1985+
// --- Both endpoints failing throws ---
1986+
{
1987+
const { client } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: new Error('HTTP 404') });
1988+
let threw = false;
1989+
try {
1990+
await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
1991+
} catch {
1992+
threw = true;
1993+
}
1994+
assert(threw, 'RegistryClient both endpoints failing throws an error');
1995+
}
1996+
1997+
// --- API URL construction ---
1998+
{
1999+
const { client, calls } = createStubbedClient({ apiResult: 'content', rawResult: 'content' });
2000+
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
2001+
2002+
const apiCall = calls[0];
2003+
assert(
2004+
apiCall.includes('api.github.com/repos/bmad-code-org/bmad-plugins-marketplace/contents/registry/official.yaml'),
2005+
'RegistryClient API URL contains correct path',
2006+
);
2007+
assert(apiCall.includes('ref=main'), 'RegistryClient API URL contains ref parameter');
2008+
}
2009+
2010+
// --- Raw CDN URL construction ---
2011+
{
2012+
const { client, calls } = createStubbedClient({ apiResult: new Error('fail'), rawResult: 'content' });
2013+
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
2014+
2015+
const rawCall = calls[1];
2016+
assert(
2017+
rawCall.includes('raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'),
2018+
'RegistryClient raw CDN URL contains correct path',
2019+
);
2020+
}
2021+
2022+
// --- fetchGitHubYaml parses YAML ---
2023+
{
2024+
const yamlContent = 'modules:\n - name: test\n description: A test module\n';
2025+
const { client } = createStubbedClient({ apiResult: yamlContent, rawResult: yamlContent });
2026+
const result = await client.fetchGitHubYaml('owner', 'repo', 'file.yaml', 'main');
2027+
2028+
assert(Array.isArray(result.modules), 'fetchGitHubYaml parses YAML correctly');
2029+
assert(result.modules[0].name === 'test', 'fetchGitHubYaml preserves YAML values');
2030+
}
2031+
}
2032+
2033+
console.log('');
2034+
19292035
// ============================================================
19302036
// Summary
19312037
// ============================================================

tools/installer/modules/community-manager.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ const { execSync } = require('node:child_process');
55
const prompts = require('../prompts');
66
const { RegistryClient } = require('./registry-client');
77

8-
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
9-
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
10-
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
8+
const MARKETPLACE_OWNER = 'bmad-code-org';
9+
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
10+
const MARKETPLACE_REF = 'main';
1111

1212
/**
1313
* Manages community modules from the BMad marketplace registry.
@@ -33,7 +33,12 @@ class CommunityModuleManager {
3333
if (this._cachedIndex) return this._cachedIndex;
3434

3535
try {
36-
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
36+
const config = await this._client.fetchGitHubYaml(
37+
MARKETPLACE_OWNER,
38+
MARKETPLACE_REPO,
39+
'registry/community-index.yaml',
40+
MARKETPLACE_REF,
41+
);
3742
if (config?.modules?.length) {
3843
this._cachedIndex = config;
3944
return config;
@@ -54,7 +59,7 @@ class CommunityModuleManager {
5459
if (this._cachedCategories) return this._cachedCategories;
5560

5661
try {
57-
const config = await this._client.fetchYaml(CATEGORIES_URL);
62+
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
5863
if (config?.categories) {
5964
this._cachedCategories = config;
6065
return config;

tools/installer/modules/external-manager.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ const yaml = require('yaml');
66
const prompts = require('../prompts');
77
const { RegistryClient } = require('./registry-client');
88

9-
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
9+
const MARKETPLACE_OWNER = 'bmad-code-org';
10+
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
11+
const MARKETPLACE_REF = 'main';
1012
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
1113

1214
/**
@@ -33,8 +35,7 @@ class ExternalModuleManager {
3335

3436
// Try remote registry first
3537
try {
36-
const content = await this._client.fetch(REGISTRY_RAW_URL);
37-
const config = yaml.parse(content);
38+
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF);
3839
if (config?.modules?.length) {
3940
this.cachedModules = config;
4041
return config;

tools/installer/modules/registry-client.js

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,37 @@
11
const https = require('node:https');
22
const yaml = require('yaml');
33

4+
/**
5+
* Build a rich Error from a non-2xx response. Includes the URL, the GitHub
6+
* JSON error message (or a truncated body snippet), rate-limit reset time,
7+
* and Retry-After — anything present that would help a user recover.
8+
*/
9+
function buildHttpError(url, res, body) {
10+
const parts = [`HTTP ${res.statusCode} ${url}`];
11+
12+
if (body) {
13+
try {
14+
const parsed = JSON.parse(body);
15+
if (parsed.message) parts.push(parsed.message);
16+
if (parsed.documentation_url) parts.push(`(see ${parsed.documentation_url})`);
17+
} catch {
18+
const snippet = body.slice(0, 200).trim();
19+
if (snippet) parts.push(snippet);
20+
}
21+
}
22+
23+
const remaining = res.headers['x-ratelimit-remaining'];
24+
const reset = res.headers['x-ratelimit-reset'];
25+
if (remaining === '0' && reset) {
26+
parts.push(`rate limit exhausted; resets at ${new Date(Number(reset) * 1000).toISOString()}`);
27+
}
28+
29+
const retryAfter = res.headers['retry-after'];
30+
if (retryAfter) parts.push(`retry after ${retryAfter}`);
31+
32+
return new Error(parts.join(' — '));
33+
}
34+
435
/**
536
* Shared HTTP client for fetching registry data from GitHub.
637
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
@@ -12,25 +43,31 @@ class RegistryClient {
1243

1344
/**
1445
* Fetch a URL and return the response body as a string.
15-
* Follows one redirect (GitHub sometimes 301s).
46+
* Follows up to 3 redirects (GitHub sometimes 301s).
1647
* @param {string} url - URL to fetch
1748
* @param {number} [timeout] - Timeout in ms (overrides default)
49+
* @param {number} [maxRedirects=3] - Maximum redirects to follow
1850
* @returns {Promise<string>} Response body
1951
*/
20-
fetch(url, timeout) {
52+
fetch(url, timeout, maxRedirects = 3) {
2153
const timeoutMs = timeout || this.timeout;
2254
return new Promise((resolve, reject) => {
2355
const req = https
2456
.get(url, { timeout: timeoutMs }, (res) => {
2557
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
26-
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
27-
}
28-
if (res.statusCode !== 200) {
29-
return reject(new Error(`HTTP ${res.statusCode}`));
58+
if (maxRedirects <= 0) {
59+
return reject(new Error('Too many redirects'));
60+
}
61+
return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject);
3062
}
3163
let data = '';
3264
res.on('data', (chunk) => (data += chunk));
33-
res.on('end', () => resolve(data));
65+
res.on('end', () => {
66+
if (res.statusCode !== 200) {
67+
return reject(buildHttpError(url, res, data));
68+
}
69+
resolve(data);
70+
});
3471
})
3572
.on('error', reject)
3673
.on('timeout', () => {
@@ -50,6 +87,101 @@ class RegistryClient {
5087
const content = await this.fetch(url, timeout);
5188
return yaml.parse(content);
5289
}
90+
91+
/**
92+
* Fetch a file from a GitHub repo using the Contents API first,
93+
* falling back to raw.githubusercontent.com if the API fails.
94+
*
95+
* The API endpoint (`api.github.com`) is tried first because corporate
96+
* proxies commonly block `raw.githubusercontent.com` while allowing
97+
* `api.github.com` under the "Software Development" category.
98+
*
99+
* @param {string} owner - Repository owner (e.g., 'bmad-code-org')
100+
* @param {string} repo - Repository name (e.g., 'bmad-plugins-marketplace')
101+
* @param {string} filePath - Path within the repo (e.g., 'registry/official.yaml')
102+
* @param {string} ref - Git ref (branch, tag, or SHA; e.g., 'main')
103+
* @param {number} [timeout] - Timeout in ms (overrides default)
104+
* @returns {Promise<string>} Raw file content
105+
*/
106+
async fetchGitHubFile(owner, repo, filePath, ref, timeout) {
107+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${ref}`;
108+
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
109+
110+
// Try GitHub Contents API first (with raw content accept header)
111+
try {
112+
return await this._fetchWithHeaders(apiUrl, { Accept: 'application/vnd.github.raw+json' }, timeout);
113+
} catch (apiError) {
114+
// API failed — fall back to raw CDN
115+
try {
116+
return await this.fetch(rawUrl, timeout);
117+
} catch (cdnError) {
118+
throw new AggregateError([apiError, cdnError], `Both GitHub API and raw CDN failed for ${filePath}`);
119+
}
120+
}
121+
}
122+
123+
/**
124+
* Fetch a file from GitHub and parse as YAML.
125+
* @param {string} owner - Repository owner
126+
* @param {string} repo - Repository name
127+
* @param {string} filePath - Path within the repo
128+
* @param {string} ref - Git ref
129+
* @param {number} [timeout] - Timeout in ms
130+
* @returns {Promise<Object>} Parsed YAML content
131+
*/
132+
async fetchGitHubYaml(owner, repo, filePath, ref, timeout) {
133+
const content = await this.fetchGitHubFile(owner, repo, filePath, ref, timeout);
134+
return yaml.parse(content);
135+
}
136+
137+
/**
138+
* Fetch a URL with custom headers. Used for GitHub API requests.
139+
* Follows up to 3 redirects.
140+
* @param {string} url - URL to fetch
141+
* @param {Object} headers - Request headers
142+
* @param {number} [timeout] - Timeout in ms
143+
* @param {number} [maxRedirects=3] - Maximum redirects to follow
144+
* @returns {Promise<string>} Response body
145+
* @private
146+
*/
147+
_fetchWithHeaders(url, headers, timeout, maxRedirects = 3) {
148+
const timeoutMs = timeout || this.timeout;
149+
const parsed = new URL(url);
150+
const options = {
151+
hostname: parsed.hostname,
152+
path: parsed.pathname + parsed.search,
153+
timeout: timeoutMs,
154+
headers: {
155+
'User-Agent': 'bmad-installer',
156+
...headers,
157+
},
158+
};
159+
160+
return new Promise((resolve, reject) => {
161+
const req = https
162+
.get(options, (res) => {
163+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
164+
if (maxRedirects <= 0) {
165+
return reject(new Error('Too many redirects'));
166+
}
167+
return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject);
168+
}
169+
let data = '';
170+
res.on('data', (chunk) => (data += chunk));
171+
res.on('end', () => {
172+
if (res.statusCode !== 200) {
173+
return reject(buildHttpError(url, res, data));
174+
}
175+
resolve(data);
176+
});
177+
})
178+
.on('error', reject)
179+
.on('timeout', () => {
180+
req.destroy();
181+
reject(new Error('Request timed out'));
182+
});
183+
});
184+
}
53185
}
54186

55187
module.exports = { RegistryClient };

0 commit comments

Comments
 (0)