feat: replace @playwright/mcp with lightweight daemon + Chrome Extension
Architecture: - Micro-daemon (HTTP + WebSocket bridge, ~190 lines, auto-start/idle-exit) - Chrome MV3 Extension using chrome.debugger CDP (10KB build) - 5 protocol actions: exec, navigate, tabs, cookies, screenshot - All DOM ops via JS evaluate — no extension update needed for new features Key features: - CDP Runtime.evaluate for JS execution in page context - Tab management, cookie access via Chrome APIs - Auto-start daemon on cold boot, idle auto-exit (5min) - Minimal permissions: debugger, tabs, cookies, activeTab, alarms Tested: zhihu hot (14.3s), twitter timeline (9.3s)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,2 @@
|
||||
// Minimal content script — required by manifest to auto-grant host permissions.
|
||||
// No actual logic needed; chrome.scripting.executeScript handles all injection.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 628 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "opencli Browser Bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"scripting",
|
||||
"tabs",
|
||||
"cookies",
|
||||
"activeTab",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content/noop.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "dist/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "opencli Browser Bridge",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
},
|
||||
"homepage_url": "https://github.com/jackwener/opencli"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* opencli Browser Bridge — Service Worker (background script).
|
||||
*
|
||||
* Connects to the opencli daemon via WebSocket, receives commands,
|
||||
* dispatches them to Chrome APIs (scripting/tabs/cookies), returns results.
|
||||
*
|
||||
* IMPORTANT: In IIFE mode (non-module), all chrome.* API registrations
|
||||
* must happen inside lifecycle events (onInstalled/onStartup), NOT at
|
||||
* the top level, or the service worker registration will fail.
|
||||
*/
|
||||
|
||||
import type { Command, Result } from './protocol';
|
||||
import { DAEMON_WS_URL, WS_RECONNECT_DELAY } from './protocol';
|
||||
import * as cdp from './cdp';
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── WebSocket connection ────────────────────────────────────────────
|
||||
|
||||
function connect(): void {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(DAEMON_WS_URL);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[opencli] Connected to daemon');
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const command = JSON.parse(event.data as string) as Command;
|
||||
const result = await handleCommand(command);
|
||||
ws?.send(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
console.error('[opencli] Message handling error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[opencli] Disconnected from daemon');
|
||||
ws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, WS_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
// ─── Lifecycle events ────────────────────────────────────────────────
|
||||
// All chrome.* API registrations must be in these listeners, not top-level.
|
||||
|
||||
function initialize(): void {
|
||||
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
|
||||
cdp.registerListeners();
|
||||
connect();
|
||||
console.log('[opencli] Browser Bridge extension initialized');
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'keepalive') connect();
|
||||
});
|
||||
|
||||
// ─── Command dispatcher ─────────────────────────────────────────────
|
||||
|
||||
async function handleCommand(cmd: Command): Promise<Result> {
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case 'exec':
|
||||
return await handleExec(cmd);
|
||||
case 'navigate':
|
||||
return await handleNavigate(cmd);
|
||||
case 'tabs':
|
||||
return await handleTabs(cmd);
|
||||
case 'cookies':
|
||||
return await handleCookies(cmd);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Action handlers ─────────────────────────────────────────────────
|
||||
|
||||
/** Resolve target tab: use specified tabId or fall back to active web page tab */
|
||||
async function resolveTabId(tabId?: number): Promise<number> {
|
||||
if (tabId !== undefined) return tabId;
|
||||
|
||||
// Try the active tab first
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (activeTab?.id && activeTab.url && !activeTab.url.startsWith('chrome://') && !activeTab.url.startsWith('chrome-extension://')) {
|
||||
return activeTab.id;
|
||||
}
|
||||
|
||||
// Active tab is not debuggable (chrome:// or chrome-extension://),
|
||||
// try to find any open web page tab
|
||||
const allTabs = await chrome.tabs.query({ currentWindow: true });
|
||||
const webTab = allTabs.find(t => t.id && t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('chrome-extension://'));
|
||||
if (webTab?.id) {
|
||||
await chrome.tabs.update(webTab.id, { active: true });
|
||||
return webTab.id;
|
||||
}
|
||||
|
||||
// No web tabs at all — create one
|
||||
const newTab = await chrome.tabs.create({ url: 'about:blank', active: true });
|
||||
if (!newTab.id) throw new Error('Failed to create new tab');
|
||||
return newTab.id;
|
||||
}
|
||||
|
||||
async function handleExec(cmd: Command): Promise<Result> {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
try {
|
||||
const data = await cdp.evaluateAsync(tabId, cmd.code);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavigate(cmd: Command): Promise<Result> {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
await chrome.tabs.update(tabId, { url: cmd.url });
|
||||
|
||||
// Wait for page to finish loading
|
||||
await new Promise<void>((resolve) => {
|
||||
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
|
||||
if (id === tabId && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
|
||||
}
|
||||
|
||||
async function handleTabs(cmd: Command): Promise<Result> {
|
||||
switch (cmd.op) {
|
||||
case 'list': {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const data = tabs
|
||||
.filter((t) => t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('chrome-extension://'))
|
||||
.map((t, i) => ({
|
||||
index: i,
|
||||
tabId: t.id,
|
||||
url: t.url,
|
||||
title: t.title,
|
||||
active: t.active,
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case 'new': {
|
||||
const tab = await chrome.tabs.create({ url: cmd.url, active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case 'close': {
|
||||
if (cmd.index !== undefined) {
|
||||
// Close by index
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
cdp.detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
// Close by tabId or active tab
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
await chrome.tabs.remove(tabId);
|
||||
cdp.detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
}
|
||||
case 'select': {
|
||||
if (cmd.index === undefined && cmd.tabId === undefined)
|
||||
return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
|
||||
if (cmd.tabId !== undefined) {
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const target = tabs[cmd.index!];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: target.id } };
|
||||
}
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCookies(cmd: Command): Promise<Result> {
|
||||
const details: chrome.cookies.GetAllDetails = {};
|
||||
if (cmd.domain) details.domain = cmd.domain;
|
||||
if (cmd.url) details.url = cmd.url;
|
||||
const cookies = await chrome.cookies.getAll(details);
|
||||
const data = cookies.map((c) => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
expirationDate: c.expirationDate,
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* CDP execution via chrome.debugger API.
|
||||
*
|
||||
* chrome.debugger only needs the "debugger" permission — no host_permissions.
|
||||
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
|
||||
* tabs (resolveTabId in background.ts filters them).
|
||||
*/
|
||||
|
||||
const attached = new Set<number>();
|
||||
|
||||
async function ensureAttached(tabId: number): Promise<void> {
|
||||
if (attached.has(tabId)) return;
|
||||
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (!msg.includes('Another debugger is already attached')) {
|
||||
throw new Error(`attach failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
attached.add(tabId);
|
||||
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
|
||||
} catch {
|
||||
// Some pages may not need explicit enable
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
|
||||
await ensureAttached(tabId);
|
||||
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
}) as {
|
||||
result?: { type: string; value?: unknown; description?: string; subtype?: string };
|
||||
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
||||
};
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
const errMsg = result.exceptionDetails.exception?.description
|
||||
|| result.exceptionDetails.text
|
||||
|| 'Eval error';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
export const evaluateAsync = evaluate;
|
||||
|
||||
export function detach(tabId: number): void {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function registerListeners(): void {
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
attached.delete(tabId);
|
||||
});
|
||||
chrome.debugger.onDetach.addListener((source) => {
|
||||
if (source.tabId) attached.delete(source.tabId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* opencli browser protocol — shared types between daemon, extension, and CLI.
|
||||
*
|
||||
* Only 4 actions. Everything else is just JS code sent via 'exec'.
|
||||
*/
|
||||
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies';
|
||||
|
||||
export interface Command {
|
||||
/** Unique request ID */
|
||||
id: string;
|
||||
/** Action type */
|
||||
action: Action;
|
||||
/** Target tab ID (omit for active tab) */
|
||||
tabId?: number;
|
||||
/** JS code to evaluate in page context (exec action) */
|
||||
code?: string;
|
||||
/** URL to navigate to (navigate action) */
|
||||
url?: string;
|
||||
/** Sub-operation for tabs: list, new, close, select */
|
||||
op?: 'list' | 'new' | 'close' | 'select';
|
||||
/** Tab index for tabs select/close */
|
||||
index?: number;
|
||||
/** Cookie domain filter */
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
/** Matching request ID */
|
||||
id: string;
|
||||
/** Whether the command succeeded */
|
||||
ok: boolean;
|
||||
/** Result data on success */
|
||||
data?: unknown;
|
||||
/** Error message on failure */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Default daemon port */
|
||||
export const DAEMON_PORT = 19825;
|
||||
export const DAEMON_HOST = 'localhost';
|
||||
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
||||
export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
||||
|
||||
/** Reconnect delay for extension WebSocket (ms) */
|
||||
export const WS_RECONNECT_DELAY = 3000;
|
||||
/** Idle timeout before daemon auto-exits (ms) */
|
||||
export const DAEMON_IDLE_TIMEOUT = 5 * 60 * 1000;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 565 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"types": ["chrome"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'src/background.ts'),
|
||||
output: {
|
||||
entryFileNames: 'background.js',
|
||||
format: 'es',
|
||||
},
|
||||
},
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
},
|
||||
});
|
||||
Generated
+37
-66
@@ -13,15 +13,16 @@
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0"
|
||||
"js-yaml": "^4.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"bin": {
|
||||
"opencli": "dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/mcp": "^0.0.68",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.1.0"
|
||||
@@ -560,23 +561,6 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/mcp": {
|
||||
"version": "0.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz",
|
||||
"integrity": "sha512-oP9I9ghXKuQEBo4xaC7HgsS2gRTxyMzlBm3UEhYj4VqqrqbPQUX2shATPaNA/am9joBzq9v0OXISzeIgP+zmHA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.0-alpha-1771104257000",
|
||||
"playwright-core": "1.59.0-alpha-1771104257000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright-mcp": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||
@@ -899,6 +883,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
|
||||
@@ -1563,6 +1557,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1570,53 +1565,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.0-alpha-1771104257000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz",
|
||||
"integrity": "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.0-alpha-1771104257000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.0-alpha-1771104257000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-1771104257000.tgz",
|
||||
"integrity": "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -1805,6 +1753,7 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -1846,6 +1795,7 @@
|
||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/runtime": "0.115.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
@@ -2017,6 +1967,27 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -33,7 +33,7 @@
|
||||
"browser",
|
||||
"web",
|
||||
"ai",
|
||||
"playwright"
|
||||
"browser"
|
||||
],
|
||||
"author": "jackwener",
|
||||
"license": "Apache-2.0",
|
||||
@@ -45,11 +45,12 @@
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0"
|
||||
"js-yaml": "^4.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/mcp": "^0.0.68",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/node": "^22.13.10",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
|
||||
+3
-224
@@ -1,14 +1,6 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { PlaywrightMCP, __test__ } from './browser/index.js';
|
||||
|
||||
afterEach(() => {
|
||||
__test__.resetMcpServerPathCache();
|
||||
__test__.setMcpDiscoveryTestHooks();
|
||||
delete process.env.OPENCLI_MCP_SERVER_PATH;
|
||||
});
|
||||
|
||||
describe('browser helpers', () => {
|
||||
it('creates JSON-RPC requests with unique ids', () => {
|
||||
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
|
||||
@@ -57,220 +49,9 @@ describe('browser helpers', () => {
|
||||
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
||||
});
|
||||
|
||||
it('builds extension MCP args in local mode (no CI)', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
'--executable-path',
|
||||
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds standalone MCP args in CI mode', () => {
|
||||
const savedCI = process.env.CI;
|
||||
process.env.CI = 'true';
|
||||
try {
|
||||
// CI mode: no --extension — browser launches in standalone headed mode
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/usr/bin/chromium',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--executable-path',
|
||||
'/usr/bin/chromium',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds a direct node launch spec when a local MCP path is available', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpLaunchSpec({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/usr/bin/google-chrome',
|
||||
})).toEqual({
|
||||
command: 'node',
|
||||
args: ['/tmp/cli.js', '--extension', '--executable-path', '/usr/bin/google-chrome'],
|
||||
usedNpxFallback: false,
|
||||
});
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to npx bootstrap when no MCP path is available', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpLaunchSpec({
|
||||
mcpPath: null,
|
||||
})).toEqual({
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
usedNpxFallback: true,
|
||||
});
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('times out slow promises', async () => {
|
||||
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
||||
});
|
||||
|
||||
it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => {
|
||||
process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js';
|
||||
const existsSync = vi.fn((candidate: any) => candidate === '/env/mcp/cli.js');
|
||||
const execSync = vi.fn();
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js');
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js');
|
||||
});
|
||||
|
||||
it('discovers global @playwright/mcp from the current Node runtime prefix', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
||||
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: runtimeExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const existsSync = vi.fn((candidate: any) => candidate === runtimeGlobalMcp);
|
||||
const execSync = vi.fn();
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
try {
|
||||
expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp);
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to npm root -g when runtime prefix lookup misses', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
||||
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
|
||||
const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules';
|
||||
const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js';
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: runtimeExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const existsSync = vi.fn((candidate: any) => candidate === npmGlobalMcp);
|
||||
const execSync = vi.fn((command: string) => {
|
||||
if (String(command).includes('npm root -g')) return `${npmRootGlobal}\n` as any;
|
||||
throw new Error(`unexpected command: ${String(command)}`);
|
||||
});
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
try {
|
||||
expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp);
|
||||
expect(execSync).toHaveBeenCalledOnce();
|
||||
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
|
||||
expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when new global discovery paths are unavailable', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: runtimeExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const existsSync = vi.fn(() => false);
|
||||
const execSync = vi.fn((command: string) => {
|
||||
if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
|
||||
throw new Error(`missing command: ${String(command)}`);
|
||||
});
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
try {
|
||||
expect(__test__.findMcpServerPath()).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores non-server playwright cli paths discovered from fallback scans', () => {
|
||||
const wrongCli = '/root/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/lib/mcp/terminal/cli.js';
|
||||
const npxCacheBase = path.join(os.homedir(), '.npm', '_npx');
|
||||
|
||||
const existsSync = vi.fn((candidate: any) => {
|
||||
const value = String(candidate);
|
||||
return value === npxCacheBase || value === wrongCli;
|
||||
});
|
||||
|
||||
const execSync = vi.fn((command: string) => {
|
||||
if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
|
||||
if (String(command).includes('--package=@playwright/mcp which mcp-server-playwright')) return `${wrongCli}\n` as any;
|
||||
if (String(command).includes('which mcp-server-playwright')) return '' as any;
|
||||
if (String(command).includes(`find "${npxCacheBase}"`)) return `${wrongCli}\n` as any;
|
||||
throw new Error(`unexpected command: ${String(command)}`);
|
||||
});
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
expect(__test__.findMcpServerPath()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaywrightMCP state', () => {
|
||||
@@ -288,22 +69,20 @@ describe('PlaywrightMCP state', () => {
|
||||
const mcp = new PlaywrightMCP();
|
||||
await mcp.close();
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
|
||||
await expect(mcp.connect()).rejects.toThrow('Session is closed');
|
||||
});
|
||||
|
||||
it('rejects connect() while already connecting', async () => {
|
||||
const mcp = new PlaywrightMCP();
|
||||
(mcp as any)._state = 'connecting';
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
|
||||
await expect(mcp.connect()).rejects.toThrow('Already connecting');
|
||||
});
|
||||
|
||||
it('rejects connect() while closing', async () => {
|
||||
const mcp = new PlaywrightMCP();
|
||||
(mcp as any)._state = 'closing';
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
|
||||
await expect(mcp.connect()).rejects.toThrow('Session is closing');
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* HTTP client for communicating with the opencli daemon.
|
||||
*
|
||||
* Provides a typed send() function that posts a Command and returns a Result.
|
||||
*/
|
||||
|
||||
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
function generateId(): string {
|
||||
return `cmd_${Date.now()}_${++_idCounter}`;
|
||||
}
|
||||
|
||||
export interface DaemonCommand {
|
||||
id: string;
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies';
|
||||
tabId?: number;
|
||||
code?: string;
|
||||
url?: string;
|
||||
op?: string;
|
||||
index?: number;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export interface DaemonResult {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running.
|
||||
*/
|
||||
export async function isDaemonRunning(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running AND the extension is connected.
|
||||
*/
|
||||
export async function isExtensionConnected(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json() as { extensionConnected?: boolean };
|
||||
return !!data.extensionConnected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the daemon and wait for a result.
|
||||
*/
|
||||
export async function sendCommand(
|
||||
action: DaemonCommand['action'],
|
||||
params: Omit<DaemonCommand, 'id' | 'action'> = {},
|
||||
): Promise<unknown> {
|
||||
const id = generateId();
|
||||
const command: DaemonCommand = { id, action, ...params };
|
||||
|
||||
const res = await fetch(`${DAEMON_URL}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
const result = (await res.json()) as DaemonResult;
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? 'Daemon command failed');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
+19
-233
@@ -1,241 +1,27 @@
|
||||
/**
|
||||
* MCP server path discovery and argument building.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
let _cachedMcpServerPath: string | null | undefined;
|
||||
let _existsSync = fs.existsSync;
|
||||
let _execSync = execSync;
|
||||
|
||||
function isSupportedMcpEntrypoint(candidate: string): boolean {
|
||||
const normalized = candidate.replace(/\\/g, '/').toLowerCase();
|
||||
return normalized.endsWith('/@playwright/mcp/cli.js') ||
|
||||
normalized.endsWith('/mcp-server-playwright') ||
|
||||
normalized.endsWith('/mcp-server-playwright.js');
|
||||
}
|
||||
|
||||
function resolveSupportedMcpPath(candidate: string | null | undefined): string | null {
|
||||
const trimmed = candidate?.trim();
|
||||
if (!trimmed || !_existsSync(trimmed)) return null;
|
||||
return isSupportedMcpEntrypoint(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resetMcpServerPathCache(): void {
|
||||
_cachedMcpServerPath = undefined;
|
||||
}
|
||||
|
||||
export function setMcpDiscoveryTestHooks(input?: {
|
||||
existsSync?: typeof fs.existsSync;
|
||||
execSync?: typeof execSync;
|
||||
}): void {
|
||||
_existsSync = input?.existsSync ?? fs.existsSync;
|
||||
_execSync = input?.execSync ?? execSync;
|
||||
}
|
||||
|
||||
export function findMcpServerPath(): string | null {
|
||||
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
||||
|
||||
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
||||
if (envMcp && _existsSync(envMcp)) {
|
||||
_cachedMcpServerPath = envMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check local node_modules first (@playwright/mcp is the modern package)
|
||||
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
||||
if (_existsSync(localMcp)) {
|
||||
_cachedMcpServerPath = localMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check project-relative path
|
||||
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
||||
if (_existsSync(projectMcp)) {
|
||||
_cachedMcpServerPath = projectMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check global npm/yarn locations derived from current Node runtime.
|
||||
const nodePrefix = path.resolve(path.dirname(process.execPath), '..');
|
||||
const globalNodeModules = path.join(nodePrefix, 'lib', 'node_modules');
|
||||
const globalMcp = path.join(globalNodeModules, '@playwright', 'mcp', 'cli.js');
|
||||
if (_existsSync(globalMcp)) {
|
||||
_cachedMcpServerPath = globalMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check npm global root directly.
|
||||
try {
|
||||
const npmRootGlobal = _execSync('npm root -g 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
const npmGlobalMcp = path.join(npmRootGlobal, '@playwright', 'mcp', 'cli.js');
|
||||
if (npmRootGlobal && _existsSync(npmGlobalMcp)) {
|
||||
_cachedMcpServerPath = npmGlobalMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Check common locations
|
||||
const candidates = [
|
||||
path.join(os.homedir(), '.npm', '_npx'),
|
||||
path.join(os.homedir(), 'node_modules', '.bin'),
|
||||
'/usr/local/lib/node_modules',
|
||||
];
|
||||
|
||||
// Try npx resolution (legacy package name)
|
||||
try {
|
||||
const result = _execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
|
||||
const resolved = resolveSupportedMcpPath(result);
|
||||
if (resolved) {
|
||||
_cachedMcpServerPath = resolved;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Try which
|
||||
try {
|
||||
const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
const resolved = resolveSupportedMcpPath(result);
|
||||
if (resolved) {
|
||||
_cachedMcpServerPath = resolved;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Search in common npx cache
|
||||
for (const base of candidates) {
|
||||
if (!_existsSync(base)) continue;
|
||||
try {
|
||||
const found = _execSync(`find "${base}" -type f -path "*/@playwright/mcp/cli.js" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
const resolved = resolveSupportedMcpPath(found);
|
||||
if (resolved) {
|
||||
_cachedMcpServerPath = resolved;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_cachedMcpServerPath = null;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
|
||||
* Daemon discovery — simplified from MCP server path discovery.
|
||||
*
|
||||
* Starting with Chrome 144, users can enable remote debugging from
|
||||
* chrome://inspect#remote-debugging without any command-line flags.
|
||||
* Chrome writes the active port and browser GUID to a DevToolsActivePort file
|
||||
* in the user data directory, which we read to construct the WebSocket endpoint.
|
||||
* Only needs to check if the daemon is running. No more file system
|
||||
* scanning for @playwright/mcp locations.
|
||||
*/
|
||||
export function discoverChromeEndpoint(): string | null {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// User-specified Chrome data dir takes highest priority
|
||||
if (process.env.CHROME_USER_DATA_DIR) {
|
||||
candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
|
||||
}
|
||||
import { isDaemonRunning } from './daemon-client.js';
|
||||
|
||||
// Standard Chrome/Edge user data dirs per platform
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
||||
candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
|
||||
} else {
|
||||
candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
|
||||
}
|
||||
export { isDaemonRunning };
|
||||
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
||||
const lines = content.split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const port = parseInt(lines[0], 10);
|
||||
const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
|
||||
if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
|
||||
return `ws://127.0.0.1:${port}${browserPath}`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
/**
|
||||
* Check daemon status and return connection info.
|
||||
*/
|
||||
export async function checkDaemonStatus(): Promise<{
|
||||
running: boolean;
|
||||
extensionConnected: boolean;
|
||||
}> {
|
||||
try {
|
||||
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/status`);
|
||||
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
|
||||
return { running: true, extensionConnected: data.extensionConnected };
|
||||
} catch {
|
||||
return { running: false, extensionConnected: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveCdpEndpoint(): { endpoint?: string; requestedCdp: boolean } {
|
||||
const envVal = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (envVal === '1' || envVal?.toLowerCase() === 'true') {
|
||||
const autoDiscovered = discoverChromeEndpoint();
|
||||
return { endpoint: autoDiscovered ?? envVal, requestedCdp: true };
|
||||
}
|
||||
|
||||
if (envVal) {
|
||||
return { endpoint: envVal, requestedCdp: true };
|
||||
}
|
||||
|
||||
// Fallback to auto-discovery if not explicitly set
|
||||
const autoDiscovered = discoverChromeEndpoint();
|
||||
if (autoDiscovered) {
|
||||
return { endpoint: autoDiscovered, requestedCdp: true };
|
||||
}
|
||||
|
||||
return { requestedCdp: false };
|
||||
}
|
||||
|
||||
function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
|
||||
if (input?.cdpEndpoint) {
|
||||
args.push('--cdp-endpoint', input.cdpEndpoint);
|
||||
return args;
|
||||
}
|
||||
|
||||
// Priority 2: Extension mode (local Chrome with MCP Bridge extension)
|
||||
if (!process.env.CI) {
|
||||
args.push('--extension');
|
||||
}
|
||||
|
||||
// CI/standalone mode: @playwright/mcp launches its own browser (headed by default).
|
||||
// xvfb provides a virtual display for headed mode in GitHub Actions.
|
||||
if (input?.executablePath) {
|
||||
args.push('--executable-path', input.executablePath);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
||||
return [input.mcpPath, ...buildRuntimeArgs(input)];
|
||||
}
|
||||
|
||||
export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): {
|
||||
command: string;
|
||||
args: string[];
|
||||
usedNpxFallback: boolean;
|
||||
} {
|
||||
const runtimeArgs = buildRuntimeArgs(input);
|
||||
if (input.mcpPath) {
|
||||
return {
|
||||
command: 'node',
|
||||
args: [input.mcpPath, ...runtimeArgs],
|
||||
usedNpxFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', ...runtimeArgs],
|
||||
usedNpxFallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
+30
-100
@@ -1,105 +1,35 @@
|
||||
/**
|
||||
* Browser connection error classification and formatting.
|
||||
* Browser connection error helpers.
|
||||
*
|
||||
* Simplified — no more token/extension/CDP classification.
|
||||
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
|
||||
|
||||
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown';
|
||||
|
||||
export type ConnectFailureInput = {
|
||||
kind: ConnectFailureKind;
|
||||
timeout: number;
|
||||
hasExtensionToken: boolean;
|
||||
tokenFingerprint?: string | null;
|
||||
stderr?: string;
|
||||
exitCode?: number | null;
|
||||
rawMessage?: string;
|
||||
};
|
||||
|
||||
export function getTokenFingerprint(token: string | undefined): string | null {
|
||||
if (!token) return null;
|
||||
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
||||
const stderr = input.stderr?.trim();
|
||||
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
||||
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
||||
|
||||
if (input.kind === 'cdp-connection-failed') {
|
||||
return new Error(
|
||||
`Failed to connect to remote Chrome via CDP endpoint.\n\n` +
|
||||
`Check if Chrome is running with remote debugging enabled (--remote-debugging-port=9222) or DevToolsActivePort is available under chrome://inspect#remote-debugging.\n` +
|
||||
`If you specified OPENCLI_CDP_ENDPOINT=1, auto-discovery might have failed.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'missing-token') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
||||
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
|
||||
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-not-installed') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
|
||||
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
|
||||
'If Chrome shows an approval dialog, click Allow.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-timeout') {
|
||||
const likelyCause = input.hasExtensionToken
|
||||
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
|
||||
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
|
||||
return new Error(
|
||||
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
|
||||
`${likelyCause} If a browser prompt is visible, click Allow.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'mcp-init') {
|
||||
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
|
||||
}
|
||||
|
||||
if (input.kind === 'process-exit') {
|
||||
return new Error(
|
||||
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(input.rawMessage ?? 'Failed to connect to browser');
|
||||
}
|
||||
|
||||
export function inferConnectFailureKind(args: {
|
||||
hasExtensionToken: boolean;
|
||||
stderr: string;
|
||||
rawMessage?: string;
|
||||
exited?: boolean;
|
||||
isCdpMode?: boolean;
|
||||
}): ConnectFailureKind {
|
||||
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
||||
|
||||
if (args.isCdpMode) {
|
||||
if (args.rawMessage?.startsWith('MCP init failed:')) return 'mcp-init';
|
||||
if (args.exited) return 'cdp-connection-failed';
|
||||
return 'cdp-connection-failed';
|
||||
}
|
||||
|
||||
if (!args.hasExtensionToken)
|
||||
return 'missing-token';
|
||||
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
||||
return 'extension-not-installed';
|
||||
if (args.rawMessage?.startsWith('MCP init failed:'))
|
||||
return 'mcp-init';
|
||||
if (args.exited)
|
||||
return 'process-exit';
|
||||
return 'extension-timeout';
|
||||
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
|
||||
switch (kind) {
|
||||
case 'daemon-not-running':
|
||||
return new Error(
|
||||
'Cannot connect to opencli daemon.\n\n' +
|
||||
'The daemon should start automatically. If it doesn\'t, try:\n' +
|
||||
' node dist/daemon.js\n' +
|
||||
'Make sure port 19825 is available.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
);
|
||||
case 'extension-not-connected':
|
||||
return new Error(
|
||||
'opencli Browser Bridge extension is not connected.\n\n' +
|
||||
'Please install the extension:\n' +
|
||||
' 1. Download from GitHub Releases\n' +
|
||||
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
||||
' 3. Click "Load unpacked" → select the extension folder\n' +
|
||||
' 4. Make sure Chrome is running' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
);
|
||||
case 'command-failed':
|
||||
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
|
||||
default:
|
||||
return new Error(detail ?? 'Failed to connect to browser');
|
||||
}
|
||||
}
|
||||
|
||||
+7
-10
@@ -7,14 +7,16 @@
|
||||
|
||||
export { Page } from './page.js';
|
||||
export { PlaywrightMCP } from './mcp.js';
|
||||
export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
|
||||
export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
|
||||
export { resolveCdpEndpoint } from './discover.js';
|
||||
export { isDaemonRunning } from './daemon-client.js';
|
||||
|
||||
// Test-only helpers — exposed for unit tests
|
||||
// Backward compatibility: getTokenFingerprint is no longer needed but kept as no-op export
|
||||
export function getTokenFingerprint(_token: string | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Test-only helpers
|
||||
import { createJsonRpcRequest } from './mcp.js';
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
|
||||
import { withTimeoutMs } from '../runtime.js';
|
||||
|
||||
export const __test__ = {
|
||||
@@ -22,10 +24,5 @@ export const __test__ = {
|
||||
extractTabEntries,
|
||||
diffTabIndexes,
|
||||
appendLimited,
|
||||
buildMcpArgs,
|
||||
buildMcpLaunchSpec,
|
||||
findMcpServerPath,
|
||||
resetMcpServerPathCache,
|
||||
setMcpDiscoveryTestHooks,
|
||||
withTimeoutMs,
|
||||
};
|
||||
|
||||
+85
-279
@@ -1,312 +1,118 @@
|
||||
/**
|
||||
* Playwright MCP process manager.
|
||||
* Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
|
||||
* Browser session manager — auto-spawns daemon and provides IPage.
|
||||
*
|
||||
* Replaces the old PlaywrightMCP class. Still exports as PlaywrightMCP
|
||||
* for backward compatibility with main.ts and other consumers.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import type { IPage } from '../types.js';
|
||||
import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
|
||||
import { PKG_VERSION } from '../version.js';
|
||||
import { Page } from './page.js';
|
||||
import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
|
||||
import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
|
||||
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
|
||||
|
||||
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
||||
const INITIAL_TABS_TIMEOUT_MS = 1500;
|
||||
const TAB_CLEANUP_TIMEOUT_MS = 2000;
|
||||
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
||||
|
||||
export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
|
||||
|
||||
// JSON-RPC helpers
|
||||
let _nextId = 1;
|
||||
// Re-export for __test__ compatibility
|
||||
let _jsonRpcId = 0;
|
||||
export function createJsonRpcRequest(method: string, params: Record<string, unknown> = {}): { id: number; message: string } {
|
||||
const id = _nextId++;
|
||||
return {
|
||||
id,
|
||||
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
||||
};
|
||||
const id = ++_jsonRpcId;
|
||||
return { id, message: JSON.stringify({ id, method, params }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright MCP process manager.
|
||||
* Browser factory: manages daemon lifecycle and provides IPage instances.
|
||||
*
|
||||
* Kept as `PlaywrightMCP` class name for backward compatibility.
|
||||
*/
|
||||
export class PlaywrightMCP {
|
||||
private static _activeInsts: Set<PlaywrightMCP> = new Set();
|
||||
private static _cleanupRegistered = false;
|
||||
|
||||
private static _registerGlobalCleanup() {
|
||||
if (this._cleanupRegistered) return;
|
||||
this._cleanupRegistered = true;
|
||||
const cleanup = () => {
|
||||
for (const inst of this._activeInsts) {
|
||||
if (inst._proc && !inst._proc.killed) {
|
||||
try { inst._proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
||||
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
||||
}
|
||||
|
||||
private _proc: ChildProcess | null = null;
|
||||
private _buffer = '';
|
||||
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
|
||||
private _initialTabIdentities: string[] = [];
|
||||
private _closingPromise: Promise<void> | null = null;
|
||||
private _state: PlaywrightMCPState = 'idle';
|
||||
|
||||
private _page: Page | null = null;
|
||||
private _daemonProc: ChildProcess | null = null;
|
||||
|
||||
get state(): PlaywrightMCPState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
private _sendRequest(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
if (!this._proc?.stdin?.writable) {
|
||||
reject(new Error('Playwright MCP process is not writable'));
|
||||
return;
|
||||
}
|
||||
const { id, message } = createJsonRpcRequest(method, params);
|
||||
this._pending.set(id, { resolve, reject });
|
||||
this._proc.stdin.write(message, (err) => {
|
||||
if (!err) return;
|
||||
this._pending.delete(id);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
|
||||
if (this._state === 'connected' && this._page) return this._page;
|
||||
if (this._state === 'connecting') throw new Error('Already connecting');
|
||||
if (this._state === 'closing') throw new Error('Session is closing');
|
||||
if (this._state === 'closed') throw new Error('Session is closed');
|
||||
|
||||
private _rejectPendingRequests(error: Error): void {
|
||||
const pending = [...this._pending.values()];
|
||||
this._pending.clear();
|
||||
for (const waiter of pending) waiter.reject(error);
|
||||
}
|
||||
this._state = 'connecting';
|
||||
|
||||
private _resetAfterFailedConnect(): void {
|
||||
const proc = this._proc;
|
||||
this._page = null;
|
||||
this._proc = null;
|
||||
this._buffer = '';
|
||||
this._initialTabIdentities = [];
|
||||
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
|
||||
PlaywrightMCP._activeInsts.delete(this);
|
||||
if (proc && !proc.killed) {
|
||||
try { proc.kill('SIGKILL'); } catch {}
|
||||
try {
|
||||
await this._ensureDaemon();
|
||||
this._page = new Page();
|
||||
this._state = 'connected';
|
||||
return this._page;
|
||||
} catch (err) {
|
||||
this._state = 'idle';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
|
||||
if (this._state === 'connected' && this._page) return this._page;
|
||||
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
|
||||
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
|
||||
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
|
||||
|
||||
const mcpPath = findMcpServerPath();
|
||||
|
||||
PlaywrightMCP._registerGlobalCleanup();
|
||||
PlaywrightMCP._activeInsts.add(this);
|
||||
this._state = 'connecting';
|
||||
const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
|
||||
|
||||
return new Promise<Page>((resolve, reject) => {
|
||||
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
||||
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
||||
const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
|
||||
const useExtension = !requestedCdp;
|
||||
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
||||
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
||||
let stderrBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settleError = (kind: Parameters<typeof formatBrowserConnectError>[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._state = 'idle';
|
||||
clearTimeout(timer);
|
||||
this._resetAfterFailedConnect();
|
||||
reject(formatBrowserConnectError({
|
||||
kind,
|
||||
timeout,
|
||||
hasExtensionToken: !!extensionToken,
|
||||
tokenFingerprint,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: extra.exitCode,
|
||||
rawMessage: extra.rawMessage,
|
||||
}));
|
||||
};
|
||||
|
||||
const settleSuccess = (pageToResolve: Page) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._state = 'connected';
|
||||
clearTimeout(timer);
|
||||
resolve(pageToResolve);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
debugLog('Connection timed out');
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
isCdpMode: requestedCdp,
|
||||
}));
|
||||
}, timeout * 1000);
|
||||
|
||||
const launchSpec = buildMcpLaunchSpec({
|
||||
mcpPath,
|
||||
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
||||
cdpEndpoint,
|
||||
});
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
|
||||
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
||||
if (launchSpec.usedNpxFallback) {
|
||||
console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
|
||||
}
|
||||
}
|
||||
debugLog(`Spawning ${launchSpec.command} ${launchSpec.args.join(' ')}`);
|
||||
|
||||
this._proc = spawn(launchSpec.command, launchSpec.args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Increase max listeners to avoid warnings
|
||||
this._proc.setMaxListeners(20);
|
||||
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
|
||||
|
||||
const page = new Page((method, params = {}) => this._sendRequest(method, params));
|
||||
this._page = page;
|
||||
|
||||
this._proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
this._buffer += chunk.toString();
|
||||
const lines = this._buffer.split('\n');
|
||||
this._buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
debugLog(`RECV: ${line}`);
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (typeof parsed?.id === 'number') {
|
||||
const waiter = this._pending.get(parsed.id);
|
||||
if (waiter) {
|
||||
this._pending.delete(parsed.id);
|
||||
waiter.resolve(parsed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(`Parse error: ${e}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
|
||||
debugLog(`STDERR: ${text}`);
|
||||
});
|
||||
this._proc.on('error', (err) => {
|
||||
debugLog(`Subprocess error: ${err.message}`);
|
||||
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
|
||||
settleError('process-exit', { rawMessage: err.message });
|
||||
});
|
||||
this._proc.on('close', (code) => {
|
||||
debugLog(`Subprocess closed with code ${code}`);
|
||||
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
|
||||
if (!settled) {
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
exited: true,
|
||||
isCdpMode: requestedCdp,
|
||||
}), { exitCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize: send initialize request
|
||||
debugLog('Waiting for initialize response...');
|
||||
this._sendRequest('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'opencli', version: PKG_VERSION },
|
||||
}).then((resp: any) => {
|
||||
debugLog('Got initialize response');
|
||||
if (resp.error) {
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
rawMessage: `MCP init failed: ${resp.error.message}`,
|
||||
isCdpMode: requestedCdp,
|
||||
}), { rawMessage: resp.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
||||
debugLog(`SEND: ${initializedMsg.trim()}`);
|
||||
this._proc?.stdin?.write(initializedMsg);
|
||||
|
||||
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
|
||||
debugLog('Fetching initial tabs count...');
|
||||
withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
|
||||
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
||||
this._initialTabIdentities = extractTabIdentities(tabs);
|
||||
settleSuccess(page);
|
||||
}).catch((err: Error) => {
|
||||
debugLog(`Tabs fetch error: ${err.message}`);
|
||||
settleSuccess(page);
|
||||
});
|
||||
}).catch((err: Error) => {
|
||||
debugLog(`Init promise rejected: ${err.message}`);
|
||||
settleError('mcp-init', { rawMessage: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._closingPromise) return this._closingPromise;
|
||||
if (this._state === 'closed') return;
|
||||
this._state = 'closing';
|
||||
this._closingPromise = (async () => {
|
||||
try {
|
||||
// Extension mode opens bridge/session tabs that we can clean up best-effort.
|
||||
if (this._page && this._proc && !this._proc.killed) {
|
||||
try {
|
||||
const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
|
||||
const tabEntries = extractTabEntries(tabs);
|
||||
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
|
||||
for (const index of tabsToClose) {
|
||||
try { await this._page.closeTab(index); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (this._proc && !this._proc.killed) {
|
||||
this._proc.kill('SIGTERM');
|
||||
const exited = await new Promise<boolean>((res) => {
|
||||
let done = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
res(value);
|
||||
};
|
||||
this._proc?.once('exit', () => finish(true));
|
||||
setTimeout(() => finish(false), 3000);
|
||||
});
|
||||
if (!exited && this._proc && !this._proc.killed) {
|
||||
try { this._proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
|
||||
this._page = null;
|
||||
this._proc = null;
|
||||
this._state = 'closed';
|
||||
PlaywrightMCP._activeInsts.delete(this);
|
||||
}
|
||||
})();
|
||||
return this._closingPromise;
|
||||
// We don't kill the daemon — it auto-exits on idle.
|
||||
// Just clean up our reference.
|
||||
this._page = null;
|
||||
this._state = 'closed';
|
||||
}
|
||||
|
||||
private async _ensureDaemon(): Promise<void> {
|
||||
if (await isDaemonRunning()) return;
|
||||
|
||||
// Find daemon relative to this file — works for both:
|
||||
// npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
|
||||
// node dist/main.js → dist/browser/mcp.js → dist/daemon.js
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const parentDir = path.resolve(__dirname, '..');
|
||||
const daemonTs = path.join(parentDir, 'daemon.ts');
|
||||
const daemonJs = path.join(parentDir, 'daemon.js');
|
||||
const isTs = fs.existsSync(daemonTs);
|
||||
const daemonPath = isTs ? daemonTs : daemonJs;
|
||||
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
|
||||
}
|
||||
|
||||
// Use tsx for .ts files, node for .js files
|
||||
const runtime = isTs ? 'npx' : process.execPath;
|
||||
const args = isTs ? ['tsx', daemonPath] : [daemonPath];
|
||||
|
||||
this._daemonProc = spawn(runtime, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env },
|
||||
});
|
||||
this._daemonProc.unref();
|
||||
|
||||
// Wait for daemon to be ready AND extension to connect
|
||||
const deadline = Date.now() + DAEMON_SPAWN_TIMEOUT;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
|
||||
// Daemon might be up but extension not connected — give a useful error
|
||||
if (await isDaemonRunning()) {
|
||||
throw new Error(
|
||||
'Daemon is running but the Browser Extension is not connected.\n' +
|
||||
'Please install and enable the opencli Browser Bridge extension in Chrome.',
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Failed to start opencli daemon. Try running manually:\n' +
|
||||
` node ${daemonPath}\n` +
|
||||
'Make sure port 19825 is available.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+190
-87
@@ -1,139 +1,236 @@
|
||||
/**
|
||||
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
||||
* Page abstraction — implements IPage by sending commands to the daemon.
|
||||
*
|
||||
* All browser operations are ultimately 'exec' (JS evaluation via CDP)
|
||||
* plus a few native Chrome Extension APIs (tabs, cookies, navigate).
|
||||
*
|
||||
* IMPORTANT: After goto(), we remember the tabId returned by the navigate
|
||||
* action and pass it to all subsequent commands. This avoids the issue
|
||||
* where resolveTabId() in the extension picks a chrome:// or
|
||||
* chrome-extension:// tab that can't be debugged.
|
||||
*/
|
||||
|
||||
import { formatSnapshot } from '../snapshotFormatter.js';
|
||||
import { normalizeEvaluateSource } from '../pipeline/template.js';
|
||||
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
|
||||
import type { IPage } from '../types.js';
|
||||
import { BrowserConnectError } from '../errors.js';
|
||||
import { sendCommand } from './daemon-client.js';
|
||||
|
||||
/**
|
||||
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
||||
* Page — implements IPage by talking to the daemon via HTTP.
|
||||
*/
|
||||
export class Page implements IPage {
|
||||
constructor(private _request: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>) {}
|
||||
|
||||
async call(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||
const resp = await this._request(method, params);
|
||||
if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
|
||||
// Extract text content from MCP result
|
||||
const result = resp.result as any;
|
||||
|
||||
if (result?.isError) {
|
||||
const errorText = result.content?.find((c: any) => c.type === 'text')?.text || 'Unknown MCP Error';
|
||||
throw new BrowserConnectError(
|
||||
errorText,
|
||||
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.content) {
|
||||
const textParts = result.content.filter((c: any) => c.type === 'text');
|
||||
if (textParts.length >= 1) {
|
||||
let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
|
||||
|
||||
// Some versions of the MCP return error text without the `isError` boolean flag
|
||||
if (typeof text === 'string' && text.trim().startsWith('### Error')) {
|
||||
throw new BrowserConnectError(
|
||||
text.trim(),
|
||||
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
||||
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
||||
const codeMarker = text.indexOf('### Ran Playwright code');
|
||||
if (codeMarker !== -1) {
|
||||
text = text.slice(0, codeMarker).trim();
|
||||
}
|
||||
// Also handle "### Result\n[JSON]" format (some MCP versions)
|
||||
const resultMarker = text.indexOf('### Result\n');
|
||||
if (resultMarker !== -1) {
|
||||
text = text.slice(resultMarker + '### Result\n'.length).trim();
|
||||
}
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- High-level methods ---
|
||||
/** Active tab ID, set after navigate and used in all subsequent commands */
|
||||
private _tabId: number | undefined;
|
||||
|
||||
async goto(url: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
|
||||
const result = await sendCommand('navigate', {
|
||||
url,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
}) as { tabId?: number };
|
||||
// Remember the tabId for subsequent exec calls
|
||||
if (result?.tabId) {
|
||||
this._tabId = result.tabId;
|
||||
}
|
||||
}
|
||||
|
||||
async evaluate(js: string): Promise<any> {
|
||||
// Normalize IIFE format to function format expected by MCP browser_evaluate
|
||||
const normalized = normalizeEvaluateSource(js);
|
||||
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
|
||||
// Wrap function-style code: `() => { ... }` or `async () => { ... }` → IIFE
|
||||
const trimmed = normalized.trim();
|
||||
const code = trimmed.startsWith('async')
|
||||
? `(${trimmed})()`
|
||||
: trimmed.startsWith('function') || trimmed.startsWith('(')
|
||||
? `(${trimmed})()`
|
||||
: trimmed;
|
||||
return sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
|
||||
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
|
||||
// Use CDP Accessibility.getFullAXTree via exec
|
||||
const code = `
|
||||
(async () => {
|
||||
// Build a simplified accessibility tree from the DOM
|
||||
function buildTree(node, depth = 0) {
|
||||
if (depth > ${opts.maxDepth ?? 50}) return '';
|
||||
const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
|
||||
const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
|
||||
const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
|
||||
|
||||
${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
|
||||
|
||||
let indent = ' '.repeat(depth);
|
||||
let line = indent + role;
|
||||
if (name) line += ' "' + name.replace(/"/g, '\\"') + '"';
|
||||
if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
|
||||
if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
|
||||
|
||||
let result = line + '\\n';
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
result += buildTree(child, depth + 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return buildTree(document.body);
|
||||
})()
|
||||
`;
|
||||
const raw = await sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
if (opts.raw) return raw;
|
||||
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
|
||||
const safeRef = JSON.stringify(ref);
|
||||
const code = `
|
||||
(() => {
|
||||
const ref = ${safeRef};
|
||||
const el = document.querySelector('[data-ref="' + ref + '"]')
|
||||
|| document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
|
||||
if (!el) throw new Error('Element not found: ' + ref);
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
el.click();
|
||||
return 'clicked';
|
||||
})()
|
||||
`;
|
||||
await sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
|
||||
const safeRef = JSON.stringify(ref);
|
||||
const safeText = JSON.stringify(text);
|
||||
const code = `
|
||||
(() => {
|
||||
const ref = ${safeRef};
|
||||
const el = document.querySelector('[data-ref="' + ref + '"]')
|
||||
|| document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
|
||||
if (!el) throw new Error('Element not found: ' + ref);
|
||||
el.focus();
|
||||
el.value = ${safeText};
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return 'typed';
|
||||
})()
|
||||
`;
|
||||
await sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
|
||||
const code = `
|
||||
(() => {
|
||||
const el = document.activeElement || document.body;
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
|
||||
return 'pressed';
|
||||
})()
|
||||
`;
|
||||
await sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
|
||||
if (typeof options === 'number') {
|
||||
await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
|
||||
} else {
|
||||
// Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
|
||||
await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
|
||||
await new Promise(resolve => setTimeout(resolve, options * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.time) {
|
||||
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
const timeout = (options.timeout ?? 30) * 1000;
|
||||
const code = `
|
||||
new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + ${timeout};
|
||||
const check = () => {
|
||||
if (document.body.innerText.includes(${JSON.stringify(options.text)})) return resolve('found');
|
||||
if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(options.text)}));
|
||||
setTimeout(check, 200);
|
||||
};
|
||||
check();
|
||||
})
|
||||
`;
|
||||
await sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async tabs(): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
|
||||
return sendCommand('tabs', { op: 'list' });
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
|
||||
await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
|
||||
await sendCommand('tabs', { op: 'new' });
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
|
||||
await sendCommand('tabs', { op: 'select', index });
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
|
||||
// Use performance API to get network entries
|
||||
const code = `
|
||||
(() => {
|
||||
const entries = performance.getEntriesByType('resource');
|
||||
return entries
|
||||
${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
|
||||
.map(e => ({
|
||||
url: e.name,
|
||||
type: e.initiatorType,
|
||||
duration: Math.round(e.duration),
|
||||
size: e.transferSize || 0,
|
||||
}));
|
||||
})()
|
||||
`;
|
||||
return sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async consoleMessages(level: string = 'info'): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
|
||||
// Console messages can't be retrospectively read via exec.
|
||||
// Return empty for now — users should use networkRequests or evaluate.
|
||||
return [];
|
||||
}
|
||||
|
||||
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
|
||||
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
||||
const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
|
||||
const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
|
||||
await sendCommand('exec', {
|
||||
code: `window.scrollBy(${dx}, ${dy})`,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
|
||||
const times = options.times ?? 3;
|
||||
const delayMs = options.delayMs ?? 2000;
|
||||
const js = `
|
||||
async () => {
|
||||
const maxTimes = ${times};
|
||||
const maxWaitMs = ${delayMs};
|
||||
for (let i = 0; i < maxTimes; i++) {
|
||||
const code = `
|
||||
(async () => {
|
||||
for (let i = 0; i < ${times}; i++) {
|
||||
const lastHeight = document.body.scrollHeight;
|
||||
window.scrollTo(0, lastHeight);
|
||||
await new Promise(resolve => {
|
||||
@@ -142,30 +239,36 @@ export class Page implements IPage {
|
||||
if (document.body.scrollHeight > lastHeight) {
|
||||
clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
setTimeout(resolve, 100); // Small debounce for rendering
|
||||
setTimeout(resolve, 100);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
timeoutId = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(null);
|
||||
}, maxWaitMs);
|
||||
timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
|
||||
});
|
||||
}
|
||||
}
|
||||
})()
|
||||
`;
|
||||
await this.evaluate(js);
|
||||
await sendCommand('exec', {
|
||||
code,
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
||||
arrayName: '__opencli_xhr',
|
||||
patchGuard: '__opencli_interceptor_patched',
|
||||
}));
|
||||
await sendCommand('exec', {
|
||||
code: generateInterceptorJs(JSON.stringify(pattern), {
|
||||
arrayName: '__opencli_xhr',
|
||||
patchGuard: '__opencli_interceptor_patched',
|
||||
}),
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async getInterceptedRequests(): Promise<any[]> {
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return result || [];
|
||||
const result = await sendCommand('exec', {
|
||||
code: generateReadInterceptedJs('__opencli_xhr'),
|
||||
...(this._tabId !== undefined ? { tabId: this._tabId } : {}),
|
||||
});
|
||||
return (result as any[]) || [];
|
||||
}
|
||||
}
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension.
|
||||
*
|
||||
* Architecture:
|
||||
* CLI → HTTP POST /command → daemon → WebSocket → Extension
|
||||
* Extension → WebSocket result → daemon → HTTP response → CLI
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Auto-spawned by opencli on first browser command
|
||||
* - Auto-exits after 5 minutes of idle
|
||||
* - Listens on localhost:19825
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
|
||||
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
let extensionWs: WebSocket | null = null;
|
||||
const pending = new Map<string, {
|
||||
resolve: (data: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>();
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── Idle auto-exit ──────────────────────────────────────────────────
|
||||
|
||||
function resetIdleTimer(): void {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
console.error('[daemon] Idle timeout, shutting down');
|
||||
process.exit(0);
|
||||
}, IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
// ─── HTTP Server ─────────────────────────────────────────────────────
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
const url = req.url ?? '/';
|
||||
|
||||
if (req.method === 'GET' && url === '/status') {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
|
||||
pending: pending.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url === '/command') {
|
||||
resetIdleTimer();
|
||||
try {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
|
||||
if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
|
||||
jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(body.id);
|
||||
reject(new Error('Command timeout (30s)'));
|
||||
}, 30000);
|
||||
pending.set(body.id, { resolve, reject, timer });
|
||||
extensionWs!.send(JSON.stringify(body));
|
||||
});
|
||||
|
||||
jsonResponse(res, 200, result);
|
||||
} catch (err) {
|
||||
jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'Invalid request',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
// ─── WebSocket for Extension ─────────────────────────────────────────
|
||||
|
||||
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.error('[daemon] Extension connected');
|
||||
extensionWs = ws;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const result = JSON.parse(data.toString());
|
||||
const p = pending.get(result.id);
|
||||
if (p) {
|
||||
clearTimeout(p.timer);
|
||||
pending.delete(result.id);
|
||||
p.resolve(result);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.error('[daemon] Extension disconnected');
|
||||
if (extensionWs === ws) {
|
||||
extensionWs = null;
|
||||
// Reject all pending requests since the extension is gone
|
||||
for (const [id, p] of pending) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error('Extension disconnected'));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
if (extensionWs === ws) extensionWs = null;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Start ───────────────────────────────────────────────────────────
|
||||
|
||||
httpServer.listen(PORT, '127.0.0.1', () => {
|
||||
console.error(`[daemon] Listening on http://127.0.0.1:${PORT}`);
|
||||
resetIdleTimer();
|
||||
});
|
||||
|
||||
httpServer.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('[daemon] Server error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
process.on('SIGINT', () => process.exit(0));
|
||||
+32
-193
@@ -1,223 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
readTokenFromShellContent,
|
||||
renderBrowserDoctorReport,
|
||||
upsertShellToken,
|
||||
readTomlConfigToken,
|
||||
upsertTomlConfigToken,
|
||||
upsertJsonConfigToken,
|
||||
} from './doctor.js';
|
||||
|
||||
describe('shell token helpers', () => {
|
||||
it('reads token from shell export', () => {
|
||||
expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('appends token export when missing', () => {
|
||||
const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
|
||||
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
||||
});
|
||||
|
||||
it('replaces token export when present', () => {
|
||||
const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
|
||||
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
|
||||
expect(next).not.toContain('"old"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toml token helpers', () => {
|
||||
it('reads token from playwright env section', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright.env]
|
||||
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
|
||||
`;
|
||||
expect(readTomlConfigToken(content)).toBe('abc123');
|
||||
});
|
||||
|
||||
it('updates token inside existing env section', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright.env]
|
||||
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
|
||||
`;
|
||||
const next = upsertTomlConfigToken(content, 'new');
|
||||
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
|
||||
expect(next).not.toContain('"old"');
|
||||
});
|
||||
|
||||
it('creates env section when missing', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright]
|
||||
type = "stdio"
|
||||
`;
|
||||
const next = upsertTomlConfigToken(content, 'abc123');
|
||||
expect(next).toContain('[mcp_servers.playwright.env]');
|
||||
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('json token helpers', () => {
|
||||
it('writes token into standard mcpServers config', () => {
|
||||
const next = upsertJsonConfigToken(JSON.stringify({
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
},
|
||||
},
|
||||
}), 'abc123');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
});
|
||||
|
||||
it('writes token into opencode mcp config', () => {
|
||||
const next = upsertJsonConfigToken(JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
mcp: {
|
||||
playwright: {
|
||||
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
||||
enabled: true,
|
||||
type: 'local',
|
||||
},
|
||||
},
|
||||
}), 'abc123');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
});
|
||||
|
||||
it('creates standard mcpServers format for empty file (not OpenCode)', () => {
|
||||
const next = upsertJsonConfigToken('', 'abc123');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
expect(parsed.mcp).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates OpenCode format when filePath contains opencode', () => {
|
||||
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
expect(parsed.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates standard format when filePath is claude.json', () => {
|
||||
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fish shell support', () => {
|
||||
it('generates fish set -gx syntax for fish config path', () => {
|
||||
const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
|
||||
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
||||
expect(next).not.toContain('export');
|
||||
});
|
||||
|
||||
it('replaces existing fish set line', () => {
|
||||
const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
|
||||
const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
|
||||
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
|
||||
expect(next).not.toContain('"old"');
|
||||
});
|
||||
|
||||
it('appends fish syntax to existing fish config', () => {
|
||||
const content = 'set -gx PATH /usr/bin\n';
|
||||
const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
|
||||
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
||||
expect(next).toContain('set -gx PATH /usr/bin');
|
||||
});
|
||||
|
||||
it('uses export syntax for zshrc even with filePath', () => {
|
||||
const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
|
||||
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
||||
expect(next).not.toContain('set -gx');
|
||||
});
|
||||
});
|
||||
import { renderBrowserDoctorReport } from './doctor.js';
|
||||
|
||||
describe('doctor report rendering', () => {
|
||||
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
|
||||
it('renders OK-style report when tokens match', () => {
|
||||
it('renders OK-style report when daemon and extension connected', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: 'abc123',
|
||||
extensionFingerprint: 'fp1',
|
||||
extensionInstalled: true,
|
||||
extensionBrowsers: ['Chrome'],
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
warnings: [],
|
||||
daemonRunning: true,
|
||||
extensionConnected: true,
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[OK] Extension installed (Chrome)');
|
||||
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
||||
expect(text).toContain('[OK] /tmp/mcp.json');
|
||||
expect(text).toContain('configured (fp1)');
|
||||
expect(text).toContain('[OK] Daemon: running on port 19825');
|
||||
expect(text).toContain('[OK] Extension: connected');
|
||||
expect(text).toContain('Everything looks good!');
|
||||
});
|
||||
|
||||
it('renders MISMATCH-style report when fingerprints differ', () => {
|
||||
it('renders MISSING when daemon not running', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: null,
|
||||
extensionFingerprint: null,
|
||||
extensionInstalled: false,
|
||||
extensionBrowsers: [],
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
warnings: [],
|
||||
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
||||
daemonRunning: false,
|
||||
extensionConnected: false,
|
||||
issues: ['Daemon is not running.'],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
||||
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
||||
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
||||
expect(text).toContain('configured (fp2)');
|
||||
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
||||
expect(text).toContain('[MISSING] Daemon: not running');
|
||||
expect(text).toContain('[MISSING] Extension: not connected');
|
||||
expect(text).toContain('Daemon is not running.');
|
||||
});
|
||||
|
||||
it('renders extension not connected when daemon is running', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
daemonRunning: true,
|
||||
extensionConnected: false,
|
||||
issues: ['Daemon is running but the Chrome extension is not connected.'],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[OK] Daemon: running on port 19825');
|
||||
expect(text).toContain('[MISSING] Extension: not connected');
|
||||
});
|
||||
|
||||
it('renders connectivity OK when live test succeeds', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: 'abc123',
|
||||
extensionFingerprint: 'fp1',
|
||||
extensionInstalled: true,
|
||||
extensionBrowsers: ['Chrome'],
|
||||
shellFiles: [],
|
||||
configs: [],
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
daemonRunning: true,
|
||||
extensionConnected: true,
|
||||
connectivity: { ok: true, durationMs: 1234 },
|
||||
warnings: [],
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
||||
expect(text).toContain('[OK] Connectivity: connected in 1.2s');
|
||||
});
|
||||
|
||||
it('renders connectivity WARN when not tested', () => {
|
||||
it('renders connectivity SKIP when not tested', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: 'abc123',
|
||||
extensionFingerprint: 'fp1',
|
||||
extensionInstalled: true,
|
||||
extensionBrowsers: ['Chrome'],
|
||||
shellFiles: [],
|
||||
configs: [],
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
warnings: [],
|
||||
daemonRunning: true,
|
||||
extensionConnected: true,
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
||||
expect(text).toContain('[SKIP] Connectivity: not tested (use --live)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+74
-668
@@ -1,47 +1,22 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
/**
|
||||
* opencli doctor — diagnose and fix browser connectivity.
|
||||
*
|
||||
* Simplified for the daemon-based architecture. No more token management,
|
||||
* MCP path discovery, or config file scanning.
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
import chalk from 'chalk';
|
||||
import type { IPage } from './types.js';
|
||||
import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
|
||||
import { checkDaemonStatus } from './browser/discover.js';
|
||||
import { PlaywrightMCP } from './browser/index.js';
|
||||
import { browserSession } from './runtime.js';
|
||||
|
||||
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
||||
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
||||
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
||||
const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
|
||||
export type DoctorOptions = {
|
||||
fix?: boolean;
|
||||
yes?: boolean;
|
||||
live?: boolean;
|
||||
shellRc?: string;
|
||||
configPaths?: string[];
|
||||
token?: string;
|
||||
cliVersion?: string;
|
||||
};
|
||||
|
||||
export type ShellFileStatus = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
token: string | null;
|
||||
fingerprint: string | null;
|
||||
};
|
||||
|
||||
export type McpConfigFormat = 'json' | 'toml';
|
||||
|
||||
export type McpConfigStatus = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
format: McpConfigFormat;
|
||||
token: string | null;
|
||||
fingerprint: string | null;
|
||||
writable: boolean;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
export type ConnectivityResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -50,463 +25,22 @@ export type ConnectivityResult = {
|
||||
|
||||
export type DoctorReport = {
|
||||
cliVersion?: string;
|
||||
envToken: string | null;
|
||||
envFingerprint: string | null;
|
||||
extensionToken: string | null;
|
||||
extensionFingerprint: string | null;
|
||||
extensionInstalled: boolean;
|
||||
extensionBrowsers: string[];
|
||||
shellFiles: ShellFileStatus[];
|
||||
configs: McpConfigStatus[];
|
||||
recommendedToken: string | null;
|
||||
recommendedFingerprint: string | null;
|
||||
daemonRunning: boolean;
|
||||
extensionConnected: boolean;
|
||||
connectivity?: ConnectivityResult;
|
||||
warnings: string[];
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
|
||||
|
||||
function colorLabel(status: ReportStatus): string {
|
||||
switch (status) {
|
||||
case 'OK': return chalk.green('[OK]');
|
||||
case 'MISSING': return chalk.red('[MISSING]');
|
||||
case 'MISMATCH': return chalk.yellow('[MISMATCH]');
|
||||
case 'WARN': return chalk.yellow('[WARN]');
|
||||
}
|
||||
}
|
||||
|
||||
function statusLine(status: ReportStatus, text: string): string {
|
||||
return `${colorLabel(status)} ${text}`;
|
||||
}
|
||||
|
||||
function tokenSummary(token: string | null, fingerprint: string | null): string {
|
||||
if (!token) return chalk.dim('missing');
|
||||
return `configured ${chalk.dim(`(${fingerprint})`)}`;
|
||||
}
|
||||
|
||||
export function shortenPath(p: string): string {
|
||||
const home = os.homedir();
|
||||
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
||||
}
|
||||
|
||||
export function toolName(p: string): string {
|
||||
if (p.includes('.codex/')) return 'Codex';
|
||||
if (p.includes('.cursor/')) return 'Cursor';
|
||||
if (p.includes('.claude.json')) return 'Claude Code';
|
||||
if (p.includes('antigravity')) return 'Antigravity';
|
||||
if (p.includes('.gemini/settings')) return 'Gemini CLI';
|
||||
if (p.includes('opencode')) return 'OpenCode';
|
||||
if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
|
||||
if (p.includes('.vscode/')) return 'VS Code';
|
||||
if (p.includes('.mcp.json')) return 'Project MCP';
|
||||
if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getDefaultShellRcPath(): string {
|
||||
const shell = process.env.SHELL ?? '';
|
||||
if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc');
|
||||
if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish');
|
||||
return path.join(os.homedir(), '.zshrc');
|
||||
}
|
||||
|
||||
function isFishConfig(filePath: string): boolean {
|
||||
return filePath.endsWith('config.fish') || filePath.includes('/fish/');
|
||||
}
|
||||
|
||||
/** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
|
||||
function isOpenCodeConfig(filePath: string): boolean {
|
||||
return filePath.includes('opencode');
|
||||
}
|
||||
|
||||
export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
path.join(home, '.codex', 'config.toml'),
|
||||
path.join(home, '.codex', 'mcp.json'),
|
||||
path.join(home, '.cursor', 'mcp.json'),
|
||||
path.join(home, '.claude.json'),
|
||||
path.join(home, '.gemini', 'settings.json'),
|
||||
path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
|
||||
path.join(home, '.config', 'opencode', 'opencode.json'),
|
||||
path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
||||
path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
|
||||
path.join(cwd, '.cursor', 'mcp.json'),
|
||||
path.join(cwd, '.vscode', 'mcp.json'),
|
||||
path.join(cwd, '.opencode', 'opencode.json'),
|
||||
path.join(cwd, '.mcp.json'),
|
||||
];
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
export function readTokenFromShellContent(content: string): string | null {
|
||||
const m = content.match(TOKEN_LINE_RE);
|
||||
return m?.[3] ?? null;
|
||||
}
|
||||
|
||||
export function upsertShellToken(content: string, token: string, filePath?: string): string {
|
||||
if (filePath && isFishConfig(filePath)) {
|
||||
// Fish shell uses `set -gx` instead of `export`
|
||||
const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
|
||||
const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
|
||||
if (!content.trim()) return `${fishLine}\n`;
|
||||
if (fishRe.test(content)) return content.replace(fishRe, fishLine);
|
||||
return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
|
||||
}
|
||||
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
||||
if (!content.trim()) return `${nextLine}\n`;
|
||||
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
|
||||
token
|
||||
}"`);
|
||||
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
|
||||
}
|
||||
|
||||
function readJsonConfigToken(content: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return readTokenFromJsonObject(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readTokenFromJsonObject(parsed: any): string | null {
|
||||
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
||||
if (typeof direct === 'string' && direct) return direct;
|
||||
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
||||
if (typeof opencode === 'string' && opencode) return opencode;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
|
||||
const parsed = content.trim() ? JSON.parse(content) : {};
|
||||
|
||||
// Determine format: use OpenCode format only if explicitly an opencode config,
|
||||
// or if the existing content already uses `mcp` key (not `mcpServers`)
|
||||
const useOpenCodeFormat = filePath
|
||||
? isOpenCodeConfig(filePath)
|
||||
: (!parsed.mcpServers && parsed.mcp);
|
||||
|
||||
if (useOpenCodeFormat) {
|
||||
parsed.mcp = parsed.mcp ?? {};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
||||
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
||||
enabled: true,
|
||||
type: 'local',
|
||||
};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
} else {
|
||||
parsed.mcpServers = parsed.mcpServers ?? {};
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
};
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
}
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function readTomlConfigToken(content: string): string | null {
|
||||
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
|
||||
if (!sectionMatch) return null;
|
||||
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
|
||||
return tokenMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function upsertTomlConfigToken(content: string, token: string): string {
|
||||
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
|
||||
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
|
||||
if (envSectionRe.test(content)) {
|
||||
return content.replace(envSectionRe, (section) => {
|
||||
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
|
||||
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
|
||||
}
|
||||
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
|
||||
if (baseSectionRe.test(content)) {
|
||||
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
|
||||
}
|
||||
|
||||
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
|
||||
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
|
||||
}
|
||||
|
||||
export function fileExists(filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canWrite(filePath: string): boolean {
|
||||
try {
|
||||
if (fileExists(filePath)) {
|
||||
fs.accessSync(filePath, fs.constants.W_OK);
|
||||
return true;
|
||||
}
|
||||
fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigStatus(filePath: string): McpConfigStatus {
|
||||
const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
|
||||
if (!fileExists(filePath)) {
|
||||
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
format,
|
||||
token,
|
||||
fingerprint: getTokenFingerprint(token ?? undefined),
|
||||
writable: canWrite(filePath),
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
format,
|
||||
token: null,
|
||||
fingerprint: null,
|
||||
writable: canWrite(filePath),
|
||||
parseError: error?.message ?? String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
|
||||
* directories across all browser base paths. Falls back to ['Default'] if none found.
|
||||
* Test connectivity by attempting a real browser command.
|
||||
*/
|
||||
function enumerateProfiles(baseDirs: string[]): string[] {
|
||||
const profiles = new Set<string>();
|
||||
for (const base of baseDirs) {
|
||||
if (!fileExists(base)) continue;
|
||||
try {
|
||||
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
|
||||
profiles.add(entry.name);
|
||||
}
|
||||
}
|
||||
} catch { /* permission denied, etc. */ }
|
||||
}
|
||||
return profiles.size > 0 ? [...profiles].sort() : ['Default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the auth token stored by the Playwright MCP Bridge extension
|
||||
* by scanning Chrome's LevelDB localStorage files directly.
|
||||
*
|
||||
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
||||
* extension ID near base64url token values. This works reliably across
|
||||
* platforms because LevelDB's internal encoding can split ASCII strings
|
||||
* like "auth-token" and the extension ID across byte boundaries, making
|
||||
* text-based tools like `strings` + `grep` unreliable.
|
||||
*/
|
||||
export function discoverExtensionToken(): string | null {
|
||||
const home = os.homedir();
|
||||
const platform = os.platform();
|
||||
const bases: string[] = [];
|
||||
|
||||
if (platform === 'darwin') {
|
||||
bases.push(
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
|
||||
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
||||
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
bases.push(
|
||||
path.join(home, '.config', 'google-chrome'),
|
||||
path.join(home, '.config', 'google-chrome-unstable'),
|
||||
path.join(home, '.config', 'google-chrome-beta'),
|
||||
path.join(home, '.config', 'chromium'),
|
||||
path.join(home, '.config', 'microsoft-edge'),
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
bases.push(
|
||||
path.join(appData, 'Google', 'Chrome', 'User Data'),
|
||||
path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
|
||||
path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
|
||||
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = enumerateProfiles(bases);
|
||||
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
||||
|
||||
for (const base of bases) {
|
||||
for (const profile of profiles) {
|
||||
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
||||
if (!fileExists(dir)) continue;
|
||||
|
||||
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
||||
if (token) return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
||||
// LevelDB fragments strings across byte boundaries, so we can't search
|
||||
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
||||
// search for a short prefix of the extension ID that reliably appears as
|
||||
// contiguous bytes, then scan a window around each match for a base64url
|
||||
// token value.
|
||||
//
|
||||
// Observed LevelDB layout near the auth-token entry:
|
||||
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
||||
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
||||
//
|
||||
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
||||
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
||||
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
|
||||
.map(f => path.join(dir, f));
|
||||
} catch { return null; }
|
||||
|
||||
// Sort by mtime descending so we find the freshest token first
|
||||
files.sort((a, b) => {
|
||||
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
let data: Buffer;
|
||||
try { data = fs.readFileSync(file); } catch { continue; }
|
||||
|
||||
// Quick check: file must contain at least the prefix
|
||||
if (data.indexOf(extIdPrefix) === -1) continue;
|
||||
|
||||
// Strategy 1: scan after each occurrence of the extension ID prefix
|
||||
// for base64url tokens within a 500-byte window
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
const pos = data.indexOf(extIdPrefix, idx);
|
||||
if (pos === -1) break;
|
||||
|
||||
const scanStart = pos;
|
||||
const scanEnd = Math.min(data.length, pos + 500);
|
||||
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
||||
const m = window.match(tokenRe);
|
||||
if (m && validateBase64urlToken(m[1])) {
|
||||
// Make sure this isn't another extension ID that happens to match
|
||||
if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
|
||||
}
|
||||
idx = pos + 1;
|
||||
}
|
||||
|
||||
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
||||
const keyBuf = Buffer.from('auth-token');
|
||||
idx = 0;
|
||||
while (true) {
|
||||
const kp = data.indexOf(keyBuf, idx);
|
||||
if (kp === -1) break;
|
||||
|
||||
const contextStart = Math.max(0, kp - 500);
|
||||
if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
|
||||
const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
|
||||
const m = after.match(tokenRe);
|
||||
if (m && validateBase64urlToken(m[1])) return m[1];
|
||||
}
|
||||
idx = kp + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateBase64urlToken(token: string): boolean {
|
||||
try {
|
||||
const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decoded = Buffer.from(b64, 'base64');
|
||||
return decoded.length >= 28 && decoded.length <= 36;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
||||
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
||||
*/
|
||||
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
|
||||
const home = os.homedir();
|
||||
const platform = os.platform();
|
||||
const browserDirs: Array<{ name: string; base: string }> = [];
|
||||
|
||||
if (platform === 'darwin') {
|
||||
browserDirs.push(
|
||||
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
||||
{ name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
|
||||
{ name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
|
||||
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
||||
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
||||
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
browserDirs.push(
|
||||
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
||||
{ name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
|
||||
{ name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
|
||||
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
||||
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
browserDirs.push(
|
||||
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
||||
{ name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
|
||||
{ name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
|
||||
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = enumerateProfiles(browserDirs.map(d => d.base));
|
||||
const foundBrowsers: string[] = [];
|
||||
|
||||
for (const { name, base } of browserDirs) {
|
||||
for (const profile of profiles) {
|
||||
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
||||
if (fileExists(extDir)) {
|
||||
foundBrowsers.push(name);
|
||||
break; // one match per browser is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test token connectivity by attempting a real MCP connection.
|
||||
* Connects, does the JSON-RPC handshake, and immediately closes.
|
||||
*/
|
||||
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
||||
const timeout = opts?.timeout ?? 8;
|
||||
export async function checkConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const mcp = new PlaywrightMCP();
|
||||
await mcp.connect({ timeout });
|
||||
const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
|
||||
// Try a simple eval to verify end-to-end connectivity
|
||||
await page.evaluate('1 + 1');
|
||||
await mcp.close();
|
||||
return { ok: true, durationMs: Date.now() - start };
|
||||
} catch (err: any) {
|
||||
@@ -515,215 +49,87 @@ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promi
|
||||
}
|
||||
|
||||
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
||||
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
||||
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
||||
const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
|
||||
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const token = readTokenFromShellContent(content);
|
||||
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
|
||||
});
|
||||
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
||||
const configs = configPaths.map(readConfigStatus);
|
||||
const status = await checkDaemonStatus();
|
||||
|
||||
// Try to discover the token directly from the Chrome extension's localStorage
|
||||
const extensionToken = discoverExtensionToken();
|
||||
|
||||
const allTokens = [
|
||||
opts.token ?? null,
|
||||
extensionToken,
|
||||
envToken,
|
||||
...shellFiles.map(s => s.token),
|
||||
...configs.map(c => c.token),
|
||||
].filter((v): v is string => !!v);
|
||||
const uniqueTokens = [...new Set(allTokens)];
|
||||
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
||||
|
||||
// Check extension installation
|
||||
const extInstall = checkExtensionInstalled();
|
||||
|
||||
// Connectivity test (only when --live)
|
||||
let connectivity: ConnectivityResult | undefined;
|
||||
if (opts.live) {
|
||||
connectivity = await checkTokenConnectivity();
|
||||
connectivity = await checkConnectivity();
|
||||
}
|
||||
|
||||
const report: DoctorReport = {
|
||||
const issues: string[] = [];
|
||||
if (!status.running) {
|
||||
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
|
||||
}
|
||||
if (status.running && !status.extensionConnected) {
|
||||
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' +
|
||||
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
||||
' 3. Click "Load unpacked" → select the extension folder',
|
||||
);
|
||||
}
|
||||
if (connectivity && !connectivity.ok) {
|
||||
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
return {
|
||||
cliVersion: opts.cliVersion,
|
||||
envToken,
|
||||
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
||||
extensionToken,
|
||||
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
||||
extensionInstalled: extInstall.installed,
|
||||
extensionBrowsers: extInstall.browsers,
|
||||
shellFiles,
|
||||
configs,
|
||||
recommendedToken,
|
||||
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
||||
daemonRunning: status.running,
|
||||
extensionConnected: status.extensionConnected,
|
||||
connectivity,
|
||||
warnings: [],
|
||||
issues: [],
|
||||
issues,
|
||||
};
|
||||
|
||||
if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
||||
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
||||
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
||||
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
||||
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
||||
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
||||
for (const config of configs) {
|
||||
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
||||
}
|
||||
if (!recommendedToken) {
|
||||
report.warnings.push('No token source found.');
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
const tokenFingerprints = [
|
||||
report.extensionFingerprint,
|
||||
report.envFingerprint,
|
||||
...report.shellFiles.map(shell => shell.fingerprint),
|
||||
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
||||
].filter((value): value is string => !!value);
|
||||
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
||||
const hasMismatch = uniqueFingerprints.length > 1;
|
||||
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
||||
|
||||
// CDP endpoint mode (for remote/server environments)
|
||||
const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (cdpEndpoint) {
|
||||
lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
|
||||
lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
// Daemon status
|
||||
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
|
||||
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
|
||||
|
||||
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
||||
const installDetail = report.extensionInstalled
|
||||
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
||||
: 'Extension not installed in any browser';
|
||||
lines.push(statusLine(installStatus, installDetail));
|
||||
// Extension status
|
||||
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
|
||||
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
|
||||
|
||||
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
||||
|
||||
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
||||
|
||||
for (const shell of report.shellFiles) {
|
||||
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
const tool = toolName(shell.path);
|
||||
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
||||
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
|
||||
}
|
||||
const existingConfigs = report.configs.filter(config => config.exists);
|
||||
const missingConfigCount = report.configs.length - existingConfigs.length;
|
||||
if (existingConfigs.length > 0) {
|
||||
for (const config of existingConfigs) {
|
||||
const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
|
||||
const configStatus: ReportStatus = config.parseError
|
||||
? 'WARN'
|
||||
: !config.token
|
||||
? 'MISSING'
|
||||
: hasMismatch
|
||||
? 'MISMATCH'
|
||||
: 'OK';
|
||||
const tool = toolName(config.path);
|
||||
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
||||
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
|
||||
}
|
||||
} else {
|
||||
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
|
||||
}
|
||||
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
||||
lines.push('');
|
||||
|
||||
// Connectivity result
|
||||
// Connectivity
|
||||
if (report.connectivity) {
|
||||
const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
||||
const connDetail = report.connectivity.ok
|
||||
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
||||
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
||||
lines.push(statusLine(connStatus, connDetail));
|
||||
const connIcon = report.connectivity.ok ? chalk.green('[OK]') : chalk.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}`);
|
||||
} else {
|
||||
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
||||
lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
|
||||
}
|
||||
|
||||
lines.push(statusLine(
|
||||
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
||||
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
||||
));
|
||||
if (report.issues.length) {
|
||||
lines.push('', chalk.yellow('Issues:'));
|
||||
for (const issue of report.issues) lines.push(chalk.dim(` • ${issue}`));
|
||||
}
|
||||
if (report.warnings.length) {
|
||||
lines.push('', chalk.yellow('Warnings:'));
|
||||
for (const warning of report.warnings) lines.push(chalk.dim(` • ${warning}`));
|
||||
for (const issue of report.issues) {
|
||||
lines.push(chalk.dim(` • ${issue}`));
|
||||
}
|
||||
} else if (report.daemonRunning && report.extensionConnected) {
|
||||
lines.push('', chalk.green('Everything looks good!'));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function confirmPrompt(question: string): Promise<boolean> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
||||
return answer === 'y' || answer === 'yes';
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFileWithMkdir(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
|
||||
const token = opts.token ?? report.recommendedToken;
|
||||
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
|
||||
const fp = getTokenFingerprint(token);
|
||||
|
||||
const plannedWrites: string[] = [];
|
||||
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
||||
const shellStatus = report.shellFiles.find(s => s.path === shellPath);
|
||||
if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
|
||||
for (const config of report.configs) {
|
||||
if (!config.writable) continue;
|
||||
if (config.fingerprint === fp) continue; // already correct
|
||||
plannedWrites.push(config.path);
|
||||
}
|
||||
|
||||
if (plannedWrites.length === 0) {
|
||||
console.log(chalk.green('All config files are already up to date.'));
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!opts.yes) {
|
||||
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
|
||||
if (!ok) return [];
|
||||
}
|
||||
|
||||
const written: string[] = [];
|
||||
if (plannedWrites.includes(shellPath)) {
|
||||
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
||||
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
|
||||
written.push(shellPath);
|
||||
}
|
||||
|
||||
for (const config of report.configs) {
|
||||
if (!plannedWrites.includes(config.path)) continue;
|
||||
if (config.parseError) continue;
|
||||
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
||||
const next = config.format === 'toml'
|
||||
? upsertTomlConfigToken(before, token)
|
||||
: upsertJsonConfigToken(before, token, config.path);
|
||||
writeFileWithMkdir(config.path, next);
|
||||
written.push(config.path);
|
||||
}
|
||||
|
||||
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
return written;
|
||||
}
|
||||
// Backward compatibility exports (no-ops for things that no longer exist)
|
||||
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
||||
export function discoverExtensionToken(): string | null { return null; }
|
||||
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } { return { installed: false, browsers: [] }; }
|
||||
export function applyBrowserDoctorFix(): Promise<string[]> { return Promise.resolve([]); }
|
||||
export function getDefaultShellRcPath(): string { return ''; }
|
||||
export function getDefaultMcpConfigPaths(): string[] { return []; }
|
||||
export function readTokenFromShellContent(_content: string): string | null { return null; }
|
||||
export function upsertShellToken(content: string): string { return content; }
|
||||
export function upsertJsonConfigToken(content: string): string { return content; }
|
||||
export function readTomlConfigToken(_content: string): string | null { return null; }
|
||||
export function upsertTomlConfigToken(content: string): string { return content; }
|
||||
export function shortenPath(p: string): string { return p; }
|
||||
export function toolName(_p: string): string { return ''; }
|
||||
export function fileExists(filePath: string): boolean { try { return require('node:fs').existsSync(filePath); } catch { return false; } }
|
||||
export function writeFileWithMkdir(_p: string, _c: string): void {}
|
||||
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> { return checkConnectivity(opts); }
|
||||
|
||||
+6
-23
@@ -119,36 +119,19 @@ program.command('cascade').description('Strategy cascade: find simplest working
|
||||
});
|
||||
|
||||
program.command('doctor')
|
||||
.description('Diagnose Playwright MCP Bridge, token consistency, and Chrome remote debugging')
|
||||
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
||||
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
||||
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
||||
.description('Diagnose opencli browser bridge connectivity')
|
||||
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
||||
.option('--shell-rc <path>', 'Shell startup file to update')
|
||||
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
||||
.action(async (opts) => {
|
||||
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
||||
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
|
||||
const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
||||
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
|
||||
const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION });
|
||||
console.log(renderBrowserDoctorReport(report));
|
||||
if (opts.fix) {
|
||||
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|
||||
console.log();
|
||||
if (written.length > 0) {
|
||||
console.log(chalk.green('Updated files:'));
|
||||
for (const filePath of written) console.log(`- ${filePath}`);
|
||||
} else {
|
||||
console.log(chalk.yellow('No files were changed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
program.command('setup')
|
||||
.description('Interactive setup: configure Playwright MCP token across all detected tools')
|
||||
.option('--token <token>', 'Provide token directly instead of auto-detecting')
|
||||
.action(async (opts) => {
|
||||
.description('Interactive setup: verify browser bridge connectivity')
|
||||
.action(async () => {
|
||||
const { runSetup } = await import('./setup.js');
|
||||
await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
|
||||
await runSetup({ cliVersion: PKG_VERSION });
|
||||
});
|
||||
|
||||
program.command('completion')
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function stepSnapshot(page: IPage | null, params: any, _data: any,
|
||||
|
||||
export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
const js = String(render(params, { args, data }));
|
||||
let result = await page!.evaluate(normalizeEvaluateSource(js));
|
||||
let result = await page!.evaluate(js);
|
||||
// MCP may return JSON as a string — auto-parse it
|
||||
if (typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
|
||||
+47
-183
@@ -1,205 +1,69 @@
|
||||
/**
|
||||
* setup.ts — Interactive Playwright MCP token setup
|
||||
* setup.ts — Interactive browser setup for opencli
|
||||
*
|
||||
* Discovers the extension token, shows an interactive checkbox
|
||||
* for selecting which config files to update, and applies changes.
|
||||
* Simplified for daemon-based architecture. No more token management.
|
||||
* Just verifies daemon + extension connectivity.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
import {
|
||||
type DoctorReport,
|
||||
PLAYWRIGHT_TOKEN_ENV,
|
||||
checkExtensionInstalled,
|
||||
checkTokenConnectivity,
|
||||
discoverExtensionToken,
|
||||
fileExists,
|
||||
getDefaultShellRcPath,
|
||||
runBrowserDoctor,
|
||||
shortenPath,
|
||||
toolName,
|
||||
upsertJsonConfigToken,
|
||||
upsertShellToken,
|
||||
upsertTomlConfigToken,
|
||||
writeFileWithMkdir,
|
||||
} from './doctor.js';
|
||||
import { getTokenFingerprint } from './browser/index.js';
|
||||
import { type CheckboxItem, checkboxPrompt } from './tui.js';
|
||||
import { checkDaemonStatus } from './browser/discover.js';
|
||||
import { checkConnectivity } from './doctor.js';
|
||||
import { PlaywrightMCP } from './browser/index.js';
|
||||
|
||||
export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
|
||||
console.log();
|
||||
console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
|
||||
console.log(chalk.bold(' opencli setup') + chalk.dim(' — browser bridge configuration'));
|
||||
console.log();
|
||||
|
||||
// Step 1: Discover token
|
||||
let token = opts.token ?? null;
|
||||
// Step 1: Check daemon
|
||||
console.log(chalk.dim(' Checking daemon status...'));
|
||||
const status = await checkDaemonStatus();
|
||||
|
||||
if (!token) {
|
||||
const extensionToken = discoverExtensionToken();
|
||||
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
||||
|
||||
if (extensionToken && envToken && extensionToken === envToken) {
|
||||
token = extensionToken;
|
||||
console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
|
||||
console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
|
||||
} else if (extensionToken) {
|
||||
token = extensionToken;
|
||||
console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
|
||||
chalk.dim(`(${getTokenFingerprint(token)})`));
|
||||
if (envToken && envToken !== extensionToken) {
|
||||
console.log(` ${chalk.yellow('!')} Environment has different token ` +
|
||||
chalk.dim(`(${getTokenFingerprint(envToken)})`));
|
||||
}
|
||||
} else if (envToken) {
|
||||
token = envToken;
|
||||
console.log(` ${chalk.green('✓')} Token from environment variable ` +
|
||||
chalk.dim(`(${getTokenFingerprint(token)})`));
|
||||
}
|
||||
if (status.running) {
|
||||
console.log(` ${chalk.green('✓')} Daemon is running on port 19825`);
|
||||
} else {
|
||||
console.log(` ${chalk.green('✓')} Using provided token ` +
|
||||
chalk.dim(`(${getTokenFingerprint(token)})`));
|
||||
}
|
||||
console.log(` ${chalk.yellow('!')} Daemon is not running`);
|
||||
console.log(chalk.dim(' The daemon starts automatically when you run a browser command.'));
|
||||
console.log(chalk.dim(' Starting daemon now...'));
|
||||
|
||||
if (!token) {
|
||||
// Give precise diagnosis of why token scan failed
|
||||
const extInstall = checkExtensionInstalled();
|
||||
|
||||
console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
|
||||
if (!extInstall.installed) {
|
||||
console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
|
||||
console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
|
||||
console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
|
||||
} else {
|
||||
console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
|
||||
console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
|
||||
console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
|
||||
}
|
||||
console.log();
|
||||
console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
|
||||
console.log();
|
||||
const rl = createInterface({ input, output });
|
||||
const answer = await rl.question(' Token (press Enter to abort): ');
|
||||
rl.close();
|
||||
token = answer.trim();
|
||||
if (!token) {
|
||||
console.log(chalk.red('\n No token provided. Aborting.\n'));
|
||||
return;
|
||||
// Try to spawn daemon
|
||||
const mcp = new PlaywrightMCP();
|
||||
try {
|
||||
await mcp.connect({ timeout: 5 });
|
||||
await mcp.close();
|
||||
console.log(` ${chalk.green('✓')} Daemon started successfully`);
|
||||
} catch {
|
||||
console.log(` ${chalk.yellow('!')} Could not start daemon automatically`);
|
||||
}
|
||||
}
|
||||
|
||||
const fingerprint = getTokenFingerprint(token) ?? 'unknown';
|
||||
console.log();
|
||||
|
||||
// Step 2: Scan all config locations
|
||||
const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
|
||||
|
||||
// Step 3: Build checkbox items
|
||||
const items: CheckboxItem[] = [];
|
||||
|
||||
// Shell file
|
||||
const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
||||
const shellStatus = report.shellFiles[0];
|
||||
const shellFp = shellStatus?.fingerprint;
|
||||
const shellOk = shellFp === fingerprint;
|
||||
const shellTool = toolName(shellPath) || 'Shell';
|
||||
items.push({
|
||||
label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`),
|
||||
value: `shell:${shellPath}`,
|
||||
checked: !shellOk,
|
||||
status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
|
||||
statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
|
||||
});
|
||||
|
||||
// Config files
|
||||
for (const config of report.configs) {
|
||||
const fp = config.fingerprint;
|
||||
const ok = fp === fingerprint;
|
||||
const tool = toolName(config.path);
|
||||
items.push({
|
||||
label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''),
|
||||
value: `config:${config.path}`,
|
||||
checked: false, // let user explicitly select which tools to configure
|
||||
status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
|
||||
statusColor: ok ? 'green' : 'yellow',
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Show interactive checkbox
|
||||
console.clear();
|
||||
const selected = await checkboxPrompt(items, {
|
||||
title: ` ${chalk.bold('opencli setup')} — token ${chalk.cyan(fingerprint)}`,
|
||||
});
|
||||
|
||||
if (selected.length === 0) {
|
||||
console.log(chalk.dim(' No changes made.\n'));
|
||||
// Step 2: Check extension
|
||||
const statusAfter = await checkDaemonStatus();
|
||||
if (statusAfter.extensionConnected) {
|
||||
console.log(` ${chalk.green('✓')} Chrome extension connected`);
|
||||
} else {
|
||||
console.log(` ${chalk.red('✗')} Chrome extension not connected`);
|
||||
console.log();
|
||||
console.log(chalk.dim(' To install the opencli Browser Bridge extension:'));
|
||||
console.log(chalk.dim(' 1. Download from GitHub Releases'));
|
||||
console.log(chalk.dim(' 2. Open chrome://extensions/ → Enable Developer Mode'));
|
||||
console.log(chalk.dim(' 3. Click "Load unpacked" → select the extension folder'));
|
||||
console.log(chalk.dim(' 4. Make sure Chrome is running'));
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Apply changes
|
||||
const written: string[] = [];
|
||||
let wroteShell = false;
|
||||
|
||||
for (const sel of selected) {
|
||||
if (sel.startsWith('shell:')) {
|
||||
const p = sel.slice('shell:'.length);
|
||||
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
||||
writeFileWithMkdir(p, upsertShellToken(before, token, p));
|
||||
written.push(p);
|
||||
wroteShell = true;
|
||||
} else if (sel.startsWith('config:')) {
|
||||
const p = sel.slice('config:'.length);
|
||||
const config = report.configs.find(c => c.path === p);
|
||||
if (config && config.parseError) continue;
|
||||
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
||||
const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
|
||||
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
|
||||
writeFileWithMkdir(p, next);
|
||||
written.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
|
||||
// Step 6: Summary
|
||||
if (written.length > 0) {
|
||||
console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
|
||||
for (const p of written) {
|
||||
const tool = toolName(p);
|
||||
console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`);
|
||||
}
|
||||
if (wroteShell) {
|
||||
console.log();
|
||||
console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`));
|
||||
}
|
||||
// Step 3: Test connectivity
|
||||
console.log();
|
||||
console.log(chalk.dim(' Testing browser connectivity...'));
|
||||
const conn = await checkConnectivity({ timeout: 5 });
|
||||
if (conn.ok) {
|
||||
console.log(` ${chalk.green('✓')} Browser connected in ${(conn.durationMs / 1000).toFixed(1)}s`);
|
||||
console.log();
|
||||
console.log(chalk.green.bold(' ✓ Setup complete! You can now use opencli browser commands.'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' No files were changed.'));
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Step 7: Auto-verify browser connectivity
|
||||
console.log(chalk.dim(' Verifying browser connectivity...'));
|
||||
try {
|
||||
const result = await checkTokenConnectivity({ timeout: 5 });
|
||||
if (result.ok) {
|
||||
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
|
||||
} else {
|
||||
console.log(` ${chalk.green('✓')} Token saved successfully.`);
|
||||
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
|
||||
console.log(chalk.dim(' Token configuration is complete. To use opencli, make sure Chrome'));
|
||||
console.log(chalk.dim(' is running with the Playwright MCP Bridge extension enabled.'));
|
||||
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
|
||||
}
|
||||
} catch {
|
||||
console.log(` ${chalk.green('✓')} Token saved successfully.`);
|
||||
console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
|
||||
console.log(chalk.dim(' Token configuration is complete. Start Chrome to begin using opencli.'));
|
||||
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
|
||||
console.log(` ${chalk.yellow('!')} Connectivity test failed: ${conn.error ?? 'unknown'}`);
|
||||
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to diagnose.`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function padRight(s: string, n: number): string {
|
||||
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user