Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
10 changes: 10 additions & 0 deletions scripts/postinstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
11 changes: 8 additions & 3 deletions src/browser/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,22 @@ 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;
}> {
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const controller = new AbortController();
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,
});
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 };
}
Expand Down
20 changes: 19 additions & 1 deletion src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
resolve: (data: unknown) => void;
reject: (error: Error) => void;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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' ? '⚠️' : '📋';
Expand All @@ -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);
Expand All @@ -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();
}
});
});

Expand Down
17 changes: 13 additions & 4 deletions src/doctor.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -31,6 +30,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[];
Expand Down Expand Up @@ -86,7 +86,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
issues.push(
'Daemon is running but the Chrome extension is not connected.\n' +
'Please install the opencli Browser Bridge extension:\n' +
' 1. Download from GitHub Releases\n' +
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder',
);
Expand All @@ -95,10 +95,18 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
}

if (status.extensionVersion && opts.cliVersion && status.extensionVersion !== opts.cliVersion) {
issues.push(
`Extension version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
' Download the latest extension from: https://github.com/jackwener/opencli/releases',
);
}

return {
cliVersion: opts.cliVersion,
daemonRunning: status.running,
extensionConnected: status.extensionConnected,
extensionVersion: status.extensionVersion,
connectivity,
sessions,
issues,
Expand All @@ -114,7 +122,8 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {

// Extension status
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
const extVersion = report.extensionVersion ? chalk.dim(` (v${report.extensionVersion})`) : '';
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}${extVersion}`);

// Connectivity
if (report.connectivity) {
Expand Down
28 changes: 27 additions & 1 deletion src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, getRegist
import type { IPage } from './types.js';
import { pathToFileURL } from 'node:url';
import { executePipeline } from './pipeline/index.js';
import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
import { AdapterLoadError, ArgumentError, BrowserConnectError, CommandExecutionError, getErrorMessage } from './errors.js';
import { shouldUseBrowserSession } from './capabilityRouting.js';
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
import { emitHook, type HookContext } from './hooks.js';
import { checkDaemonStatus } from './browser/discover.js';
import { PKG_VERSION } from './version.js';
import chalk from 'chalk';

const _loadedModules = new Set<string>();
type CommandArgs = Record<string, unknown>;
Expand Down Expand Up @@ -151,6 +154,29 @@ 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.
// 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',
'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) => {
Expand Down
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 <N> [word1 word2 ...]
const getCompIdx = process.argv.indexOf('--get-completions');
Expand Down
114 changes: 114 additions & 0 deletions src/update-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* 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;
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)
}
});
}

/**
* 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
}
})();
}
2 changes: 1 addition & 1 deletion tests/e2e/browser-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any[] | null> {
Expand Down
Loading