diff --git a/package-lock.json b/package-lock.json index 73344777..f35e877e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "ink": "^7.0.0", "react": "^19.2.5", "strip-ansi": "^7.2.0", + "undici": "^7.27.2", "zod": "^3.25.76" }, "bin": { @@ -3432,6 +3433,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 3aa31db3..46a69635 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "ink": "^7.0.0", "react": "^19.2.5", "strip-ansi": "^7.2.0", + "undici": "^7.27.2", "zod": "^3.25.76" }, "devDependencies": { diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 855f7fb5..fbbe6006 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -6,6 +6,7 @@ import { homedir, platform, tmpdir } from 'node:os' import { join } from 'node:path' import { pipeline } from 'node:stream/promises' import { Readable } from 'node:stream' +import { ProxyAgent, fetch as undiciFetch } from 'undici' import { buildPersistentCodeburnLookupPath, @@ -31,6 +32,34 @@ export type InstallResult = { installedPath: string; launched: boolean } export type ReleaseAsset = { name: string; browser_download_url: string } export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } +type ProxyEnv = Partial> +type FetchOptions = Parameters[1] + +export function resolveProxyUrlForUrl(url: string, env: ProxyEnv = process.env): string | undefined { + const target = new URL(url) + if (matchesNoProxy(target.hostname, env.NO_PROXY ?? env.no_proxy)) return undefined + if (target.protocol === 'https:') return env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy + if (target.protocol === 'http:') return env.HTTP_PROXY ?? env.http_proxy + return undefined +} + +function matchesNoProxy(hostname: string, noProxy?: string): boolean { + if (!noProxy) return false + const host = hostname.toLowerCase() + return noProxy.split(',').some(entry => { + const rule = entry.trim().toLowerCase().split(':')[0] + if (!rule) return false + if (rule === '*') return true + if (rule.startsWith('.')) return host === rule.slice(1) || host.endsWith(rule) + return host === rule || host.endsWith(`.${rule}`) + }) +} + +function fetchWithProxy(url: string, options: FetchOptions = {}) { + const proxyUrl = resolveProxyUrlForUrl(url) + const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined + return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options) +} export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets { const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name)) @@ -102,7 +131,7 @@ async function sysProductVersion(): Promise { } async function fetchLatestReleaseAssets(): Promise { - const response = await fetch(RELEASE_API, { + const response = await fetchWithProxy(RELEASE_API, { headers: { 'User-Agent': 'codeburn-menubar-installer', Accept: 'application/vnd.github+json', @@ -116,7 +145,7 @@ async function fetchLatestReleaseAssets(): Promise { } async function verifyChecksum(archivePath: string, checksumUrl: string): Promise { - const response = await fetch(checksumUrl, { + const response = await fetchWithProxy(checksumUrl, { headers: { 'User-Agent': 'codeburn-menubar-installer' }, redirect: 'follow', }) @@ -138,7 +167,7 @@ async function verifyChecksum(archivePath: string, checksumUrl: string): Promise } async function downloadToFile(url: string, destPath: string): Promise { - const response = await fetch(url, { + const response = await fetchWithProxy(url, { headers: { 'User-Agent': 'codeburn-menubar-installer' }, redirect: 'follow', }) diff --git a/tests/menubar-installer.test.ts b/tests/menubar-installer.test.ts index 38c4c402..911145e9 100644 --- a/tests/menubar-installer.test.ts +++ b/tests/menubar-installer.test.ts @@ -4,6 +4,7 @@ import { resolveLatestMenubarReleaseAssets, resolveMenubarReleaseAssets, resolvePersistentCodeburnPathFromWhichOutput, + resolveProxyUrlForUrl, type ReleaseResponse, } from '../src/menubar-installer.js' @@ -96,4 +97,21 @@ describe('resolveMenubarReleaseAssets', () => { '/Users/me/.npm/_npx/abcd/node_modules/.bin/codeburn' )).toThrow(/Install CodeBurn globally first/) }) + + it('uses HTTPS proxy for GitHub HTTPS downloads', () => { + const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', { + HTTPS_PROXY: 'http://proxy.company.test:8080', + }) + + expect(proxyUrl).toBe('http://proxy.company.test:8080') + }) + + it('bypasses proxy when NO_PROXY matches the download host', () => { + const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', { + HTTPS_PROXY: 'http://proxy.company.test:8080', + NO_PROXY: '.github.com', + }) + + expect(proxyUrl).toBeUndefined() + }) })