Skip to content

Commit 04f3fc1

Browse files
authored
fix(menubar): support installer HTTP proxies (#475)
Route the menubar installer's GitHub downloads through an undici ProxyAgent when HTTP(S)_PROXY is set, honoring NO_PROXY for bypass. Falls back to direct fetch when no proxy is configured. Closes #473.
1 parent 81fdeee commit 04f3fc1

4 files changed

Lines changed: 61 additions & 3 deletions

File tree

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"ink": "^7.0.0",
5454
"react": "^19.2.5",
5555
"strip-ansi": "^7.2.0",
56+
"undici": "^7.27.2",
5657
"zod": "^3.25.76"
5758
},
5859
"devDependencies": {

src/menubar-installer.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { homedir, platform, tmpdir } from 'node:os'
66
import { join } from 'node:path'
77
import { pipeline } from 'node:stream/promises'
88
import { Readable } from 'node:stream'
9+
import { ProxyAgent, fetch as undiciFetch } from 'undici'
910

1011
import {
1112
buildPersistentCodeburnLookupPath,
@@ -31,6 +32,34 @@ export type InstallResult = { installedPath: string; launched: boolean }
3132
export type ReleaseAsset = { name: string; browser_download_url: string }
3233
export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
3334
export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset }
35+
type ProxyEnv = Partial<Record<'HTTPS_PROXY' | 'https_proxy' | 'HTTP_PROXY' | 'http_proxy' | 'NO_PROXY' | 'no_proxy', string>>
36+
type FetchOptions = Parameters<typeof undiciFetch>[1]
37+
38+
export function resolveProxyUrlForUrl(url: string, env: ProxyEnv = process.env): string | undefined {
39+
const target = new URL(url)
40+
if (matchesNoProxy(target.hostname, env.NO_PROXY ?? env.no_proxy)) return undefined
41+
if (target.protocol === 'https:') return env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy
42+
if (target.protocol === 'http:') return env.HTTP_PROXY ?? env.http_proxy
43+
return undefined
44+
}
45+
46+
function matchesNoProxy(hostname: string, noProxy?: string): boolean {
47+
if (!noProxy) return false
48+
const host = hostname.toLowerCase()
49+
return noProxy.split(',').some(entry => {
50+
const rule = entry.trim().toLowerCase().split(':')[0]
51+
if (!rule) return false
52+
if (rule === '*') return true
53+
if (rule.startsWith('.')) return host === rule.slice(1) || host.endsWith(rule)
54+
return host === rule || host.endsWith(`.${rule}`)
55+
})
56+
}
57+
58+
function fetchWithProxy(url: string, options: FetchOptions = {}) {
59+
const proxyUrl = resolveProxyUrlForUrl(url)
60+
const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined
61+
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options)
62+
}
3463

3564
export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets {
3665
const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name))
@@ -102,7 +131,7 @@ async function sysProductVersion(): Promise<string> {
102131
}
103132

104133
async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
105-
const response = await fetch(RELEASE_API, {
134+
const response = await fetchWithProxy(RELEASE_API, {
106135
headers: {
107136
'User-Agent': 'codeburn-menubar-installer',
108137
Accept: 'application/vnd.github+json',
@@ -116,7 +145,7 @@ async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
116145
}
117146

118147
async function verifyChecksum(archivePath: string, checksumUrl: string): Promise<void> {
119-
const response = await fetch(checksumUrl, {
148+
const response = await fetchWithProxy(checksumUrl, {
120149
headers: { 'User-Agent': 'codeburn-menubar-installer' },
121150
redirect: 'follow',
122151
})
@@ -138,7 +167,7 @@ async function verifyChecksum(archivePath: string, checksumUrl: string): Promise
138167
}
139168

140169
async function downloadToFile(url: string, destPath: string): Promise<void> {
141-
const response = await fetch(url, {
170+
const response = await fetchWithProxy(url, {
142171
headers: { 'User-Agent': 'codeburn-menubar-installer' },
143172
redirect: 'follow',
144173
})

tests/menubar-installer.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
resolveLatestMenubarReleaseAssets,
55
resolveMenubarReleaseAssets,
66
resolvePersistentCodeburnPathFromWhichOutput,
7+
resolveProxyUrlForUrl,
78
type ReleaseResponse,
89
} from '../src/menubar-installer.js'
910

@@ -96,4 +97,21 @@ describe('resolveMenubarReleaseAssets', () => {
9697
'/Users/me/.npm/_npx/abcd/node_modules/.bin/codeburn'
9798
)).toThrow(/Install CodeBurn globally first/)
9899
})
100+
101+
it('uses HTTPS proxy for GitHub HTTPS downloads', () => {
102+
const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', {
103+
HTTPS_PROXY: 'http://proxy.company.test:8080',
104+
})
105+
106+
expect(proxyUrl).toBe('http://proxy.company.test:8080')
107+
})
108+
109+
it('bypasses proxy when NO_PROXY matches the download host', () => {
110+
const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', {
111+
HTTPS_PROXY: 'http://proxy.company.test:8080',
112+
NO_PROXY: '.github.com',
113+
})
114+
115+
expect(proxyUrl).toBeUndefined()
116+
})
99117
})

0 commit comments

Comments
 (0)