diff --git a/apps/daemon/src/plugins/installer.ts b/apps/daemon/src/plugins/installer.ts index aefb8bd3d6..f9d2570f1d 100644 --- a/apps/daemon/src/plugins/installer.ts +++ b/apps/daemon/src/plugins/installer.ts @@ -181,8 +181,10 @@ async function* installFromGithub( return; } } - if (lastError && isRetryableGithubCandidateError(lastError)) continue; - if (lastError) break; + if (lastError) { + if (shouldTryNextGithubRefCandidate(lastError)) continue; + if (!isRetryableGithubCandidateError(lastError)) break; + } } const tarballUrl = githubTarballUrl(parsed.owner, parsed.repo, candidate.ref); @@ -199,13 +201,13 @@ async function* installFromGithub( return; } } - if (!lastError || !isRetryableGithubCandidateError(lastError)) break; + if (!lastError || !shouldTryNextGithubRefCandidate(lastError)) break; } yield { kind: 'error', message: lastError - ? `${lastError}. Tried GitHub archive URL(s): ${triedUrls.join(', ')}` + ? `GitHub install failed: ${lastError}. Tried GitHub fetch URL(s): ${triedUrls.join(', ')}` : `GitHub source ${opts.source} did not produce an installable archive`, warnings: [], }; @@ -264,7 +266,19 @@ function githubContentsUrl(owner: string, repo: string, subpath: string, ref: st } function isRetryableGithubCandidateError(message: string): boolean { - return /^Fetch failed: 404\b/.test(message) || /^Subpath .+ not found inside archive$/.test(message); + return /^Fetch failed: 404\b/.test(message) + || isGithubRateLimitError(message) + || /^Subpath .+ not found inside archive$/.test(message); +} + +function shouldTryNextGithubRefCandidate(message: string): boolean { + return /^Fetch failed: 404\b/.test(message) + || /^Subpath .+ not found inside archive$/.test(message); +} + +function isGithubRateLimitError(message: string): boolean { + return /^Fetch failed: 429\b/.test(message) + || /^Fetch failed: 403\b/.test(message); } async function* installFromGithubContents( diff --git a/apps/daemon/tests/plugins-installer-archive.test.ts b/apps/daemon/tests/plugins-installer-archive.test.ts index 2f95fdf924..267862b162 100644 --- a/apps/daemon/tests/plugins-installer-archive.test.ts +++ b/apps/daemon/tests/plugins-installer-archive.test.ts @@ -169,6 +169,77 @@ describe('archive installer', () => { expect(row).toEqual({ source_kind: 'github', source }); }); + it.each([ + [403, 'Forbidden', '{"message":"API rate limit exceeded for 127.0.0.1"}'], + [429, 'Too Many Requests', 'too many requests'], + ])('falls back to codeload when GitHub contents returns %i for a plugin subpath', async (status, statusText, body) => { + const tarball = await buildFixtureTarball({ + rootPrefix: 'open-design-main', + pluginSubpath: 'plugins/community/import-smoke-test', + }); + const urlsSeen: string[] = []; + const contentsUrl = + 'https://api.github.com/repos/nexu-io/open-design/contents/plugins/community/import-smoke-test?ref=main'; + const tarballUrl = 'https://codeload.github.com/nexu-io/open-design/tar.gz/main'; + const fetcher: ArchiveFetcher = async (u) => { + urlsSeen.push(u); + if (u === contentsUrl) { + return makeResponse(body, status, statusText); + } + if (u === tarballUrl) return makeResponse(tarball); + return makeResponse('not found', 404, 'Not Found'); + }; + + let success = false; + let error: string | undefined; + const source = 'github:nexu-io/open-design@main/plugins/community/import-smoke-test'; + for await (const ev of installPlugin(db, { + source, + roots: { userPluginsRoot: pluginsRoot }, + fetcher, + })) { + if (ev.kind === 'success') success = true; + if (ev.kind === 'error') error = ev.message; + } + + if (!success) { + throw new Error(`install failed: ${error}`); + } + expect(urlsSeen).toEqual([contentsUrl, tarballUrl]); + const row = db.prepare(`SELECT source_kind, source FROM installed_plugins WHERE id = 'sample-plugin'`).get(); + expect(row).toEqual({ source_kind: 'github', source }); + }); + + it('reports both GitHub contents and codeload URLs when subpath fallback fails', async () => { + const urlsSeen: string[] = []; + const contentsUrl = + 'https://api.github.com/repos/nexu-io/open-design/contents/plugins/community/import-smoke-test?ref=main'; + const tarballUrl = 'https://codeload.github.com/nexu-io/open-design/tar.gz/main'; + const fetcher: ArchiveFetcher = async (u) => { + urlsSeen.push(u); + if (u === contentsUrl) { + return makeResponse('too many requests', 429, 'Too Many Requests'); + } + if (u === tarballUrl) return makeResponse('server unavailable', 503, 'Service Unavailable'); + return makeResponse('not found', 404, 'Not Found'); + }; + + let error: string | undefined; + const source = 'github:nexu-io/open-design@main/plugins/community/import-smoke-test'; + for await (const ev of installPlugin(db, { + source, + roots: { userPluginsRoot: pluginsRoot }, + fetcher, + })) { + if (ev.kind === 'error') error = ev.message; + } + + expect(urlsSeen).toEqual([contentsUrl, tarballUrl]); + expect(error).toContain('GitHub install failed'); + expect(error).toContain('Fetch failed: 503 Service Unavailable'); + expect(error).toContain(`Tried GitHub fetch URL(s): ${contentsUrl}, ${tarballUrl}`); + }); + it('extracts a https://*.tgz source (records source_kind=url)', async () => { const tarball = await buildFixtureTarball({ rootPrefix: 'sample-plugin-1.0.0' }); let success = false;