From 29b5eeb0d592776bb80fd7d28f3db45f360e93df Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:43:04 -0500 Subject: [PATCH 1/7] fix(update): bound the registry check with an overall deadline Check for Updates is meant to give up on the npm registry after five seconds, but its only timer was the request's socket timeout. That timer starts once the socket has connected, and Bun's doesn't fire while it connects at all. On a network that silently drops outbound HTTPS (a firewall that expects a proxy, a captive portal), the Updates screen sat on "Checking npm registry..." until the OS gave up on the connection: about 75 seconds on macOS under Bun, and the same on Node 18 and older, whose global agent has no default timeout. Node 19+ only stopped at five seconds because its global agent's own socket timeout happens to match. The check now arms one timer for the whole request, as the usage and status-page requests already do. When it fires, it destroys the request and fails the check with "npm registry request timed out after 5000ms". The socket timeout is gone, since the deadline always fires first. The request function can be passed in, so tests can stand in for the network. --- src/utils/__tests__/update-checker.test.ts | 24 ++++++++++ src/utils/update-checker.ts | 55 ++++++++++++++++++---- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/utils/__tests__/update-checker.test.ts b/src/utils/__tests__/update-checker.test.ts index 1b2f5b176..a87a58e9c 100644 --- a/src/utils/__tests__/update-checker.test.ts +++ b/src/utils/__tests__/update-checker.test.ts @@ -1,4 +1,5 @@ import * as childProcess from 'node:child_process'; +import { EventEmitter } from 'node:events'; import { afterEach, describe, @@ -12,10 +13,13 @@ import { initConfigPath } from '../config'; import { buildUpdateCheckResult, checkForUpdates, + fetchLatestNpmVersion, runGlobalPackageInstall, type UpdateCheckResult } from '../update-checker'; +type RegistryRequestFn = NonNullable[1]>; + const ALL_AVAILABLE = { npm: true, npx: true, @@ -285,3 +289,23 @@ describe('update checker', () => { ]); }); }); + +describe('npm registry request', () => { + // 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(); + }); +}); diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index f0b42609a..bb8aa5d5a 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -257,9 +257,39 @@ 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); + +export function fetchLatestNpmVersion( + timeoutMs = DEFAULT_REGISTRY_TIMEOUT_MS, + requestFn: RegistryRequestFn = requestRegistry +): Promise { 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: { @@ -270,7 +300,7 @@ export function fetchLatestNpmVersion(timeoutMs = DEFAULT_REGISTRY_TIMEOUT_MS): (response) => { if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { response.resume(); - reject(new Error(`npm registry returned HTTP ${response.statusCode ?? 'unknown'}`)); + fail(new Error(`npm registry returned HTTP ${response.statusCode ?? 'unknown'}`)); return; } @@ -285,23 +315,28 @@ 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. + const deadline = setTimeout(() => { + const error = new Error(`npm registry request timed out after ${timeoutMs}ms`); + request.destroy(error); + fail(error); + }, timeoutMs); request.end(); }); } From c6b196dce75d9e3f13852663208228fcdcd2cce4 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:47:26 -0500 Subject: [PATCH 2/7] fix(update): send the registry check through HTTPS_PROXY The usage and Claude Status requests go through the proxy named by HTTPS_PROXY unless NO_PROXY excludes the host, but Check for Updates called https.request with no agent, and neither Node nor Bun reads the proxy variables on its own. On a network where only the proxy gets out, every other network widget worked while the Updates screen failed with "npm registry request timed out after 5000ms" (and under Bun waited about 75 seconds for the OS to give up first). The registry request now uses the same helpers: createProxyAgent for HTTPS_PROXY, skipped when isExcludedFromProxy matches registry.npmjs.org. At the deadline it also closes the agent's connection to a proxy that never answers CONNECT, which would otherwise keep the process alive. A HTTPS_PROXY that isn't a URL fails the check with a message that says so. The proxy docs now list the update check, and the test helper that clears the proxy variables is shared by both suites that need it. --- README.md | 2 +- docs/DEVELOPMENT.md | 2 +- docs/USAGE.md | 2 +- .../__tests__/claude-service-status.test.ts | 33 +-------- src/utils/__tests__/proxy-test-helpers.ts | 35 +++++++++ src/utils/__tests__/update-checker.test.ts | 74 +++++++++++++++++++ src/utils/update-checker.ts | 28 ++++++- 7 files changed, 138 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3b89843e6..b9142338f 100644 --- a/README.md +++ b/README.md @@ -375,7 +375,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 5579cf476..0b3c85e9b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -96,7 +96,7 @@ After a complete successful usage fetch, the process clears the lock only if its - **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. -Both usage 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, 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 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 a60352bee..577ef3653 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -193,7 +193,7 @@ Each macOS Keychain command has a five-second timeout. When no OAuth credentials ### Proxy Settings -Usage 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 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/utils/__tests__/claude-service-status.test.ts b/src/utils/__tests__/claude-service-status.test.ts index e9e0f42ee..cc25fb893 100644 --- a/src/utils/__tests__/claude-service-status.test.ts +++ b/src/utils/__tests__/claude-service-status.test.ts @@ -2,7 +2,6 @@ import { HttpsProxyAgent } from 'https-proxy-agent'; import { EventEmitter } from 'node:events'; import { afterEach, - beforeEach, describe, expect, it, @@ -23,6 +22,7 @@ import { } from '../claude-service-status'; import { + isolateHttpsProxyEnv, startStalledProxy, type StalledProxy } from './proxy-test-helpers'; @@ -32,37 +32,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 a87a58e9c..7554a2433 100644 --- a/src/utils/__tests__/update-checker.test.ts +++ b/src/utils/__tests__/update-checker.test.ts @@ -1,3 +1,4 @@ +import { HttpsProxyAgent } from 'https-proxy-agent'; import * as childProcess from 'node:child_process'; import { EventEmitter } from 'node:events'; import { @@ -18,6 +19,12 @@ import { type UpdateCheckResult } from '../update-checker'; +import { + isolateHttpsProxyEnv, + startStalledProxy, + type StalledProxy +} from './proxy-test-helpers'; + type RegistryRequestFn = NonNullable[1]>; const ALL_AVAILABLE = { @@ -291,6 +298,25 @@ 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 () => { @@ -308,4 +334,52 @@ describe('npm registry request', () => { expect(result).toEqual(new Error('npm registry request timed out after 10ms')); expect(destroy).toHaveBeenCalledOnce(); }); + + it('tunnels through the proxy named by HTTPS_PROXY', async () => { + process.env.HTTPS_PROXY = 'http://proxy.example:8080'; + 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/update-checker.ts b/src/utils/update-checker.ts index bb8aa5d5a..efb531b71 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; @@ -279,10 +285,23 @@ type RegistryRequestFn = ( 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 fail = (error: Error) => { clearTimeout(deadline); @@ -295,7 +314,8 @@ export function fetchLatestNpmVersion( headers: { 'Accept': 'application/json', 'User-Agent': 'ccstatusline' - } + }, + ...(proxy ? { agent: proxy.agent } : {}) }, (response) => { if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { @@ -331,10 +351,12 @@ export function fetchLatestNpmVersion( 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. + // 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(); From c710687361876622edca695d03042a23b0ddcf89 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:49:19 -0500 Subject: [PATCH 3/7] fix(update): compare a self-managed global install's own version With Claude Code's status line set to the global `ccstatusline` command and no pinned metadata, Check for Updates compared the latest release with the version of the TUI that was running. The README starts the TUI with npx or bunx, so that is often a different copy from the global package Claude Code runs. Manage Installation showed "Active PATH match: npm global 2.2.20", yet Check for Updates said "Current: 2.2.32 ... ccstatusline is up to date" and offered nothing, and a global already at the latest release got an offer to reinstall it whenever the TUI was older. Pinned installs already use the version found on PATH. Self-managed installs now do too when that version is known, and fall back to the running TUI's version otherwise. The offered command already targeted that global package's manager, so the version shown now matches what it would update. --- src/tui/App.tsx | 24 ++++++++++-- src/tui/__tests__/App.test.ts | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 23e5c51a4..1d6471086 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -423,6 +423,25 @@ export function getPathInferredInstallation( return installation; } +// The version Check for Updates compares with the latest release: the global +// package Claude Code runs when its version is known, since this TUI may be a +// different copy (npx, bunx), else this TUI's own version +function getUpdateCheckVersion( + installation: ResolvedInstallationMetadata, + activeCommand: ActiveGlobalCommandResolution | null, + runningVersion: string +): string { + if (installation.method === 'pinned' && installation.installedVersion) { + return installation.installedVersion; + } + + if (installation.method === 'self-managed' && activeCommand?.version) { + return activeCommand.version; + } + + return runningVersion; +} + export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude { return confirmDialog?.cancelScreen ?? 'main'; } @@ -749,12 +768,9 @@ 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, getPackageVersion()), installedCommand: existingStatusLine, installationMetadata: effectiveUpdateInstallation, commandAvailability diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts index a950ddc63..2fa90685e 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -661,3 +661,73 @@ 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(); + } + }); +}); From 50e211c80c92a1b461420c87b7c52d1de2bfa3b0 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:51:17 -0500 Subject: [PATCH 4/7] fix(update): check again after a global update from the Updates screen After "Run npm install -g ccstatusline@X" succeeded, the TUI flashed "Global package updated" (or showed the Update Complete notice) and went back to the Updates screen, which still held the result from before the update: the old Current version, "An update is available." and the same install command, which reinstalled X if picked again. Only Check again showed that the install was up to date. A successful update now runs the check again. It runs from an effect once the new installation metadata has reached `settings`: calling the check from the update's own callback would read the settings from before the update, so a self-managed install would still show as one, and a pinned install whose PATH version can't be read would compare the old version. Both the flash path and the PATH-warning notice path land on the fresh result. --- src/tui/App.tsx | 12 +++++++++ src/tui/__tests__/App.test.ts | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 1d6471086..3635f8023 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -518,6 +518,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'); @@ -777,6 +778,15 @@ export const App: React.FC = () => { }).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.`, @@ -798,6 +808,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 2fa90685e..773080f1a 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -14,6 +14,7 @@ import { type InstallationMetadata } from '../../types/Settings'; import * as claudeSettings from '../../utils/claude-settings'; +import * as globalCommandResolution from '../../utils/global-command-resolution'; import * as globalPackageManager from '../../utils/global-package-manager'; import { getPackageVersion } from '../../utils/terminal'; import * as updateChecker from '../../utils/update-checker'; @@ -730,4 +731,51 @@ describe('App Check for Updates', () => { 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(); + } + }); }); From 78e826cd46cd57207195cfc043df82e794aa59b4 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 06:59:46 -0500 Subject: [PATCH 5/7] fix(update): compare a checkout status line with its own version, not PATH's A status line that runs a local checkout (any command running ccstatusline.ts) is classified as self-managed, the same as the bare global `ccstatusline` command. Check for Updates then compared the latest release with the version of whatever global `ccstatusline` sits first on PATH, a copy Claude Code never runs for that status line. A checkout at the latest release with an old global on PATH was told an update is available and offered `npm install -g`; a checkout behind the latest release read as up to date when the global was current. The PATH version now applies only when the status line command is the global `ccstatusline` (with or without --config). Every other self-managed command compares this TUI's own version again, as before the self-managed change. --- src/tui/App.tsx | 16 ++++++++++++---- src/tui/__tests__/App.test.ts | 21 +++++++++++++++++++++ src/utils/claude-settings.ts | 7 ++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 3635f8023..05c6fe0ab 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -28,6 +28,7 @@ import { getPackageCommandAvailability, installStatusLine, isClaudeCodeVersionAtLeast, + isGlobalStatusLineCommand, isInstalled, isKnownCommand, setRefreshInterval, @@ -424,18 +425,25 @@ export function getPathInferredInstallation( } // The version Check for Updates compares with the latest release: the global -// package Claude Code runs when its version is known, since this TUI may be a -// different copy (npx, bunx), else this TUI's own version +// 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) { + if ( + installation.method === 'self-managed' + && activeCommand?.version + && isGlobalStatusLineCommand(statusLineCommand) + ) { return activeCommand.version; } @@ -771,7 +779,7 @@ export const App: React.FC = () => { const effectiveUpdateInstallation = getPathInferredInstallation(installation, activeCommand); void checkForUpdates({ - currentVersion: getUpdateCheckVersion(effectiveUpdateInstallation, activeCommand, getPackageVersion()), + currentVersion: getUpdateCheckVersion(effectiveUpdateInstallation, activeCommand, existingStatusLine, getPackageVersion()), installedCommand: existingStatusLine, installationMetadata: effectiveUpdateInstallation, commandAvailability diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts index 773080f1a..a84fe5d95 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -732,6 +732,27 @@ describe('App Check for Updates', () => { } }); + // 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.'] diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 5e9fb79e6..0cbf8d2b1 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; } From 1aa5321bd6b0c68165993a3a915404085a890c21 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 07:00:57 -0500 Subject: [PATCH 6/7] fix(proxy): read an HTTPS_PROXY with no scheme as an HTTP proxy A proxy given as host:port, such as `proxy.corp:3128` (a form curl accepts as http://), went to HttpsProxyAgent as it was. `new URL()` parses it without complaint but takes `proxy.corp:` as the scheme and leaves the host empty, so the agent connected to localhost:80. Check for Updates, which now goes through HTTPS_PROXY, then failed with only "Unable to query npm registry", where it used to connect directly and work wherever direct connections were allowed. The invalid-URL check missed this form, since nothing threw. createProxyAgent now adds `http://` when the value has no `scheme://`, as curl does, so the request goes through the named proxy. Values that still aren't URLs, like one with spaces, fail as before. The usage and Claude Status requests build their agents with the same helper, so they read the variable the same way. --- src/utils/__tests__/update-checker.test.ts | 8 ++++++-- src/utils/proxy-agent.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/update-checker.test.ts b/src/utils/__tests__/update-checker.test.ts index 7554a2433..ef6ca48dd 100644 --- a/src/utils/__tests__/update-checker.test.ts +++ b/src/utils/__tests__/update-checker.test.ts @@ -335,8 +335,12 @@ describe('npm registry request', () => { expect(destroy).toHaveBeenCalledOnce(); }); - it('tunnels through the proxy named by HTTPS_PROXY', async () => { - process.env.HTTPS_PROXY = 'http://proxy.example:8080'; + // 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'); 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(); } From cf7f8c5eb706b2bd198a120ca77f1d26375ced82 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Fri, 6 Nov 2026 09:38:36 -0500 Subject: [PATCH 7/7] fix: close failed registry requests before clearing deadline --- src/utils/__tests__/update-checker.test.ts | 19 +++++++++++++++++++ src/utils/update-checker.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/update-checker.test.ts b/src/utils/__tests__/update-checker.test.ts index ef6ca48dd..6ba5ebe2e 100644 --- a/src/utils/__tests__/update-checker.test.ts +++ b/src/utils/__tests__/update-checker.test.ts @@ -335,6 +335,25 @@ describe('npm registry request', () => { 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'], diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index efb531b71..a7737e393 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -319,7 +319,9 @@ export function fetchLatestNpmVersion( }, (response) => { if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { - response.resume(); + // 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; }