From 5d2899907ebb718d94162a776d08c0d6ad04e275 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 5 Aug 2026 01:53:43 +0200 Subject: [PATCH] fix(cli-gating): gate the tunnel button instead of deleting it, and cover antigravity Follow-up to #200 and #201, which gate the welcome buttons and the run-mode dropdown on whether the CLI is actually installed. Four corrections: 1. #200 also DELETED the Cloudflare Tunnel welcome button and the QR widget outright. Its rationale is right (offering a tunnel where cloudflared is not installed is a bad default) but the conclusion overshoots: the welcome QR is the whole scan-to-connect-from-your-phone flow, and deleting it left a large block of live tunnel code in settings-ui.js driving elements that no longer existed. Both are restored and the button is gated on cloudflared, which is what the stated rationale actually asks for. New cloudflared-resolver.ts mirrors the CLI resolvers, and TunnelManager now shares its search path so the button and the spawn can never disagree about where cloudflared lives. 2. Antigravity was missing from the run-mode gating, the one run mode LEAST likely to be installed. It slipped past because #201 predates it. Covered now, plus a static test that fails if a sixth mode reaches the dropdown without being gated, so the next one cannot slip the same way. 3. The per-surface fetches are replaced by the injected availability object already used for the Codex settings tab, so the codebase has one mechanism rather than two. The status routes buy nothing as a gating source: every resolver memoizes its PATH probe server-side, so a fetch is exactly as stale as an injected value while costing a round trip every time the dropdown opens and leaving the welcome buttons to flicker in after paint. The routes themselves stay, including the /api/claude/status that #200 adds. 4. Unknown availability now reads as AVAILABLE for run buttons. Both PRs hid the button on a failed fetch, so a blip left a working install with nothing to click; a genuinely missing CLI only ever produced an error toast. The Codex settings TAB keeps the opposite default, since hiding it costs nothing. The dropdown query is also scoped to the menu: `.run-mode-option` is the class the saved-dashboard and history rows use too, and a document-wide querySelector would have found whichever came first in the DOM. Fixes a latent environment-sensitivity in 816d900 while here: the index-title test asserted the template was untouched apart from the title, which held only on a machine with no codex installed. Verified end-to-end against a real server on an isolated instance+socket, with Playwright: gemini/codex hidden and claude/opencode/antigravity/shell shown, matching this host, tunnel button back, Codex settings tab still hidden, no console errors. Full test:ci sweep green (3902 tests). Co-Authored-By: Claude Opus 5 (1M context) --- src/tunnel-manager.ts | 25 ++----- src/utils/cloudflared-resolver.ts | 65 ++++++++++++++++++ src/web/public/index.html | 8 +++ src/web/public/session-ui.js | 37 +++++------ src/web/public/settings-ui.js | 62 +++++++++++------- src/web/public/terminal-ui.js | 4 +- src/web/server.ts | 52 +++++++++++---- test/render-index-html.test.ts | 72 +++++++++++++++++--- test/run-mode-ui.test.ts | 105 +++++++++++++++++++++++++++++- test/server-index-title.test.ts | 12 +++- 10 files changed, 349 insertions(+), 93 deletions(-) create mode 100644 src/utils/cloudflared-resolver.ts diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts index 9e10764e..4a84adea 100644 --- a/src/tunnel-manager.ts +++ b/src/tunnel-manager.ts @@ -15,10 +15,8 @@ import { EventEmitter } from 'node:events'; import { spawn, type ChildProcess } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; import { randomBytes } from 'node:crypto'; +import { resolveCloudflaredPath } from './utils/cloudflared-resolver.js'; import { QR_TOKEN_TTL_MS, QR_TOKEN_GRACE_MS, @@ -95,23 +93,10 @@ export class TunnelManager extends EventEmitter { private resolveCloudflared(): string | null { if (this.cloudflaredPath) return this.cloudflaredPath; - // Check ~/.local/bin first (common user install location) - const localBin = join(homedir(), '.local', 'bin', 'cloudflared'); - if (existsSync(localBin)) { - this.cloudflaredPath = localBin; - return localBin; - } - - // Check /usr/local/bin - const usrLocalBin = '/usr/local/bin/cloudflared'; - if (existsSync(usrLocalBin)) { - this.cloudflaredPath = usrLocalBin; - return usrLocalBin; - } - - // Fall back to PATH - this.cloudflaredPath = 'cloudflared'; - return 'cloudflared'; + // Shared with the welcome-screen availability check, so the button and the + // spawn can never disagree about where cloudflared lives. + this.cloudflaredPath = resolveCloudflaredPath() ?? 'cloudflared'; + return this.cloudflaredPath; } /** Clear all pending timers */ diff --git a/src/utils/cloudflared-resolver.ts b/src/utils/cloudflared-resolver.ts new file mode 100644 index 00000000..c7719001 --- /dev/null +++ b/src/utils/cloudflared-resolver.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Resolve the `cloudflared` binary across common install paths. + * + * Mirrors the CLI resolvers (gemini-cli-resolver.ts et al), for the same reason + * they exist: the welcome screen should not offer a button whose only possible + * outcome is an error toast. + * + * The search list is deliberately the SAME one `TunnelManager.resolveCloudflared()` + * has always used, and that method now delegates here so the two can never drift. + * The difference is the fallback: this module answers "is it installed?" honestly + * with null, while the tunnel manager keeps falling back to the bare name so a + * cloudflared that only exists somewhere on the tunnel process's PATH still + * starts. A stricter answer there would turn a working tunnel into a refusal. + * + * @module utils/cloudflared-resolver + */ + +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; + +/** Common directories where the cloudflared binary may be installed */ +const CLOUDFLARED_SEARCH_DIRS = [join(homedir(), '.local', 'bin'), '/usr/local/bin']; + +/** Cached path to the cloudflared binary (empty string = searched but not found) */ +let _cloudflaredPath: string | null = null; + +/** + * Finds the `cloudflared` binary. + * + * @returns Absolute path, or null if not found + */ +export function resolveCloudflaredPath(): string | null { + if (_cloudflaredPath !== null) return _cloudflaredPath || null; + + for (const dir of CLOUDFLARED_SEARCH_DIRS) { + const candidate = join(dir, 'cloudflared'); + if (existsSync(candidate)) { + _cloudflaredPath = candidate; + return candidate; + } + } + + try { + const result = execSync('which cloudflared', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); + if (result && existsSync(result)) { + _cloudflaredPath = result; + return result; + } + } catch { + // Not on PATH either. + } + + _cloudflaredPath = ''; // mark as searched, not found + return null; +} + +/** + * Check if cloudflared is available on the system. + */ +export function isCloudflaredAvailable(): boolean { + return resolveCloudflaredPath() !== null; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index 63d6df0b..0b36b8a7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -315,6 +315,10 @@ Run Claude Code + +
+
+
+