diff --git a/src/browser.test.ts b/src/browser.test.ts index 56a0fd13..1753195c 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -144,13 +144,15 @@ describe('BrowserBridge state', () => { await expect(bridge.connect()).rejects.toThrow('Session is closing'); }); - it('fails fast when daemon is running but extension is disconnected', async () => { + it('fails fast when daemon is running but extension is disconnected (same version)', async () => { + const { PKG_VERSION } = await import('./version.js'); vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ state: 'no-extension', status: { ok: true, pid: 1, uptime: 0, + daemonVersion: PKG_VERSION, extensionConnected: false, pending: 0, memoryMB: 0, @@ -162,6 +164,47 @@ describe('BrowserBridge state', () => { await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected'); }); + + it('attempts stale daemon replacement when daemonVersion is missing', async () => { + vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ + state: 'no-extension', + status: { + ok: true, + pid: 1, + uptime: 0, + extensionConnected: false, + pending: 0, + memoryMB: 0, + port: 0, + }, + }); + vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false); + + const bridge = new BrowserBridge(); + + await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced'); + }); + + it('attempts stale daemon replacement when daemonVersion mismatches', async () => { + vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ + state: 'no-extension', + status: { + ok: true, + pid: 1, + uptime: 0, + daemonVersion: '0.0.1', + extensionConnected: false, + pending: 0, + memoryMB: 0, + port: 0, + }, + }); + vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false); + + const bridge = new BrowserBridge(); + + await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced'); + }); }); describe('stealth anti-detection', () => { diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index fa19abde..d31a01d8 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -9,9 +9,10 @@ import * as fs from 'node:fs'; import type { IPage } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; import { Page } from './page.js'; -import { getDaemonHealth } from './daemon-client.js'; +import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { BrowserConnectError } from '../errors.js'; +import { PKG_VERSION } from '../version.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension @@ -66,21 +67,50 @@ export class BrowserBridge implements IBrowserFactory { // Fast path: everything ready if (health.state === 'ready') return; - // Daemon running but no extension — wait for extension with progress + // Daemon running but no extension if (health.state === 'no-extension') { - if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { - process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n'); - process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); + // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon) + const daemonVersion = health.status?.daemonVersion; + const isStale = !daemonVersion || daemonVersion !== PKG_VERSION; + + if (isStale) { + // Stale daemon — restart it so extension gets a fresh WebSocket endpoint + const reason = daemonVersion + ? `v${daemonVersion} ≠ v${PKG_VERSION}` + : `pre-version daemon, CLI is v${PKG_VERSION}`; + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`); + } + const shutdownAccepted = await requestDaemonShutdown(); + const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000); + + if (!portReleased) { + // Stale daemon replacement failed — don't blindly spawn on an occupied port + throw new BrowserConnectError( + 'Stale daemon could not be replaced', + `A stale daemon (${reason}) is running but did not shut down.\n` + + ' Run manually: opencli daemon stop && opencli doctor', + 'daemon-not-running', + ); + } + // Port released — fall through to spawn a fresh daemon + } else { + // Same version — wait for extension to connect + if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { + process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n'); + process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); + } + if (await this._pollUntilReady(timeoutMs)) return; + throw new BrowserConnectError( + 'Browser Bridge extension not connected', + 'Make sure Chrome/Chromium is open and the extension is enabled.\n' + + 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' + + 'If not installed:\n' + + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + + ' 2. Open chrome://extensions → Developer Mode → Load unpacked', + 'extension-not-connected', + ); } - if (await this._pollUntilReady(timeoutMs)) return; - throw new BrowserConnectError( - 'Browser Bridge extension not connected', - 'Install the Browser Bridge:\n' + - ' 1. Download: https://github.com/jackwener/opencli/releases\n' + - ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' + - ' Then run: opencli doctor', - 'extension-not-connected', - ); } // No daemon — spawn one @@ -113,10 +143,11 @@ export class BrowserBridge implements IBrowserFactory { if (finalHealth.state === 'no-extension') { throw new BrowserConnectError( 'Browser Bridge extension not connected', - 'Install the Browser Bridge:\n' + + 'Make sure Chrome/Chromium is open and the extension is enabled.\n' + + 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' + + 'If not installed:\n' + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + - ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' + - ' Then run: opencli doctor', + ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected', ); } @@ -128,6 +159,17 @@ export class BrowserBridge implements IBrowserFactory { ); } + /** Poll until daemon is fully stopped (port released). */ + private async _waitForDaemonStop(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 200)); + const h = await getDaemonHealth(); + if (h.state === 'stopped') return true; + } + return false; + } + /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */ private async _pollUntilReady(timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index cc7cd3b0..19dd7a7a 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -65,6 +65,7 @@ export interface DaemonStatus { ok: boolean; pid: number; uptime: number; + daemonVersion?: string; extensionConnected: boolean; extensionVersion?: string; extensionCompatRange?: string; diff --git a/src/cli.ts b/src/cli.ts index d9502a7f..cc4db59a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; import { registerAllCommands } from './commanderAdapter.js'; -import { EXIT_CODES, getErrorMessage } from './errors.js'; +import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js'; import { TargetError } from './browser/target-errors.js'; import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js'; import { daemonStop } from './commands/daemon.js'; @@ -311,11 +311,9 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command const page = await getBrowserPage(); await fn(page, ...args); } catch (err) { - const msg = getErrorMessage(err); - if (msg.includes('Extension not connected') || msg.includes('Daemon')) { - log.error(`Browser not connected. Run 'opencli doctor' to diagnose.`); - } else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) { - log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`); + if (err instanceof BrowserConnectError) { + log.error(err.message); + if (err.hint) log.error(`Hint: ${err.hint}`); } else if (err instanceof TargetError) { log.error(`[${err.code}] ${err.message}`); if (err.hint) log.error(`Hint: ${err.hint}`); @@ -324,7 +322,12 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`)); } } else { - log.error(msg); + const msg = getErrorMessage(err); + if (msg.includes('attach failed') || msg.includes('chrome-extension://')) { + log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`); + } else { + log.error(msg); + } } process.exitCode = EXIT_CODES.GENERIC_ERROR; } diff --git a/src/daemon.ts b/src/daemon.ts index f4de2ea1..391cc334 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -24,6 +24,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws'; import { DEFAULT_DAEMON_PORT } from './constants.js'; import { EXIT_CODES } from './errors.js'; import { log } from './logger.js'; +import { PKG_VERSION } from './version.js'; const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); @@ -123,6 +124,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise ok: true, pid: process.pid, uptime, + daemonVersion: PKG_VERSION, extensionConnected: extensionWs?.readyState === WebSocket.OPEN, extensionVersion, extensionCompatRange, diff --git a/src/doctor.ts b/src/doctor.ts index f0eb0733..7a2e6558 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -60,6 +60,7 @@ export type DoctorReport = { cliVersion?: string; daemonRunning: boolean; daemonFlaky?: boolean; + daemonVersion?: string; extensionConnected: boolean; extensionFlaky?: boolean; extensionVersion?: string; @@ -131,13 +132,27 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise