diff --git a/README.md b/README.md index 164c836ec..e49fa954a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The interactive configuration tool provides a terminal UI where you can: > export CLAUDE_CONFIG_DIR=/custom/path/to/.claude > ``` -> 🌐 **HTTPS proxy:** Usage and Claude Status requests honor uppercase `HTTPS_PROXY`, with exclusions from both `NO_PROXY` and `no_proxy`. See [proxy settings](docs/USAGE.md#proxy-settings) for host, domain, and port matching. +> 🌐 **HTTPS proxy:** Usage, Claude Status and update-check requests honor uppercase `HTTPS_PROXY`, with exclusions from both `NO_PROXY` and `no_proxy`. See [proxy settings](docs/USAGE.md#proxy-settings) for host, domain, and port matching. > 🪟 **Windows Support:** PowerShell examples, installation notes, fonts, troubleshooting, WSL, and Windows Terminal configuration are in [docs/WINDOWS.md](docs/WINDOWS.md). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 65d76f0a6..b7644f1f8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -116,7 +116,7 @@ Stale-cache fallback removes each expired window's usage and reset fields while - **Sandbox Status** reads `sandbox.enabled` from Claude Code's layered project-local, project, user-local, and user settings on every refresh. This reflects `/sandbox` file updates but remains a best-effort indicator when managed or CLI settings take precedence. - **Skills** reads only logs for session IDs accepted by `getSkillsFilePath()`. Rejected IDs return no metrics, and the hook handler exits before creating directories or writing a log. -Usage, account profile, and service-status requests honor uppercase `HTTPS_PROXY` and share `isExcludedFromProxy()` for the `NO_PROXY`/`no_proxy` rules described in [Usage](USAGE.md#proxy-settings). Each request has a total five-second deadline in addition to its socket timeout. On expiry, `createProxyAgent().close()` aborts the pending proxy connection when `AbortController` is available; on older runtimes without it, that connection can remain open. The proxy integration tests require permission to listen on a local socket. +Usage, account profile, service-status and update-check (npm registry) requests honor uppercase `HTTPS_PROXY` and share `isExcludedFromProxy()` for the `NO_PROXY`/`no_proxy` rules described in [Usage](USAGE.md#proxy-settings). Each request has a total five-second deadline; the usage, account profile and service-status requests also set a socket timeout. On expiry, `createProxyAgent().close()` aborts the pending proxy connection when `AbortController` is available; on older runtimes without it, that connection can remain open. The proxy integration tests require permission to listen on a local socket. ## Widget Implementation diff --git a/docs/USAGE.md b/docs/USAGE.md index e2ceb1c99..655dca13b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -220,7 +220,7 @@ Claude Account Email uses the same credentials to look up the account's email fr ### Proxy Settings -Usage and Claude Account Email requests to `api.anthropic.com` and Claude Status requests to `status.claude.com` honor uppercase `HTTPS_PROXY`. Both `NO_PROXY` and `no_proxy` supply exclusions; matching either variable sends the request directly. +Usage and Claude Account Email requests to `api.anthropic.com`, Claude Status requests to `status.claude.com`, and the TUI's **Check for Updates** request to `registry.npmjs.org` honor uppercase `HTTPS_PROXY`. Both `NO_PROXY` and `no_proxy` supply exclusions; matching either variable sends the request directly. Exclusions are separated by commas or whitespace and matched without regard to case. Use `*` for all hosts, a hostname such as `api.anthropic.com`, or a domain such as `anthropic.com`, `.anthropic.com`, or `*.anthropic.com` to match that domain and its subdomains. An optional `:443` suffix is supported; entries for other ports do not match these HTTPS requests. diff --git a/src/tui/App.tsx b/src/tui/App.tsx index be375ac43..3c3e462ae 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -29,6 +29,7 @@ import { getPackageCommandAvailability, installStatusLine, isClaudeCodeVersionAtLeast, + isGlobalStatusLineCommand, isInstalled, isKnownCommand, setRefreshInterval, @@ -452,6 +453,32 @@ export function getPathInferredInstallation( return installation; } +// The version Check for Updates compares with the latest release: the global +// package's, when the status line runs the global `ccstatusline` and its +// version is known, since this TUI may be a different copy (npx, bunx), else +// this TUI's own version. A checkout also counts as self-managed, but Claude +// Code runs the checkout, not the global on PATH. +function getUpdateCheckVersion( + installation: ResolvedInstallationMetadata, + activeCommand: ActiveGlobalCommandResolution | null, + statusLineCommand: string | null, + runningVersion: string +): string { + if (installation.method === 'pinned' && installation.installedVersion) { + return installation.installedVersion; + } + + if ( + installation.method === 'self-managed' + && activeCommand?.version + && isGlobalStatusLineCommand(statusLineCommand) + ) { + return activeCommand.version; + } + + return runningVersion; +} + export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude { return confirmDialog?.cancelScreen ?? 'main'; } @@ -545,6 +572,7 @@ export const App: React.FC = () => { const [supportsRefreshInterval] = useState(() => isClaudeCodeVersionAtLeast('2.1.97')); const [commandAvailability] = useState(() => getPackageCommandAvailability()); const [updateCheckerState, setUpdateCheckerState] = useState({ status: 'checking' }); + const [updateRecheckPending, setUpdateRecheckPending] = useState(false); const [flowNotice, setFlowNotice] = useState(null); const [globalPackageInstallations, setGlobalPackageInstallations] = useState([]); const [updatesReturnScreen, setUpdatesReturnScreen] = useState<'main' | 'manageInstallation'>('main'); @@ -838,18 +866,24 @@ export const App: React.FC = () => { ? inspectActiveGlobalCommand({ commandAvailability }) : null; const effectiveUpdateInstallation = getPathInferredInstallation(installation, activeCommand); - const currentUpdateVersion = effectiveUpdateInstallation.method === 'pinned' && effectiveUpdateInstallation.installedVersion - ? effectiveUpdateInstallation.installedVersion - : getPackageVersion(); void checkForUpdates({ - currentVersion: currentUpdateVersion, + currentVersion: getUpdateCheckVersion(effectiveUpdateInstallation, activeCommand, existingStatusLine, getPackageVersion()), installedCommand: existingStatusLine, installationMetadata: effectiveUpdateInstallation, commandAvailability }).then(setUpdateCheckerState); }, [commandAvailability, existingStatusLine, isClaudeInstalled, settings]); + // A finished global update asks for this, so the check runs once the new + // installation metadata is in `settings`, not with the settings from before + useEffect(() => { + if (updateRecheckPending) { + setUpdateRecheckPending(false); + handleUpdateCheck(); + } + }, [updateRecheckPending, handleUpdateCheck]); + const handleRunUpdateAction = useCallback((action: UpdateAction) => { setConfirmDialog({ message: `Run global update command?\n\n${action.command}\n\nClaude settings will not be changed.`, @@ -872,6 +906,8 @@ export const App: React.FC = () => { setOriginalSettings(prev => prev ? { ...prev, installation } : prev); + // The Updates screen still shows the result from before the update + setUpdateRecheckPending(true); const resolutionWarning = getGlobalResolutionWarning(action.packageManager); if (resolutionWarning) { diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts index 21b9fed51..837668bbd 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -1126,3 +1126,141 @@ describe('App while an install or update runs', () => { } }); }); + +describe('App Check for Updates', () => { + let sandbox: AppSandbox; + + beforeEach(() => { + sandbox = setUpAppSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + // The real check, against a registry whose latest release is `latestVersion` + function stubLatestRelease(latestVersion: string) { + const realCheckForUpdates = updateChecker.checkForUpdates; + return vi.spyOn(updateChecker, 'checkForUpdates').mockImplementation(options => realCheckForUpdates({ + ...options, + latestVersionFetcher: () => Promise.resolve(latestVersion) + })); + } + + function stubNpmGlobalInstall(getVersion: () => string) { + vi.spyOn(globalPackageManager, 'inspectActiveGlobalCommand').mockImplementation(() => ({ + packageManager: 'npm', + resolvedPath: '/usr/local/bin/ccstatusline', + resolvedPaths: ['/usr/local/bin/ccstatusline'], + binDir: '/usr/local/bin', + version: getVersion(), + warning: null + })); + } + + async function openCheckForUpdates(rendered: ReturnType, expectedText: string): Promise { + await waitFor(() => { + expect(rendered.getFrame()).toContain('Main Menu'); + }); + await pressKey(rendered, KEYS.up, '▶ ⭐ Like ccstatusline?'); + await pressKey(rendered, KEYS.up, '▶ 🚪 Exit'); + await pressKey(rendered, KEYS.up, '▶ 🧰 Manage Installation'); + await pressKey(rendered, KEYS.enter, '▶ 🔄 Check for Updates'); + await pressKey(rendered, KEYS.enter, expectedText); + } + + // Started through npx or bunx, the TUI can be a different version from the + // global package that Claude Code's `ccstatusline` command runs + it.each([ + ['offers the update to a global install older than the latest release', '2.0.0', getPackageVersion(), 'An update is available.'], + ['reports a global install already at the latest release as up to date', '99.0.0', '99.0.0', 'ccstatusline is up to date.'] + ])('on a self-managed global install, %s', async (_label, globalVersion, latestVersion, expectedResult) => { + vi.spyOn(claudeStatus, 'loadClaudeStatusLineState').mockResolvedValue({ existingStatusLine: 'ccstatusline', refreshInterval: null }); + vi.spyOn(claudeSettings, 'isInstalled').mockResolvedValue(true); + stubNpmGlobalInstall(() => globalVersion); + stubLatestRelease(latestVersion); + const rendered = renderApp(); + + try { + await openCheckForUpdates(rendered, expectedResult); + expect(rendered.getFrame()).toContain(`Current: ${globalVersion}`); + expect(rendered.getFrame()).toContain(`Latest: ${latestVersion}`); + expect(rendered.getFrame()).toContain('Install: Self-managed/global install'); + if (globalVersion === latestVersion) { + expect(rendered.getFrame()).not.toContain('Run npm install -g'); + } else { + expect(rendered.getFrame()).toContain(`Run npm install -g ccstatusline@${latestVersion}`); + } + } finally { + rendered.cleanup(); + } + }); + + // Claude Code runs the checkout, so a global elsewhere on PATH says nothing + // about whether the status line is current + it.each([ + ['reports a checkout at the latest release as up to date, with an older global on PATH', '2.0.0', getPackageVersion(), 'ccstatusline is up to date.'], + ['offers the update to a checkout older than the latest release, with that release on PATH', '99.0.0', '99.0.0', 'An update is available.'] + ])('on a status line that runs a local checkout, %s', async (_label, globalVersion, latestVersion, expectedResult) => { + vi.spyOn(claudeStatus, 'loadClaudeStatusLineState').mockResolvedValue({ existingStatusLine: 'bun /Users/dev/ccstatusline/src/ccstatusline.ts', refreshInterval: null }); + vi.spyOn(claudeSettings, 'isInstalled').mockResolvedValue(true); + stubNpmGlobalInstall(() => globalVersion); + stubLatestRelease(latestVersion); + const rendered = renderApp(); + + try { + await openCheckForUpdates(rendered, expectedResult); + expect(rendered.getFrame()).toContain(`Current: ${getPackageVersion()}`); + expect(rendered.getFrame()).toContain(`Latest: ${latestVersion}`); + } finally { + rendered.cleanup(); + } + }); + + it.each([ + ['', null], + [' and its PATH warning', '⚠ Multiple ccstatusline binaries are on PATH.'] + ])('checks again after a global update%s, instead of offering the same update', async (_label, resolutionWarning) => { + const runningVersion = getPackageVersion(); + fs.writeFileSync(sandbox.settingsPath, JSON.stringify({ + ...DEFAULT_SETTINGS, + installation: { method: 'pinned', installedVersion: runningVersion } + })); + vi.spyOn(claudeStatus, 'loadClaudeStatusLineState').mockResolvedValue({ existingStatusLine: 'ccstatusline', refreshInterval: null }); + vi.spyOn(claudeSettings, 'isInstalled').mockResolvedValue(true); + vi.spyOn(globalCommandResolution, 'inspectGlobalCommandResolution').mockReturnValue({ + resolvedPaths: ['/usr/local/bin/ccstatusline'], + firstResolvedPath: '/usr/local/bin/ccstatusline', + expectedBinDir: '/usr/local/bin', + warning: resolutionWarning + }); + let globalVersion = runningVersion; + stubNpmGlobalInstall(() => globalVersion); + const checkForUpdates = stubLatestRelease('99.0.0'); + const runUpdate = vi.spyOn(updateChecker, 'runGlobalUpdateAction').mockImplementation((action) => { + globalVersion = action.version; + return Promise.resolve(); + }); + const rendered = renderApp(); + + try { + await openCheckForUpdates(rendered, 'An update is available.'); + await pressKey(rendered, KEYS.enter, 'Run global update command?'); + // Yes, then Continue past the Update Complete notice when there is one + if (resolutionWarning) { + await pressKey(rendered, KEYS.enter, resolutionWarning); + } + rendered.stdin.write(KEYS.enter); + await waitFor(() => { + expect(rendered.getFrame()).toContain('ccstatusline is up to date.'); + }); + expect(rendered.getFrame()).toContain('Current: 99.0.0'); + expect(rendered.getFrame()).toContain('Install: Pinned global install via npm 99.0.0'); + expect(rendered.getFrame()).not.toContain('Run npm install -g'); + expect(runUpdate).toHaveBeenCalledOnce(); + expect(checkForUpdates).toHaveBeenCalledTimes(2); + } finally { + rendered.cleanup(); + } + }); +}); diff --git a/src/utils/__tests__/claude-service-status.test.ts b/src/utils/__tests__/claude-service-status.test.ts index 53cb21174..f3efa08f0 100644 --- a/src/utils/__tests__/claude-service-status.test.ts +++ b/src/utils/__tests__/claude-service-status.test.ts @@ -29,6 +29,7 @@ import { } from '../claude-service-status'; import { + isolateHttpsProxyEnv, startStalledProxy, type StalledProxy } from './proxy-test-helpers'; @@ -41,37 +42,6 @@ type StatusPageRequestFn = NonNullable { - originalProxy = process.env.HTTPS_PROXY; - originalNoProxy = process.env.NO_PROXY; - originalLowercaseNoProxy = process.env.no_proxy; - delete process.env.HTTPS_PROXY; - delete process.env.NO_PROXY; - delete process.env.no_proxy; - }); - - afterEach(() => { - restoreEnv('HTTPS_PROXY', originalProxy); - restoreEnv('NO_PROXY', originalNoProxy); - restoreEnv('no_proxy', originalLowercaseNoProxy); - }); -} - -function restoreEnv(name: 'HTTPS_PROXY' | 'NO_PROXY' | 'no_proxy', value: string | undefined): void { - if (value === undefined) { - Reflect.deleteProperty(process.env, name); - } else { - process.env[name] = value; - } -} - function incident(impact: ClaudeIncidentWindow['impact'], startHoursAgo: number, endHoursAgo: number | null): ClaudeIncidentWindow { return { impact, diff --git a/src/utils/__tests__/proxy-test-helpers.ts b/src/utils/__tests__/proxy-test-helpers.ts index d92b2e245..289f14c2b 100644 --- a/src/utils/__tests__/proxy-test-helpers.ts +++ b/src/utils/__tests__/proxy-test-helpers.ts @@ -1,4 +1,8 @@ import * as net from 'node:net'; +import { + afterEach, + beforeEach +} from 'vitest'; export interface StalledProxy { url: string; @@ -43,3 +47,34 @@ export async function startStalledProxy(): Promise { }) }; } + +// Tests set HTTPS_PROXY and NO_PROXY themselves; the environment running the +// suite must not leak in. +export function isolateHttpsProxyEnv(): void { + let originalProxy: string | undefined; + let originalNoProxy: string | undefined; + let originalLowercaseNoProxy: string | undefined; + + beforeEach(() => { + originalProxy = process.env.HTTPS_PROXY; + originalNoProxy = process.env.NO_PROXY; + originalLowercaseNoProxy = process.env.no_proxy; + delete process.env.HTTPS_PROXY; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + }); + + afterEach(() => { + restoreEnv('HTTPS_PROXY', originalProxy); + restoreEnv('NO_PROXY', originalNoProxy); + restoreEnv('no_proxy', originalLowercaseNoProxy); + }); +} + +function restoreEnv(name: 'HTTPS_PROXY' | 'NO_PROXY' | 'no_proxy', value: string | undefined): void { + if (value === undefined) { + Reflect.deleteProperty(process.env, name); + } else { + process.env[name] = value; + } +} diff --git a/src/utils/__tests__/update-checker.test.ts b/src/utils/__tests__/update-checker.test.ts index 1b2f5b176..6ba5ebe2e 100644 --- a/src/utils/__tests__/update-checker.test.ts +++ b/src/utils/__tests__/update-checker.test.ts @@ -1,4 +1,6 @@ +import { HttpsProxyAgent } from 'https-proxy-agent'; import * as childProcess from 'node:child_process'; +import { EventEmitter } from 'node:events'; import { afterEach, describe, @@ -12,10 +14,19 @@ import { initConfigPath } from '../config'; import { buildUpdateCheckResult, checkForUpdates, + fetchLatestNpmVersion, runGlobalPackageInstall, type UpdateCheckResult } from '../update-checker'; +import { + isolateHttpsProxyEnv, + startStalledProxy, + type StalledProxy +} from './proxy-test-helpers'; + +type RegistryRequestFn = NonNullable[1]>; + const ALL_AVAILABLE = { npm: true, npx: true, @@ -285,3 +296,113 @@ describe('update checker', () => { ]); }); }); + +describe('npm registry request', () => { + isolateHttpsProxyEnv(); + + // Answers 200 with the body in one chunk + function respondingRequest(body: string): RegistryRequestFn { + return (_url, _options, onResponse) => Object.assign(new EventEmitter(), { + destroy: () => undefined, + end() { + const response = Object.assign(new EventEmitter(), { + statusCode: 200, + resume: () => undefined, + setEncoding: () => undefined + }); + onResponse(response); + response.emit('data', body); + response.emit('end'); + } + }); + } + + // Under Bun, and Node without an agent default, a socket timeout can't fire + // while the request is still connecting + it('gives up once the deadline passes, even before the request connects', async () => { + const destroy = vi.fn(); + const connectingRequest: RegistryRequestFn = () => Object.assign(new EventEmitter(), { + destroy, + end: () => undefined + }); + + const result = await Promise.race([ + fetchLatestNpmVersion(10, connectingRequest).catch((error: unknown) => error), + new Promise<'still pending'>(resolve => setTimeout(() => { resolve('still pending'); }, 1000)) + ]); + + expect(result).toEqual(new Error('npm registry request timed out after 10ms')); + expect(destroy).toHaveBeenCalledOnce(); + }); + + it.each([302, 503, undefined])('closes a stalled HTTP %s response before rejecting', async (statusCode) => { + const destroy = vi.fn(); + const stalledErrorRequest: RegistryRequestFn = (_url, _options, onResponse) => Object.assign(new EventEmitter(), { + destroy, + end() { + // Headers arrive, but the server never finishes the body. + onResponse(Object.assign(new EventEmitter(), { + statusCode, + resume: () => undefined, + setEncoding: () => undefined + })); + } + }); + + await expect(fetchLatestNpmVersion(5000, stalledErrorRequest)) + .rejects.toThrow(`npm registry returned HTTP ${statusCode ?? 'unknown'}`); + expect(destroy).toHaveBeenCalledOnce(); + }); + + // A host:port with no scheme is an HTTP proxy, as curl reads it + it.each([ + ['', 'http://proxy.example:8080'], + [' given as host:port, with no scheme', 'proxy.example:8080'] + ])('tunnels through the proxy named by HTTPS_PROXY%s', async (_label, proxyUrl) => { + process.env.HTTPS_PROXY = proxyUrl; + const requestFn = vi.fn(respondingRequest('{"version":"2.3.0"}')); + + await expect(fetchLatestNpmVersion(5000, requestFn)).resolves.toBe('2.3.0'); + + const agent = requestFn.mock.calls[0]?.[1].agent; + expect(agent).toBeInstanceOf(HttpsProxyAgent); + expect((agent as HttpsProxyAgent).proxy.href).toBe('http://proxy.example:8080/'); + }); + + it.each([ + ['NO_PROXY lists npmjs.org', { HTTPS_PROXY: 'http://proxy.example:8080', NO_PROXY: 'localhost,.npmjs.org' }], + ['HTTPS_PROXY is blank', { HTTPS_PROXY: ' ' }] + ])('connects directly when %s', async (_label, env) => { + Object.assign(process.env, env); + const requestFn = vi.fn(respondingRequest('{"version":"2.3.0"}')); + + await expect(fetchLatestNpmVersion(5000, requestFn)).resolves.toBe('2.3.0'); + expect(requestFn.mock.calls[0]?.[1]).not.toHaveProperty('agent'); + }); + + it('fails without sending a request when HTTPS_PROXY is not a valid URL', async () => { + process.env.HTTPS_PROXY = 'not a proxy url'; + const requestFn = vi.fn(respondingRequest('{"version":"2.3.0"}')); + + await expect(fetchLatestNpmVersion(5000, requestFn)).rejects.toThrow('HTTPS_PROXY is not a valid URL'); + expect(requestFn).not.toHaveBeenCalled(); + }); + + // The agent's own connection to the proxy would keep the process alive + describe('behind a proxy that never answers CONNECT', () => { + let proxy: StalledProxy | null = null; + + afterEach(async () => { + await proxy?.stop(); + proxy = null; + }); + + it('gives up at the deadline and closes its connection to the proxy', async () => { + proxy = await startStalledProxy(); + process.env.HTTPS_PROXY = proxy.url; + + await expect(fetchLatestNpmVersion(50)).rejects.toThrow('npm registry request timed out after 50ms'); + await proxy.connectionClosed; + }); + }); +}); diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 5d3033593..652fd1d91 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -348,6 +348,11 @@ function matchesCommandBase(command: string, baseCommand: string): boolean { return command === baseCommand || command.startsWith(`${baseCommand} --config `); } +// Whether the status line runs the global `ccstatusline` from PATH (not npx, bunx or a checkout) +export function isGlobalStatusLineCommand(command: string | null | undefined): boolean { + return matchesCommandBase(command ?? '', CCSTATUSLINE_COMMANDS.GLOBAL); +} + function isLocalDevelopmentCommand(command: string): boolean { return /(?:^|[\s"'\\/])ccstatusline\.ts(?=$|[\s"'])/.test(command); } @@ -372,7 +377,7 @@ export function classifyInstallation( }; } - if (matchesCommandBase(statusLineCommand, CCSTATUSLINE_COMMANDS.GLOBAL)) { + if (isGlobalStatusLineCommand(statusLineCommand)) { if (metadata?.method === 'pinned') { return metadata; } diff --git a/src/utils/proxy-agent.ts b/src/utils/proxy-agent.ts index 46dcc7aba..cf565057f 100644 --- a/src/utils/proxy-agent.ts +++ b/src/utils/proxy-agent.ts @@ -5,6 +5,13 @@ export interface ClosableProxyAgent { close: () => void; } +// A proxy given as host:port is an HTTP proxy, as curl reads it. Parsed as it +// is, the host would become the URL's scheme and the agent would connect to +// localhost:80. +function withProxyScheme(proxyUrl: string): string { + return /^[a-z][a-z\d+.-]*:\/\//i.test(proxyUrl) ? proxyUrl : `http://${proxyUrl}`; +} + /** * An agent that tunnels through the proxy, and a way to close its connection * to the proxy. The agent opens that connection before the request has a @@ -16,7 +23,7 @@ export function createProxyAgent(proxyUrl: string): ClosableProxyAgent { const controller = typeof AbortController === 'function' ? new AbortController() : null; return { - agent: new HttpsProxyAgent(proxyUrl, controller ? { signal: controller.signal } : undefined), + agent: new HttpsProxyAgent(withProxyScheme(proxyUrl), controller ? { signal: controller.signal } : undefined), close: () => { controller?.abort(); } diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index f0b42609a..a7737e393 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -12,12 +12,18 @@ import { classifyInstallation, type PackageCommandAvailability } from './claude-settings'; +import { isExcludedFromProxy } from './no-proxy'; import { getPackageManagerExecutable, getPackageManagerShellOptions } from './package-manager-executable'; +import { + createProxyAgent, + type ClosableProxyAgent +} from './proxy-agent'; -export const NPM_REGISTRY_LATEST_URL = 'https://registry.npmjs.org/ccstatusline/latest'; +const NPM_REGISTRY_HOST = 'registry.npmjs.org'; +export const NPM_REGISTRY_LATEST_URL = `https://${NPM_REGISTRY_HOST}/ccstatusline/latest`; const DEFAULT_REGISTRY_TIMEOUT_MS = 5000; const GLOBAL_UPDATE_TIMEOUT_MS = 120000; @@ -257,20 +263,66 @@ export async function checkForUpdates({ } } -export function fetchLatestNpmVersion(timeoutMs = DEFAULT_REGISTRY_TIMEOUT_MS): Promise { +interface RegistryResponse { + statusCode?: number; + resume(): unknown; + setEncoding(encoding: BufferEncoding): unknown; + on(event: 'data', listener: (chunk: string) => void): unknown; + on(event: 'end', listener: () => void): unknown; +} + +interface RegistryRequest { + destroy(error?: Error): unknown; + end(): unknown; + on(event: 'error', listener: (error: Error) => void): unknown; +} + +type RegistryRequestFn = ( + url: string, + options: https.RequestOptions, + onResponse: (response: RegistryResponse) => void +) => RegistryRequest; + +const requestRegistry: RegistryRequestFn = (url, options, onResponse) => https.request(url, options, onResponse); + +function getRegistryProxyUrl(): string | null { + const proxyUrl = process.env.HTTPS_PROXY?.trim(); + return proxyUrl?.length && !isExcludedFromProxy(NPM_REGISTRY_HOST) ? proxyUrl : null; +} + +export function fetchLatestNpmVersion( + timeoutMs = DEFAULT_REGISTRY_TIMEOUT_MS, + requestFn: RegistryRequestFn = requestRegistry +): Promise { + let proxy: ClosableProxyAgent | null; + try { + const proxyUrl = getRegistryProxyUrl(); + proxy = proxyUrl ? createProxyAgent(proxyUrl) : null; + } catch { + return Promise.reject(new Error('HTTPS_PROXY is not a valid URL')); + } + return new Promise((resolve, reject) => { - const request = https.request( + const fail = (error: Error) => { + clearTimeout(deadline); + reject(error); + }; + + const request = requestFn( NPM_REGISTRY_LATEST_URL, { headers: { 'Accept': 'application/json', 'User-Agent': 'ccstatusline' - } + }, + ...(proxy ? { agent: proxy.agent } : {}) }, (response) => { if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { - response.resume(); - reject(new Error(`npm registry returned HTTP ${response.statusCode ?? 'unknown'}`)); + // The error body may never finish. Close the connection before + // fail clears the deadline so it cannot keep the process alive. + request.destroy(); + fail(new Error(`npm registry returned HTTP ${response.statusCode ?? 'unknown'}`)); return; } @@ -285,23 +337,30 @@ export function fetchLatestNpmVersion(timeoutMs = DEFAULT_REGISTRY_TIMEOUT_MS): try { const parsed = JSON.parse(body) as { version?: unknown }; if (typeof parsed.version !== 'string' || parsed.version.trim() === '') { - reject(new Error('npm registry response did not include a version')); + fail(new Error('npm registry response did not include a version')); return; } + clearTimeout(deadline); resolve(parsed.version); } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); + fail(error instanceof Error ? error : new Error(String(error))); } }); } ); - request.setTimeout(timeoutMs, () => { - request.destroy(new Error(`npm registry request timed out after ${timeoutMs}ms`)); - }); - - request.on('error', reject); + request.on('error', fail); + // Covers the whole request. A socket timeout only starts once the socket + // connects, and Bun's doesn't fire while it connects, so on a network + // that drops the connection it would wait for the OS to give up. Behind + // a proxy the request has no socket until the proxy answers CONNECT. + const deadline = setTimeout(() => { + const error = new Error(`npm registry request timed out after ${timeoutMs}ms`); + request.destroy(error); + proxy?.close(); + fail(error); + }, timeoutMs); request.end(); }); }