mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
dccf9d00e9
* fix(doctor): pass session to connectivity probe * fix(doctor): isolate probe session name * fix(cli): mark browser session as required
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
/**
|
|
* opencli doctor — diagnose browser connectivity.
|
|
*
|
|
* Simplified for the daemon-based architecture.
|
|
*/
|
|
|
|
import { styleText } from 'node:util';
|
|
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
|
import { BrowserBridge } from './browser/index.js';
|
|
import { getDaemonHealth } from './browser/daemon-client.js';
|
|
import { getErrorMessage } from './errors.js';
|
|
import { getRuntimeLabel } from './runtime-detect.js';
|
|
import { getCachedLatestExtensionVersion } from './update-check.js';
|
|
import type { BrowserProfileStatus } from './browser/daemon-client.js';
|
|
import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
|
|
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
|
|
import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js';
|
|
|
|
const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
|
|
const DOCTOR_SESSION = '__doctor__';
|
|
|
|
/** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
|
|
function parseSemver(v: string): [number, number, number] | null {
|
|
const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number);
|
|
if (parts.length < 3 || parts.some(isNaN)) return null;
|
|
return [parts[0], parts[1], parts[2]];
|
|
}
|
|
|
|
/** Returns true if `a` is strictly newer than `b`. */
|
|
function isNewerVersion(a: string, b: string): boolean {
|
|
const va = parseSemver(a);
|
|
const vb = parseSemver(b);
|
|
if (!va || !vb) return false;
|
|
const cmp = va[0] - vb[0] || va[1] - vb[1] || va[2] - vb[2];
|
|
return cmp > 0;
|
|
}
|
|
|
|
/** Check if version satisfies a simple range like ">=1.7.0". */
|
|
function satisfiesRange(version: string, range: string): boolean {
|
|
const match = range.match(/^(>=?)\s*(\S+)$/);
|
|
if (!match) return true; // Unknown range format — don't block
|
|
const [, op, rangeVer] = match;
|
|
const v = parseSemver(version);
|
|
const r = parseSemver(rangeVer);
|
|
if (!v || !r) return true;
|
|
const cmp = v[0] - r[0] || v[1] - r[1] || v[2] - r[2];
|
|
return op === '>=' ? cmp >= 0 : cmp > 0;
|
|
}
|
|
|
|
export type DoctorOptions = {
|
|
yes?: boolean;
|
|
cliVersion?: string;
|
|
};
|
|
|
|
export type ConnectivityResult = {
|
|
ok: boolean;
|
|
error?: string;
|
|
durationMs: number;
|
|
};
|
|
|
|
|
|
export type DoctorReport = {
|
|
cliVersion?: string;
|
|
daemonRunning: boolean;
|
|
daemonFlaky?: boolean;
|
|
daemonStale?: boolean;
|
|
daemonVersion?: string;
|
|
extensionConnected: boolean;
|
|
extensionFlaky?: boolean;
|
|
extensionVersion?: string;
|
|
latestExtensionVersion?: string;
|
|
connectivity?: ConnectivityResult;
|
|
profiles?: BrowserProfileStatus[];
|
|
adapterShadows?: AdapterShadow[];
|
|
issues: string[];
|
|
};
|
|
|
|
/**
|
|
* Test connectivity by attempting a real browser command.
|
|
*/
|
|
export async function checkConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
|
const start = Date.now();
|
|
try {
|
|
const bridge = new BrowserBridge();
|
|
const page = await bridge.connect({
|
|
timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS,
|
|
session: DOCTOR_SESSION,
|
|
surface: 'browser',
|
|
});
|
|
try {
|
|
// Try a simple eval to verify end-to-end connectivity.
|
|
await page.evaluate('1 + 1');
|
|
await page.closeWindow?.();
|
|
} finally {
|
|
await bridge.close();
|
|
}
|
|
return { ok: true, durationMs: Date.now() - start };
|
|
} catch (err) {
|
|
return { ok: false, error: getErrorMessage(err), durationMs: Date.now() - start };
|
|
}
|
|
}
|
|
|
|
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
// Live connectivity check is the core of doctor — it doubles as auto-start
|
|
// (bridge.connect spawns daemon) and validates end-to-end browser bridge health.
|
|
const connectivity = await checkConnectivity();
|
|
|
|
// Single status read *after* connectivity side-effects settle.
|
|
const health = await getDaemonHealth();
|
|
const daemonRunning = health.state !== 'stopped';
|
|
const extensionConnected = health.state === 'ready';
|
|
const daemonFlaky = connectivity.ok && !daemonRunning;
|
|
const extensionFlaky = connectivity.ok && daemonRunning && !extensionConnected;
|
|
const daemonStale = isDaemonStale(health.status, opts.cliVersion);
|
|
const profiles = health.status?.profiles;
|
|
const extensionVersion = health.status?.extensionVersion;
|
|
const adapterShadows = findShadowedUserAdapters();
|
|
|
|
const issues: string[] = [];
|
|
if (daemonFlaky) {
|
|
issues.push(
|
|
'Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.\n' +
|
|
'This usually means the daemon crashed or exited right after serving the live probe.',
|
|
);
|
|
} else if (!daemonRunning) {
|
|
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
|
|
}
|
|
if (daemonStale && opts.cliVersion) {
|
|
issues.push(staleDaemonIssue(health.status, opts.cliVersion));
|
|
}
|
|
if (extensionFlaky) {
|
|
issues.push(
|
|
'Extension connection is unstable. The live browser test succeeded, but the daemon reported the extension disconnected immediately afterward.\n' +
|
|
'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.',
|
|
);
|
|
} else if (daemonRunning && !extensionConnected) {
|
|
if (health.state === 'profile-required') {
|
|
issues.push(
|
|
'Multiple Chrome profiles are connected to the daemon, but no default profile was selected.\n' +
|
|
' Run opencli profile list, then opencli profile use <name>, or pass --profile <name>.',
|
|
);
|
|
} else if (health.state === 'profile-disconnected') {
|
|
issues.push(
|
|
`Selected browser profile is not connected: ${health.status?.contextId ?? 'unknown'}.\n` +
|
|
' Open that Chrome profile and make sure the OpenCLI extension is enabled.',
|
|
);
|
|
} else {
|
|
issues.push(
|
|
'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
|
|
'If the extension is already installed, try: opencli daemon restart\n' +
|
|
'If the extension is not installed:\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',
|
|
);
|
|
}
|
|
}
|
|
if (extensionConnected && !extensionVersion) {
|
|
issues.push(
|
|
'Extension is connected but did not report a version.\n' +
|
|
' This usually means an outdated Browser Bridge extension.\n' +
|
|
' Reload or reinstall the extension from: https://github.com/jackwener/opencli/releases',
|
|
);
|
|
}
|
|
if (!connectivity.ok) {
|
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
}
|
|
const extensionCompatRange = health.status?.extensionCompatRange;
|
|
if (extensionVersion && opts.cliVersion && extensionCompatRange) {
|
|
if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) {
|
|
issues.push(
|
|
`CLI version incompatible with extension: extension v${extensionVersion} requires CLI ${extensionCompatRange}, but CLI is v${opts.cliVersion}\n` +
|
|
' Update the CLI: npm install -g @jackwener/opencli\n' +
|
|
' Or download a compatible extension from: https://github.com/jackwener/opencli/releases',
|
|
);
|
|
}
|
|
} else if (extensionVersion && opts.cliVersion) {
|
|
// Fallback for older extensions that don't send compatRange
|
|
const extMajor = extensionVersion.split('.')[0];
|
|
const cliMajor = opts.cliVersion.split('.')[0];
|
|
if (extMajor !== cliMajor) {
|
|
issues.push(
|
|
`Extension major version mismatch: extension v${extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
|
|
' Download the latest extension from: https://github.com/jackwener/opencli/releases',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Extension update check (from cached background fetch)
|
|
const latestExtensionVersion = getCachedLatestExtensionVersion();
|
|
if (extensionVersion && latestExtensionVersion && isNewerVersion(latestExtensionVersion, extensionVersion)) {
|
|
issues.push(
|
|
`Extension update available: v${extensionVersion} → v${latestExtensionVersion}\n` +
|
|
' Download from: https://github.com/jackwener/opencli/releases',
|
|
);
|
|
}
|
|
if (adapterShadows.length > 0) {
|
|
issues.push(formatAdapterShadowIssue(adapterShadows));
|
|
}
|
|
|
|
return {
|
|
cliVersion: opts.cliVersion,
|
|
daemonRunning,
|
|
daemonFlaky,
|
|
daemonStale,
|
|
daemonVersion: health.status?.daemonVersion,
|
|
extensionConnected,
|
|
extensionFlaky,
|
|
extensionVersion,
|
|
latestExtensionVersion,
|
|
connectivity,
|
|
profiles,
|
|
adapterShadows,
|
|
issues,
|
|
};
|
|
}
|
|
|
|
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
const lines = [styleText('bold', `opencli v${report.cliVersion ?? 'unknown'} doctor`) + styleText('dim', ` (${getRuntimeLabel()})`), ''];
|
|
|
|
// Daemon status
|
|
const daemonIcon = report.daemonFlaky
|
|
? styleText('yellow', '[WARN]')
|
|
: report.daemonStale
|
|
? styleText('yellow', '[WARN]')
|
|
: report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
|
|
const daemonLabel = report.daemonFlaky
|
|
? 'unstable (running during live check, then stopped)'
|
|
: report.daemonRunning
|
|
? `running on port ${DEFAULT_DAEMON_PORT} (${report.daemonStale
|
|
? `${formatDaemonVersion(report)}, stale; CLI v${report.cliVersion ?? 'unknown'}`
|
|
: formatDaemonVersion(report)})`
|
|
: 'not running';
|
|
lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
|
|
|
|
// Extension status
|
|
const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
|
|
? styleText('yellow', '[WARN]')
|
|
: report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
|
|
const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
|
|
? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
|
|
: '';
|
|
const extVersion = !report.extensionConnected
|
|
? ''
|
|
: report.extensionVersion
|
|
? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint
|
|
: styleText('dim', ' (version unknown)');
|
|
const extLabel = report.extensionFlaky
|
|
? 'unstable (connected during live check, then disconnected)'
|
|
: report.extensionConnected ? 'connected' : 'not connected';
|
|
lines.push(`${extIcon} Extension: ${extLabel}${extVersion}`);
|
|
|
|
if (report.profiles && report.profiles.length > 0) {
|
|
const config = loadProfileConfig();
|
|
lines.push('', styleText('bold', 'Profiles:'));
|
|
for (const profile of report.profiles) {
|
|
const alias = aliasForContextId(config, profile.contextId);
|
|
const aliasText = alias ? ` (${alias})` : '';
|
|
const defaultText = config.defaultContextId === profile.contextId ? ', default' : '';
|
|
const version = profile.extensionVersion ? `v${profile.extensionVersion}` : 'version unknown';
|
|
lines.push(styleText('dim', ` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`));
|
|
}
|
|
}
|
|
|
|
// Connectivity
|
|
if (report.connectivity) {
|
|
const connIcon = report.connectivity.ok ? styleText('green', '[OK]') : styleText('red', '[FAIL]');
|
|
const detail = report.connectivity.ok
|
|
? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
: `failed (${report.connectivity.error ?? 'unknown'})`;
|
|
lines.push(`${connIcon} Connectivity: ${detail}`);
|
|
}
|
|
|
|
if (report.issues.length) {
|
|
lines.push('', styleText('yellow', 'Issues:'));
|
|
for (const issue of report.issues) {
|
|
lines.push(styleText('dim', ` • ${issue}`));
|
|
}
|
|
} else if (report.daemonRunning && report.extensionConnected) {
|
|
lines.push('', styleText('green', 'Everything looks good!'));
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|