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 + +
+
+
+