From 38a3cd34528059b83194e0fd1804051ec4a9dcf9 Mon Sep 17 00:00:00 2001 From: jackwener Date: Fri, 27 Mar 2026 02:04:21 +0800 Subject: [PATCH 1/4] feat: zero onboarding, extension version check, and update notifier - Fail-fast guard in execution.ts: when daemon is running but extension is not connected, immediately surface a setup guide instead of waiting for the 30s connect timeout - Extension version handshake: extension sends `hello` with its version on WebSocket connect; daemon stores it and exposes via /status; CLI warns on mismatch in both execution path and `opencli doctor` - `opencli doctor` now shows extension version inline and reports version mismatch as an actionable issue - Non-blocking npm update checker: registers a process exit hook so the update notice appears after command output (same pattern as npm/gh/yarn); background fetch writes to ~/.opencli/update-check.json for next run - postinstall: print Browser Bridge setup instructions after shell completion install for first-time global install users Bug fixes caught in review: - discover.ts: add AbortController timeout to checkDaemonStatus() fetch, move clearTimeout after res.json() to cover body streaming - daemon.ts: clear extensionVersion and reject pending requests in ws.on('error') handler, not just ws.on('close') - update-check.ts: skip update notice when process exits with non-zero code; read cache once at module load to avoid double disk I/O; guard isNewer() against NaN from pre-release version strings --- extension/src/background.ts | 2 + scripts/postinstall.js | 10 ++++ src/browser/discover.ts | 9 ++- src/daemon.ts | 20 ++++++- src/doctor.ts | 12 +++- src/execution.ts | 26 ++++++++- src/main.ts | 6 ++ src/update-check.ts | 110 ++++++++++++++++++++++++++++++++++++ 8 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 src/update-check.ts diff --git a/extension/src/background.ts b/extension/src/background.ts index f12aa38f..5ae16597 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -51,6 +51,8 @@ function connect(): void { clearTimeout(reconnectTimer); reconnectTimer = null; } + // Send version so the daemon can report mismatches to the CLI + ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); }; ws.onmessage = async (event) => { diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 7fefda6d..b852993c 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -195,6 +195,16 @@ function main() { console.error(`Warning: Could not install shell completion: ${err.message}`); } } + + // ── Browser Bridge setup hint ─────────────────────────────────────── + console.log(''); + console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m'); + console.log(' Browser commands (bilibili, zhihu, twitter...) require the extension:'); + console.log(' 1. Download: https://github.com/jackwener/opencli/releases'); + console.log(' 2. Open chrome://extensions → enable Developer Mode → Load unpacked'); + console.log(''); + console.log(' Then run \x1b[36mopencli doctor\x1b[0m to verify.'); + console.log(''); } main(); diff --git a/src/browser/discover.ts b/src/browser/discover.ts index a73cd959..07e85c50 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -16,14 +16,19 @@ export { isDaemonRunning }; export async function checkDaemonStatus(): Promise<{ running: boolean; extensionConnected: boolean; + extensionVersion?: string; }> { try { const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); const res = await fetch(`http://127.0.0.1:${port}/status`, { headers: { 'X-OpenCLI': '1' }, + signal: controller.signal, }); - const data = await res.json() as { ok: boolean; extensionConnected: boolean }; - return { running: true, extensionConnected: data.extensionConnected }; + const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string }; + clearTimeout(timer); + return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion }; } catch { return { running: false, extensionConnected: false }; } diff --git a/src/daemon.ts b/src/daemon.ts index c39e006c..164a66ce 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -29,6 +29,7 @@ const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes // ─── State ─────────────────────────────────────────────────────────── let extensionWs: WebSocket | null = null; +let extensionVersion: string | null = null; const pending = new Map void; reject: (error: Error) => void; @@ -117,6 +118,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise jsonResponse(res, 200, { ok: true, extensionConnected: extensionWs?.readyState === WebSocket.OPEN, + extensionVersion, pending: pending.size, }); return; @@ -222,6 +224,12 @@ wss.on('connection', (ws: WebSocket) => { try { const msg = JSON.parse(data.toString()); + // Handle hello message from extension (version handshake) + if (msg.type === 'hello') { + extensionVersion = typeof msg.version === 'string' ? msg.version : null; + return; + } + // Handle log messages from extension if (msg.type === 'log') { const prefix = msg.level === 'error' ? '❌' : msg.level === 'warn' ? '⚠️' : '📋'; @@ -247,6 +255,7 @@ wss.on('connection', (ws: WebSocket) => { clearInterval(heartbeatInterval); if (extensionWs === ws) { extensionWs = null; + extensionVersion = null; // Reject all pending requests since the extension is gone for (const [id, p] of pending) { clearTimeout(p.timer); @@ -258,7 +267,16 @@ wss.on('connection', (ws: WebSocket) => { ws.on('error', () => { clearInterval(heartbeatInterval); - if (extensionWs === ws) extensionWs = null; + if (extensionWs === ws) { + extensionWs = null; + extensionVersion = null; + // Reject pending requests in case 'close' does not follow this 'error' + for (const [, p] of pending) { + clearTimeout(p.timer); + p.reject(new Error('Extension disconnected')); + } + pending.clear(); + } }); }); diff --git a/src/doctor.ts b/src/doctor.ts index 59cfd140..dd92cf45 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -31,6 +31,7 @@ export type DoctorReport = { cliVersion?: string; daemonRunning: boolean; extensionConnected: boolean; + extensionVersion?: string; connectivity?: ConnectivityResult; sessions?: Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>; issues: string[]; @@ -95,10 +96,18 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise(); type CommandArgs = Record; @@ -151,6 +154,27 @@ export async function executeCommand( let result: unknown; try { if (shouldUseBrowserSession(cmd)) { + // ── Fail-fast: only when daemon is UP but extension is not connected ── + // If daemon is not running, let browserSession() handle auto-start as usual. + // We only short-circuit when the daemon confirms the extension is missing — + // that's a clear setup gap, not a transient startup state. + const status = await checkDaemonStatus(); + if (status.running && !status.extensionConnected) { + throw new BrowserConnectError( + 'Browser Bridge extension not connected', + 'Install the Browser Bridge:\n' + + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + + ' 2. chrome://extensions → Developer Mode → Load unpacked\n' + + ' Then run: opencli doctor', + ); + } + // ── Version mismatch: warn but don't block ── + if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) { + process.stderr.write( + chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`) + ); + } + ensureRequiredEnv(cmd); const BrowserFactory = getBrowserFactory(); result = await browserSession(BrowserFactory, async (page) => { diff --git a/src/main.ts b/src/main.ts index 3ac704e5..f1d43c09 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { discoverClis, discoverPlugins } from './discovery.js'; import { getCompletions } from './completion.js'; import { runCli } from './cli.js'; import { emitHook } from './hooks.js'; +import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -29,6 +30,11 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis'); await discoverClis(BUILTIN_CLIS, USER_CLIS); await discoverPlugins(); +// Register exit hook: notice appears after command output (same as npm/gh/yarn) +registerUpdateNoticeOnExit(); +// Kick off background fetch for next run (non-blocking) +checkForUpdateBackground(); + // ── Fast-path: handle --get-completions before commander parses ───────── // Usage: opencli --get-completions --cursor [word1 word2 ...] const getCompIdx = process.argv.indexOf('--get-completions'); diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 00000000..42381cd5 --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,110 @@ +/** + * Non-blocking update checker. + * + * Pattern: register exit-hook + kick-off-background-fetch + * - On startup: kick off background fetch (non-blocking) + * - On process exit: read cache, print notice if newer version exists + * - Check interval: 24 hours + * - Notice appears AFTER command output, not before (same as npm/gh/yarn) + * - Never delays or blocks the CLI command + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import chalk from 'chalk'; +import { PKG_VERSION } from './version.js'; + +const CACHE_DIR = path.join(os.homedir(), '.opencli'); +const CACHE_FILE = path.join(CACHE_DIR, 'update-check.json'); +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h +const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@jackwener/opencli/latest'; + +interface UpdateCache { + lastCheck: number; + latestVersion: string; +} + +// Read cache once at module load — shared by both exported functions +const _cache: UpdateCache | null = (() => { + try { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')) as UpdateCache; + } catch { + return null; + } +})(); + +function writeCache(latestVersion: string): void { + try { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(CACHE_FILE, JSON.stringify({ lastCheck: Date.now(), latestVersion }), 'utf-8'); + } catch { + // Best-effort; never fail + } +} + +/** Compare semver strings. Returns true if `a` is strictly newer than `b`. */ +function isNewer(a: string, b: string): boolean { + const parse = (v: string) => v.replace(/^v/, '').split('-')[0].split('.').map(Number); + const pa = parse(a); + const pb = parse(b); + if (pa.some(isNaN) || pb.some(isNaN)) return false; + const [aMaj, aMin, aPat] = pa; + const [bMaj, bMin, bPat] = pb; + if (aMaj !== bMaj) return aMaj > bMaj; + if (aMin !== bMin) return aMin > bMin; + return aPat > bPat; +} + +function isCI(): boolean { + return !!(process.env.CI || process.env.CONTINUOUS_INTEGRATION); +} + +/** + * Register a process exit hook that prints an update notice if a newer + * version was found on the last background check. + * Notice appears after command output — same pattern as npm/gh/yarn. + * Skipped during --get-completions to avoid polluting shell completion output. + */ +export function registerUpdateNoticeOnExit(): void { + if (isCI()) return; + if (process.argv.includes('--get-completions')) return; + + process.on('exit', (code) => { + if (code !== 0) return; // Don't show update notice on error exit + if (!_cache) return; + if (!isNewer(_cache.latestVersion, PKG_VERSION)) return; + process.stderr.write( + chalk.yellow(`\n Update available: v${PKG_VERSION} → v${_cache.latestVersion}\n`) + + chalk.dim(` Run: npm install -g @jackwener/opencli\n\n`), + ); + }); +} + +/** + * Kick off a background fetch to npm registry. Writes to cache for next run. + * Fully non-blocking — never awaited. + */ +export function checkForUpdateBackground(): void { + if (isCI()) return; + if (_cache && Date.now() - _cache.lastCheck < CHECK_INTERVAL_MS) return; + + void (async () => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + const res = await fetch(NPM_REGISTRY_URL, { + signal: controller.signal, + headers: { 'User-Agent': `opencli/${PKG_VERSION}` }, + }); + clearTimeout(timer); + if (!res.ok) return; + const data = await res.json() as { version?: string }; + if (typeof data.version === 'string') { + writeCache(data.version); + } + } catch { + // Network error: silently skip, try again next run + } + })(); +} From 1e24db4e4b5fe7ef9d12e3e0d0d45e91392383b2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Fri, 27 Mar 2026 02:14:23 +0800 Subject: [PATCH 2/4] fix: reduce fail-fast timeout to 300ms and guard stderr.write in exit hook --- src/browser/discover.ts | 4 ++-- src/execution.ts | 4 +++- src/update-check.ts | 12 ++++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/browser/discover.ts b/src/browser/discover.ts index 07e85c50..1dab6e5f 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -13,7 +13,7 @@ export { isDaemonRunning }; /** * Check daemon status and return connection info. */ -export async function checkDaemonStatus(): Promise<{ +export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{ running: boolean; extensionConnected: boolean; extensionVersion?: string; @@ -21,7 +21,7 @@ export async function checkDaemonStatus(): Promise<{ try { const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 2000); + const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000); const res = await fetch(`http://127.0.0.1:${port}/status`, { headers: { 'X-OpenCLI': '1' }, signal: controller.signal, diff --git a/src/execution.ts b/src/execution.ts index b402f006..c981c43f 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -158,7 +158,9 @@ export async function executeCommand( // If daemon is not running, let browserSession() handle auto-start as usual. // We only short-circuit when the daemon confirms the extension is missing — // that's a clear setup gap, not a transient startup state. - const status = await checkDaemonStatus(); + // Use a short timeout: localhost responds in <50ms when running. + // 300ms avoids a full 2s wait on cold-start (daemon not yet running). + const status = await checkDaemonStatus({ timeout: 300 }); if (status.running && !status.extensionConnected) { throw new BrowserConnectError( 'Browser Bridge extension not connected', diff --git a/src/update-check.ts b/src/update-check.ts index 42381cd5..5a4d35fb 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -74,10 +74,14 @@ export function registerUpdateNoticeOnExit(): void { if (code !== 0) return; // Don't show update notice on error exit if (!_cache) return; if (!isNewer(_cache.latestVersion, PKG_VERSION)) return; - process.stderr.write( - chalk.yellow(`\n Update available: v${PKG_VERSION} → v${_cache.latestVersion}\n`) + - chalk.dim(` Run: npm install -g @jackwener/opencli\n\n`), - ); + try { + process.stderr.write( + chalk.yellow(`\n Update available: v${PKG_VERSION} → v${_cache.latestVersion}\n`) + + chalk.dim(` Run: npm install -g @jackwener/opencli\n\n`), + ); + } catch { + // Ignore broken pipe (stderr closed before process exits) + } }); } From 110d46025b8416b8d200d26846099cb5e96b5635 Mon Sep 17 00:00:00 2001 From: jackwener Date: Fri, 27 Mar 2026 13:17:35 +0800 Subject: [PATCH 3/4] fix(doctor): remove unused fix option and add release URL to extension install hint --- src/doctor.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/doctor.ts b/src/doctor.ts index dd92cf45..e78178bb 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -1,5 +1,5 @@ /** - * opencli doctor — diagnose and fix browser connectivity. + * opencli doctor — diagnose browser connectivity. * * Simplified for the daemon-based architecture. No more token management, * MCP path discovery, or config file scanning. @@ -14,7 +14,6 @@ import { getErrorMessage } from './errors.js'; import { getRuntimeLabel } from './runtime-detect.js'; export type DoctorOptions = { - fix?: boolean; yes?: boolean; live?: boolean; sessions?: boolean; @@ -87,7 +86,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise Date: Fri, 27 Mar 2026 13:21:34 +0800 Subject: [PATCH 4/4] fix(e2e): update BrowserBridge unavailable detection regex to match current error format --- tests/e2e/browser-public.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/browser-public.test.ts b/tests/e2e/browser-public.test.ts index c8922d14..377a0df3 100644 --- a/tests/e2e/browser-public.test.ts +++ b/tests/e2e/browser-public.test.ts @@ -35,7 +35,7 @@ function isImdbChallenge(result: CliResult): boolean { function isBrowserBridgeUnavailable(result: CliResult): boolean { const text = `${result.stderr}\n${result.stdout}`; - return /Browser Extension is not connected|Browser Bridge extension.*not connected|Daemon is running but the Browser Extension is not connected/i.test(text); + return /Browser Bridge not connected|Extension.*not connected|not connected.*extension/i.test(text); } async function expectImdbDataOrChallengeSkip(args: string[], label: string): Promise {