@jackwener/opencli 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -27
- package/SKILL.md +7 -4
- package/dist/doctor.d.ts +29 -2
- package/dist/doctor.js +122 -55
- package/dist/doctor.test.js +42 -1
- package/dist/main.js +2 -1
- package/package.json +1 -1
- package/src/doctor.test.ts +46 -1
- package/src/doctor.ts +149 -53
- package/src/main.ts +2 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# OpenCLI
|
|
2
2
|
|
|
3
3
|
> **Make any website your CLI.**
|
|
4
|
-
> Zero risk · Reuse Chrome login · AI-powered discovery
|
|
4
|
+
> Zero risk · Reuse Chrome login · AI-powered discovery · 80+ commands · 19 sites
|
|
5
5
|
|
|
6
6
|
[中文文档](./README.zh-CN.md)
|
|
7
7
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](./LICENSE)
|
|
11
11
|
|
|
12
|
-
A CLI tool that turns **any website** into a command-line interface —
|
|
12
|
+
A CLI tool that turns **any website** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -32,8 +32,9 @@ A CLI tool that turns **any website** into a command-line interface — bilibili
|
|
|
32
32
|
|
|
33
33
|
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
|
34
34
|
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
|
35
|
+
- **Self-healing setup** — `opencli setup` auto-discovers tokens; `opencli doctor` diagnoses config across 10+ tools; `--fix` repairs them all.
|
|
35
36
|
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
|
36
|
-
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime
|
|
37
|
+
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
|
37
38
|
|
|
38
39
|
## Prerequisites
|
|
39
40
|
|
|
@@ -85,10 +86,12 @@ export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<your-token-here>"
|
|
|
85
86
|
|
|
86
87
|
</details>
|
|
87
88
|
|
|
88
|
-
Verify with `opencli doctor` — shows colored status for all config locations:
|
|
89
|
+
Verify with `opencli doctor` — shows colored status for extension install, token consistency, and all config locations:
|
|
89
90
|
|
|
90
91
|
```bash
|
|
91
|
-
opencli doctor
|
|
92
|
+
opencli doctor # Token & config diagnosis
|
|
93
|
+
opencli doctor --live # Also test live browser connectivity
|
|
94
|
+
opencli doctor --fix -y # Auto-fix all mismatched configs
|
|
92
95
|
```
|
|
93
96
|
|
|
94
97
|
## Quick Start
|
|
@@ -130,27 +133,29 @@ npm install -g @jackwener/opencli@latest
|
|
|
130
133
|
|
|
131
134
|
## Built-in Commands
|
|
132
135
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
|
136
|
-
|
|
137
|
-
| **
|
|
138
|
-
| **
|
|
139
|
-
| **
|
|
140
|
-
| **
|
|
141
|
-
| **
|
|
142
|
-
| **
|
|
143
|
-
| **
|
|
144
|
-
| **
|
|
145
|
-
| **
|
|
146
|
-
| **
|
|
147
|
-
| **
|
|
148
|
-
| **
|
|
149
|
-
| **
|
|
150
|
-
| **
|
|
151
|
-
| **
|
|
152
|
-
| **
|
|
153
|
-
| **
|
|
136
|
+
**19 sites · 80+ commands** — run `opencli list` for the live registry.
|
|
137
|
+
|
|
138
|
+
| Site | Commands | Count | Mode |
|
|
139
|
+
|------|----------|:-----:|------|
|
|
140
|
+
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 Browser |
|
|
141
|
+
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 Browser |
|
|
142
|
+
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser |
|
|
143
|
+
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
|
|
144
|
+
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser |
|
|
145
|
+
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser |
|
|
146
|
+
| **youtube** | `search` `video` `transcript` | 3 | 🔐 Browser |
|
|
147
|
+
| **zhihu** | `hot` `search` `question` | 3 | 🔐 Browser |
|
|
148
|
+
| **boss** | `search` `detail` | 2 | 🔐 Browser |
|
|
149
|
+
| **coupang** | `search` `add-to-cart` | 2 | 🔐 Browser |
|
|
150
|
+
| **bbc** | `news` | 1 | 🌐 Public |
|
|
151
|
+
| **ctrip** | `search` | 1 | 🔐 Browser |
|
|
152
|
+
| **github** | `search` | 1 | 🌐 Public |
|
|
153
|
+
| **hackernews** | `top` | 1 | 🌐 Public |
|
|
154
|
+
| **linkedin** | `search` | 1 | 🔐 Browser |
|
|
155
|
+
| **reuters** | `search` | 1 | 🔐 Browser |
|
|
156
|
+
| **smzdm** | `search` | 1 | 🔐 Browser |
|
|
157
|
+
| **weibo** | `hot` | 1 | 🔐 Browser |
|
|
158
|
+
| **yahoo-finance** | `quote` | 1 | 🔐 Browser |
|
|
154
159
|
|
|
155
160
|
## Output Formats
|
|
156
161
|
|
|
@@ -195,7 +200,7 @@ Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, ca
|
|
|
195
200
|
|
|
196
201
|
See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
|
|
197
202
|
|
|
198
|
-
- Current test coverage (unit +
|
|
203
|
+
- Current test coverage (unit + E2E tests across 19 sites)
|
|
199
204
|
- How to run tests locally
|
|
200
205
|
- How to add tests when creating new adapters
|
|
201
206
|
- CI/CD pipeline with sharding
|
package/SKILL.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: opencli
|
|
3
|
-
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
|
|
4
|
-
version: 0.7.
|
|
3
|
+
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login. 80+ commands across 19 sites."
|
|
4
|
+
version: 0.7.3
|
|
5
5
|
author: jackwener
|
|
6
|
-
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, AI, agent]
|
|
6
|
+
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# OpenCLI
|
|
@@ -68,6 +68,7 @@ opencli zhihu question --id 34816524 # 问题详情和回答
|
|
|
68
68
|
opencli xiaohongshu search --keyword "美食" # 搜索笔记
|
|
69
69
|
opencli xiaohongshu notifications # 通知(mentions/likes/connections)
|
|
70
70
|
opencli xiaohongshu feed --limit 10 # 推荐 Feed
|
|
71
|
+
opencli xiaohongshu me # 我的信息
|
|
71
72
|
opencli xiaohongshu user --uid xxx # 用户主页
|
|
72
73
|
|
|
73
74
|
# 雪球 Xueqiu (browser)
|
|
@@ -131,6 +132,7 @@ opencli weibo hot --limit 10 # 微博热搜
|
|
|
131
132
|
|
|
132
133
|
# BOSS直聘 (browser)
|
|
133
134
|
opencli boss search --query "AI agent" # 搜索职位
|
|
135
|
+
opencli boss detail --securityId xxx # 职位详情
|
|
134
136
|
|
|
135
137
|
# YouTube (browser)
|
|
136
138
|
opencli youtube search --query "rust" # 搜索视频
|
|
@@ -160,7 +162,8 @@ opencli list -f yaml # YAML output
|
|
|
160
162
|
opencli validate # Validate all CLI definitions
|
|
161
163
|
opencli validate bilibili # Validate specific site
|
|
162
164
|
opencli setup # Interactive token setup (auto-discover + TUI checkbox)
|
|
163
|
-
opencli doctor # Diagnose token config across all tools
|
|
165
|
+
opencli doctor # Diagnose token & extension config across all tools
|
|
166
|
+
opencli doctor --live # Also test live browser connectivity
|
|
164
167
|
opencli doctor --fix -y # Auto-fix all config files (non-interactive)
|
|
165
168
|
```
|
|
166
169
|
|
package/dist/doctor.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const PLAYWRIGHT_TOKEN_ENV = "PLAYWRIGHT_MCP_EXTENSION_TOKEN";
|
|
|
2
2
|
export type DoctorOptions = {
|
|
3
3
|
fix?: boolean;
|
|
4
4
|
yes?: boolean;
|
|
5
|
+
live?: boolean;
|
|
5
6
|
shellRc?: string;
|
|
6
7
|
configPaths?: string[];
|
|
7
8
|
token?: string;
|
|
@@ -23,16 +24,24 @@ export type McpConfigStatus = {
|
|
|
23
24
|
writable: boolean;
|
|
24
25
|
parseError?: string;
|
|
25
26
|
};
|
|
27
|
+
export type ConnectivityResult = {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
durationMs: number;
|
|
31
|
+
};
|
|
26
32
|
export type DoctorReport = {
|
|
27
33
|
cliVersion?: string;
|
|
28
34
|
envToken: string | null;
|
|
29
35
|
envFingerprint: string | null;
|
|
30
36
|
extensionToken: string | null;
|
|
31
37
|
extensionFingerprint: string | null;
|
|
38
|
+
extensionInstalled: boolean;
|
|
39
|
+
extensionBrowsers: string[];
|
|
32
40
|
shellFiles: ShellFileStatus[];
|
|
33
41
|
configs: McpConfigStatus[];
|
|
34
42
|
recommendedToken: string | null;
|
|
35
43
|
recommendedFingerprint: string | null;
|
|
44
|
+
connectivity?: ConnectivityResult;
|
|
36
45
|
warnings: string[];
|
|
37
46
|
issues: string[];
|
|
38
47
|
};
|
|
@@ -50,10 +59,28 @@ export declare function fileExists(filePath: string): boolean;
|
|
|
50
59
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
51
60
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
52
61
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
62
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
63
|
+
* extension ID near base64url token values. This works reliably across
|
|
64
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
65
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
66
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
55
67
|
*/
|
|
56
68
|
export declare function discoverExtensionToken(): string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
71
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
72
|
+
*/
|
|
73
|
+
export declare function checkExtensionInstalled(): {
|
|
74
|
+
installed: boolean;
|
|
75
|
+
browsers: string[];
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
79
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
80
|
+
*/
|
|
81
|
+
export declare function checkTokenConnectivity(opts?: {
|
|
82
|
+
timeout?: number;
|
|
83
|
+
}): Promise<ConnectivityResult>;
|
|
57
84
|
export declare function runBrowserDoctor(opts?: DoctorOptions): Promise<DoctorReport>;
|
|
58
85
|
export declare function renderBrowserDoctorReport(report: DoctorReport): string;
|
|
59
86
|
export declare function writeFileWithMkdir(filePath: string, content: string): void;
|
package/dist/doctor.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
-
import { execSync } from 'node:child_process';
|
|
5
4
|
import { createInterface } from 'node:readline/promises';
|
|
6
5
|
import { stdin as input, stdout as output } from 'node:process';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
|
-
import { getTokenFingerprint } from './browser.js';
|
|
7
|
+
import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
|
|
9
8
|
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
10
9
|
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
|
11
10
|
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
|
@@ -105,7 +104,7 @@ function readTokenFromJsonObject(parsed) {
|
|
|
105
104
|
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
106
105
|
if (typeof direct === 'string' && direct)
|
|
107
106
|
return direct;
|
|
108
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.
|
|
107
|
+
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
109
108
|
if (typeof opencode === 'string' && opencode)
|
|
110
109
|
return opencode;
|
|
111
110
|
return null;
|
|
@@ -127,8 +126,8 @@ export function upsertJsonConfigToken(content, token) {
|
|
|
127
126
|
enabled: true,
|
|
128
127
|
type: 'local',
|
|
129
128
|
};
|
|
130
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
131
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
129
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
|
|
130
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
132
131
|
}
|
|
133
132
|
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
134
133
|
}
|
|
@@ -211,8 +210,11 @@ function readConfigStatus(filePath) {
|
|
|
211
210
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
212
211
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
213
212
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
213
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
214
|
+
* extension ID near base64url token values. This works reliably across
|
|
215
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
216
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
217
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
216
218
|
*/
|
|
217
219
|
export function discoverExtensionToken() {
|
|
218
220
|
const home = os.homedir();
|
|
@@ -229,21 +231,12 @@ export function discoverExtensionToken() {
|
|
|
229
231
|
bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
|
|
230
232
|
}
|
|
231
233
|
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
232
|
-
// Token is 43 chars of base64url (from 32 random bytes)
|
|
233
234
|
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
234
235
|
for (const base of bases) {
|
|
235
236
|
for (const profile of profiles) {
|
|
236
237
|
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
237
238
|
if (!fileExists(dir))
|
|
238
239
|
continue;
|
|
239
|
-
// Fast path: use strings + grep to find candidate files and extract token
|
|
240
|
-
if (platform !== 'win32') {
|
|
241
|
-
const token = extractTokenViaStrings(dir, tokenRe);
|
|
242
|
-
if (token)
|
|
243
|
-
return token;
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
// Slow path (Windows): read binary files directly
|
|
247
240
|
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
248
241
|
if (token)
|
|
249
242
|
return token;
|
|
@@ -251,39 +244,20 @@ export function discoverExtensionToken() {
|
|
|
251
244
|
}
|
|
252
245
|
return null;
|
|
253
246
|
}
|
|
254
|
-
function extractTokenViaStrings(dir, tokenRe) {
|
|
255
|
-
try {
|
|
256
|
-
// Single shell pipeline: for each LevelDB file, extract strings, find lines
|
|
257
|
-
// after the extension ID, and filter for base64url token pattern.
|
|
258
|
-
//
|
|
259
|
-
// LevelDB `strings` output for the extension's auth-token entry:
|
|
260
|
-
// auth-token ← key name
|
|
261
|
-
// 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
|
|
262
|
-
// hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
|
|
263
|
-
//
|
|
264
|
-
// We get the line immediately after any EXTENSION_ID mention and check
|
|
265
|
-
// if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
|
|
266
|
-
const shellDir = dir.replace(/'/g, "'\\''");
|
|
267
|
-
const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
|
|
268
|
-
`[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
|
|
269
|
-
`grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
270
|
-
`grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
271
|
-
`grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
|
|
272
|
-
`done 2>/dev/null`;
|
|
273
|
-
const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
274
|
-
// Take the first non-empty line
|
|
275
|
-
for (const line of result.split('\n')) {
|
|
276
|
-
const token = line.trim();
|
|
277
|
-
if (token && validateBase64urlToken(token))
|
|
278
|
-
return token;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
catch { }
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
247
|
function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
248
|
+
// LevelDB fragments strings across byte boundaries, so we can't search
|
|
249
|
+
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
|
250
|
+
// search for a short prefix of the extension ID that reliably appears as
|
|
251
|
+
// contiguous bytes, then scan a window around each match for a base64url
|
|
252
|
+
// token value.
|
|
253
|
+
//
|
|
254
|
+
// Observed LevelDB layout near the auth-token entry:
|
|
255
|
+
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
|
256
|
+
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
|
257
|
+
//
|
|
258
|
+
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
|
285
259
|
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
286
|
-
const
|
|
260
|
+
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
287
261
|
let files;
|
|
288
262
|
try {
|
|
289
263
|
files = fs.readdirSync(dir)
|
|
@@ -293,7 +267,7 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
|
293
267
|
catch {
|
|
294
268
|
return null;
|
|
295
269
|
}
|
|
296
|
-
// Sort by mtime descending
|
|
270
|
+
// Sort by mtime descending so we find the freshest token first
|
|
297
271
|
files.sort((a, b) => {
|
|
298
272
|
try {
|
|
299
273
|
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
|
|
@@ -310,15 +284,30 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
|
310
284
|
catch {
|
|
311
285
|
continue;
|
|
312
286
|
}
|
|
313
|
-
// Quick check:
|
|
314
|
-
|
|
315
|
-
if (extPos === -1)
|
|
316
|
-
continue;
|
|
317
|
-
const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
|
|
318
|
-
if (keyPos === -1)
|
|
287
|
+
// Quick check: file must contain at least the prefix
|
|
288
|
+
if (data.indexOf(extIdPrefix) === -1)
|
|
319
289
|
continue;
|
|
320
|
-
//
|
|
290
|
+
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
291
|
+
// for base64url tokens within a 500-byte window
|
|
321
292
|
let idx = 0;
|
|
293
|
+
while (true) {
|
|
294
|
+
const pos = data.indexOf(extIdPrefix, idx);
|
|
295
|
+
if (pos === -1)
|
|
296
|
+
break;
|
|
297
|
+
const scanStart = pos;
|
|
298
|
+
const scanEnd = Math.min(data.length, pos + 500);
|
|
299
|
+
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
|
300
|
+
const m = window.match(tokenRe);
|
|
301
|
+
if (m && validateBase64urlToken(m[1])) {
|
|
302
|
+
// Make sure this isn't another extension ID that happens to match
|
|
303
|
+
if (m[1] !== PLAYWRIGHT_EXTENSION_ID)
|
|
304
|
+
return m[1];
|
|
305
|
+
}
|
|
306
|
+
idx = pos + 1;
|
|
307
|
+
}
|
|
308
|
+
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
|
309
|
+
const keyBuf = Buffer.from('auth-token');
|
|
310
|
+
idx = 0;
|
|
322
311
|
while (true) {
|
|
323
312
|
const kp = data.indexOf(keyBuf, idx);
|
|
324
313
|
if (kp === -1)
|
|
@@ -345,6 +334,54 @@ function validateBase64urlToken(token) {
|
|
|
345
334
|
return false;
|
|
346
335
|
}
|
|
347
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
339
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
340
|
+
*/
|
|
341
|
+
export function checkExtensionInstalled() {
|
|
342
|
+
const home = os.homedir();
|
|
343
|
+
const platform = os.platform();
|
|
344
|
+
const browserDirs = [];
|
|
345
|
+
if (platform === 'darwin') {
|
|
346
|
+
browserDirs.push({ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') }, { 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') });
|
|
347
|
+
}
|
|
348
|
+
else if (platform === 'linux') {
|
|
349
|
+
browserDirs.push({ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') }, { name: 'Chromium', base: path.join(home, '.config', 'chromium') }, { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') });
|
|
350
|
+
}
|
|
351
|
+
else if (platform === 'win32') {
|
|
352
|
+
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
353
|
+
browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
|
|
354
|
+
}
|
|
355
|
+
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
356
|
+
const foundBrowsers = [];
|
|
357
|
+
for (const { name, base } of browserDirs) {
|
|
358
|
+
for (const profile of profiles) {
|
|
359
|
+
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
|
360
|
+
if (fileExists(extDir)) {
|
|
361
|
+
foundBrowsers.push(name);
|
|
362
|
+
break; // one match per browser is enough
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
370
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
371
|
+
*/
|
|
372
|
+
export async function checkTokenConnectivity(opts) {
|
|
373
|
+
const timeout = opts?.timeout ?? 8;
|
|
374
|
+
const start = Date.now();
|
|
375
|
+
try {
|
|
376
|
+
const mcp = new PlaywrightMCP();
|
|
377
|
+
await mcp.connect({ timeout });
|
|
378
|
+
await mcp.close();
|
|
379
|
+
return { ok: true, durationMs: Date.now() - start };
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
348
385
|
export async function runBrowserDoctor(opts = {}) {
|
|
349
386
|
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
350
387
|
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
@@ -368,19 +405,31 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
368
405
|
].filter((v) => !!v);
|
|
369
406
|
const uniqueTokens = [...new Set(allTokens)];
|
|
370
407
|
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
408
|
+
// Check extension installation
|
|
409
|
+
const extInstall = checkExtensionInstalled();
|
|
410
|
+
// Connectivity test (only when --live)
|
|
411
|
+
let connectivity;
|
|
412
|
+
if (opts.live) {
|
|
413
|
+
connectivity = await checkTokenConnectivity();
|
|
414
|
+
}
|
|
371
415
|
const report = {
|
|
372
416
|
cliVersion: opts.cliVersion,
|
|
373
417
|
envToken,
|
|
374
418
|
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
375
419
|
extensionToken,
|
|
376
420
|
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
421
|
+
extensionInstalled: extInstall.installed,
|
|
422
|
+
extensionBrowsers: extInstall.browsers,
|
|
377
423
|
shellFiles,
|
|
378
424
|
configs,
|
|
379
425
|
recommendedToken,
|
|
380
426
|
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
427
|
+
connectivity,
|
|
381
428
|
warnings: [],
|
|
382
429
|
issues: [],
|
|
383
430
|
};
|
|
431
|
+
if (!extInstall.installed)
|
|
432
|
+
report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
384
433
|
if (!envToken)
|
|
385
434
|
report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
386
435
|
if (!shellFiles.some(s => s.token))
|
|
@@ -389,6 +438,8 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
389
438
|
report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
390
439
|
if (uniqueTokens.length > 1)
|
|
391
440
|
report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
441
|
+
if (connectivity && !connectivity.ok)
|
|
442
|
+
report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
392
443
|
for (const config of configs) {
|
|
393
444
|
if (config.parseError)
|
|
394
445
|
report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
@@ -408,6 +459,11 @@ export function renderBrowserDoctorReport(report) {
|
|
|
408
459
|
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
409
460
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
410
461
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
462
|
+
const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
463
|
+
const installDetail = report.extensionInstalled
|
|
464
|
+
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
465
|
+
: 'Extension not installed in any browser';
|
|
466
|
+
lines.push(statusLine(installStatus, installDetail));
|
|
411
467
|
const extStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
412
468
|
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
413
469
|
const envStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
@@ -441,6 +497,17 @@ export function renderBrowserDoctorReport(report) {
|
|
|
441
497
|
if (missingConfigCount > 0)
|
|
442
498
|
lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
443
499
|
lines.push('');
|
|
500
|
+
// Connectivity result
|
|
501
|
+
if (report.connectivity) {
|
|
502
|
+
const connStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
|
503
|
+
const connDetail = report.connectivity.ok
|
|
504
|
+
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
505
|
+
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
|
506
|
+
lines.push(statusLine(connStatus, connDetail));
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
|
510
|
+
}
|
|
444
511
|
lines.push(statusLine(hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`));
|
|
445
512
|
if (report.issues.length) {
|
|
446
513
|
lines.push('', chalk.yellow('Issues:'));
|
package/dist/doctor.test.js
CHANGED
|
@@ -66,7 +66,7 @@ describe('json token helpers', () => {
|
|
|
66
66
|
},
|
|
67
67
|
}), 'abc123');
|
|
68
68
|
const parsed = JSON.parse(next);
|
|
69
|
-
expect(parsed.mcp.playwright.
|
|
69
|
+
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
72
|
describe('doctor report rendering', () => {
|
|
@@ -77,6 +77,8 @@ describe('doctor report rendering', () => {
|
|
|
77
77
|
envFingerprint: 'fp1',
|
|
78
78
|
extensionToken: 'abc123',
|
|
79
79
|
extensionFingerprint: 'fp1',
|
|
80
|
+
extensionInstalled: true,
|
|
81
|
+
extensionBrowsers: ['Chrome'],
|
|
80
82
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
81
83
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
82
84
|
recommendedToken: 'abc123',
|
|
@@ -84,6 +86,7 @@ describe('doctor report rendering', () => {
|
|
|
84
86
|
warnings: [],
|
|
85
87
|
issues: [],
|
|
86
88
|
}));
|
|
89
|
+
expect(text).toContain('[OK] Extension installed (Chrome)');
|
|
87
90
|
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
|
88
91
|
expect(text).toContain('[OK] /tmp/mcp.json');
|
|
89
92
|
expect(text).toContain('configured (fp1)');
|
|
@@ -94,6 +97,8 @@ describe('doctor report rendering', () => {
|
|
|
94
97
|
envFingerprint: 'fp1',
|
|
95
98
|
extensionToken: null,
|
|
96
99
|
extensionFingerprint: null,
|
|
100
|
+
extensionInstalled: false,
|
|
101
|
+
extensionBrowsers: [],
|
|
97
102
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
98
103
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
99
104
|
recommendedToken: 'abc123',
|
|
@@ -101,9 +106,45 @@ describe('doctor report rendering', () => {
|
|
|
101
106
|
warnings: [],
|
|
102
107
|
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
103
108
|
}));
|
|
109
|
+
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
|
104
110
|
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
|
105
111
|
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
|
106
112
|
expect(text).toContain('configured (fp2)');
|
|
107
113
|
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
108
114
|
});
|
|
115
|
+
it('renders connectivity OK when live test succeeds', () => {
|
|
116
|
+
const text = strip(renderBrowserDoctorReport({
|
|
117
|
+
envToken: 'abc123',
|
|
118
|
+
envFingerprint: 'fp1',
|
|
119
|
+
extensionToken: 'abc123',
|
|
120
|
+
extensionFingerprint: 'fp1',
|
|
121
|
+
extensionInstalled: true,
|
|
122
|
+
extensionBrowsers: ['Chrome'],
|
|
123
|
+
shellFiles: [],
|
|
124
|
+
configs: [],
|
|
125
|
+
recommendedToken: 'abc123',
|
|
126
|
+
recommendedFingerprint: 'fp1',
|
|
127
|
+
connectivity: { ok: true, durationMs: 1234 },
|
|
128
|
+
warnings: [],
|
|
129
|
+
issues: [],
|
|
130
|
+
}));
|
|
131
|
+
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
|
132
|
+
});
|
|
133
|
+
it('renders connectivity WARN when not tested', () => {
|
|
134
|
+
const text = strip(renderBrowserDoctorReport({
|
|
135
|
+
envToken: 'abc123',
|
|
136
|
+
envFingerprint: 'fp1',
|
|
137
|
+
extensionToken: 'abc123',
|
|
138
|
+
extensionFingerprint: 'fp1',
|
|
139
|
+
extensionInstalled: true,
|
|
140
|
+
extensionBrowsers: ['Chrome'],
|
|
141
|
+
shellFiles: [],
|
|
142
|
+
configs: [],
|
|
143
|
+
recommendedToken: 'abc123',
|
|
144
|
+
recommendedFingerprint: 'fp1',
|
|
145
|
+
warnings: [],
|
|
146
|
+
issues: [],
|
|
147
|
+
}));
|
|
148
|
+
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
|
149
|
+
});
|
|
109
150
|
});
|
package/dist/main.js
CHANGED
|
@@ -102,12 +102,13 @@ program.command('doctor')
|
|
|
102
102
|
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
|
103
103
|
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
|
104
104
|
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
|
105
|
+
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
|
105
106
|
.option('--shell-rc <path>', 'Shell startup file to update')
|
|
106
107
|
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
|
107
108
|
.action(async (opts) => {
|
|
108
109
|
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
|
109
110
|
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
110
|
-
const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
111
|
+
const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
111
112
|
console.log(renderBrowserDoctorReport(report));
|
|
112
113
|
if (opts.fix) {
|
|
113
114
|
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|
package/package.json
CHANGED
package/src/doctor.test.ts
CHANGED
|
@@ -81,7 +81,7 @@ describe('json token helpers', () => {
|
|
|
81
81
|
},
|
|
82
82
|
}), 'abc123');
|
|
83
83
|
const parsed = JSON.parse(next);
|
|
84
|
-
expect(parsed.mcp.playwright.
|
|
84
|
+
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
87
|
|
|
@@ -94,6 +94,8 @@ describe('doctor report rendering', () => {
|
|
|
94
94
|
envFingerprint: 'fp1',
|
|
95
95
|
extensionToken: 'abc123',
|
|
96
96
|
extensionFingerprint: 'fp1',
|
|
97
|
+
extensionInstalled: true,
|
|
98
|
+
extensionBrowsers: ['Chrome'],
|
|
97
99
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
98
100
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
99
101
|
recommendedToken: 'abc123',
|
|
@@ -102,6 +104,7 @@ describe('doctor report rendering', () => {
|
|
|
102
104
|
issues: [],
|
|
103
105
|
}));
|
|
104
106
|
|
|
107
|
+
expect(text).toContain('[OK] Extension installed (Chrome)');
|
|
105
108
|
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
|
106
109
|
expect(text).toContain('[OK] /tmp/mcp.json');
|
|
107
110
|
expect(text).toContain('configured (fp1)');
|
|
@@ -113,6 +116,8 @@ describe('doctor report rendering', () => {
|
|
|
113
116
|
envFingerprint: 'fp1',
|
|
114
117
|
extensionToken: null,
|
|
115
118
|
extensionFingerprint: null,
|
|
119
|
+
extensionInstalled: false,
|
|
120
|
+
extensionBrowsers: [],
|
|
116
121
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
117
122
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
118
123
|
recommendedToken: 'abc123',
|
|
@@ -121,10 +126,50 @@ describe('doctor report rendering', () => {
|
|
|
121
126
|
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
122
127
|
}));
|
|
123
128
|
|
|
129
|
+
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
|
124
130
|
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
|
125
131
|
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
|
126
132
|
expect(text).toContain('configured (fp2)');
|
|
127
133
|
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
128
134
|
});
|
|
135
|
+
|
|
136
|
+
it('renders connectivity OK when live test succeeds', () => {
|
|
137
|
+
const text = strip(renderBrowserDoctorReport({
|
|
138
|
+
envToken: 'abc123',
|
|
139
|
+
envFingerprint: 'fp1',
|
|
140
|
+
extensionToken: 'abc123',
|
|
141
|
+
extensionFingerprint: 'fp1',
|
|
142
|
+
extensionInstalled: true,
|
|
143
|
+
extensionBrowsers: ['Chrome'],
|
|
144
|
+
shellFiles: [],
|
|
145
|
+
configs: [],
|
|
146
|
+
recommendedToken: 'abc123',
|
|
147
|
+
recommendedFingerprint: 'fp1',
|
|
148
|
+
connectivity: { ok: true, durationMs: 1234 },
|
|
149
|
+
warnings: [],
|
|
150
|
+
issues: [],
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('renders connectivity WARN when not tested', () => {
|
|
157
|
+
const text = strip(renderBrowserDoctorReport({
|
|
158
|
+
envToken: 'abc123',
|
|
159
|
+
envFingerprint: 'fp1',
|
|
160
|
+
extensionToken: 'abc123',
|
|
161
|
+
extensionFingerprint: 'fp1',
|
|
162
|
+
extensionInstalled: true,
|
|
163
|
+
extensionBrowsers: ['Chrome'],
|
|
164
|
+
shellFiles: [],
|
|
165
|
+
configs: [],
|
|
166
|
+
recommendedToken: 'abc123',
|
|
167
|
+
recommendedFingerprint: 'fp1',
|
|
168
|
+
warnings: [],
|
|
169
|
+
issues: [],
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
|
173
|
+
});
|
|
129
174
|
});
|
|
130
175
|
|
package/src/doctor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { stdin as input, stdout as output } from 'node:process';
|
|
7
7
|
import chalk from 'chalk';
|
|
@@ -16,6 +16,7 @@ const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'
|
|
|
16
16
|
export type DoctorOptions = {
|
|
17
17
|
fix?: boolean;
|
|
18
18
|
yes?: boolean;
|
|
19
|
+
live?: boolean;
|
|
19
20
|
shellRc?: string;
|
|
20
21
|
configPaths?: string[];
|
|
21
22
|
token?: string;
|
|
@@ -41,16 +42,25 @@ export type McpConfigStatus = {
|
|
|
41
42
|
parseError?: string;
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
export type ConnectivityResult = {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
error?: string;
|
|
48
|
+
durationMs: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
44
51
|
export type DoctorReport = {
|
|
45
52
|
cliVersion?: string;
|
|
46
53
|
envToken: string | null;
|
|
47
54
|
envFingerprint: string | null;
|
|
48
55
|
extensionToken: string | null;
|
|
49
56
|
extensionFingerprint: string | null;
|
|
57
|
+
extensionInstalled: boolean;
|
|
58
|
+
extensionBrowsers: string[];
|
|
50
59
|
shellFiles: ShellFileStatus[];
|
|
51
60
|
configs: McpConfigStatus[];
|
|
52
61
|
recommendedToken: string | null;
|
|
53
62
|
recommendedFingerprint: string | null;
|
|
63
|
+
connectivity?: ConnectivityResult;
|
|
54
64
|
warnings: string[];
|
|
55
65
|
issues: string[];
|
|
56
66
|
};
|
|
@@ -147,7 +157,7 @@ function readJsonConfigToken(content: string): string | null {
|
|
|
147
157
|
function readTokenFromJsonObject(parsed: any): string | null {
|
|
148
158
|
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
149
159
|
if (typeof direct === 'string' && direct) return direct;
|
|
150
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.
|
|
160
|
+
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
151
161
|
if (typeof opencode === 'string' && opencode) return opencode;
|
|
152
162
|
return null;
|
|
153
163
|
}
|
|
@@ -168,8 +178,8 @@ export function upsertJsonConfigToken(content: string, token: string): string {
|
|
|
168
178
|
enabled: true,
|
|
169
179
|
type: 'local',
|
|
170
180
|
};
|
|
171
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
172
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
181
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
|
|
182
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
173
183
|
}
|
|
174
184
|
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
175
185
|
}
|
|
@@ -256,8 +266,11 @@ function readConfigStatus(filePath: string): McpConfigStatus {
|
|
|
256
266
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
257
267
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
258
268
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
269
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
270
|
+
* extension ID near base64url token values. This works reliably across
|
|
271
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
272
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
273
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
261
274
|
*/
|
|
262
275
|
export function discoverExtensionToken(): string | null {
|
|
263
276
|
const home = os.homedir();
|
|
@@ -286,7 +299,6 @@ export function discoverExtensionToken(): string | null {
|
|
|
286
299
|
}
|
|
287
300
|
|
|
288
301
|
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
289
|
-
// Token is 43 chars of base64url (from 32 random bytes)
|
|
290
302
|
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
291
303
|
|
|
292
304
|
for (const base of bases) {
|
|
@@ -294,14 +306,6 @@ export function discoverExtensionToken(): string | null {
|
|
|
294
306
|
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
295
307
|
if (!fileExists(dir)) continue;
|
|
296
308
|
|
|
297
|
-
// Fast path: use strings + grep to find candidate files and extract token
|
|
298
|
-
if (platform !== 'win32') {
|
|
299
|
-
const token = extractTokenViaStrings(dir, tokenRe);
|
|
300
|
-
if (token) return token;
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Slow path (Windows): read binary files directly
|
|
305
309
|
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
306
310
|
if (token) return token;
|
|
307
311
|
}
|
|
@@ -310,39 +314,20 @@ export function discoverExtensionToken(): string | null {
|
|
|
310
314
|
return null;
|
|
311
315
|
}
|
|
312
316
|
|
|
313
|
-
function extractTokenViaStrings(dir: string, tokenRe: RegExp): string | null {
|
|
314
|
-
try {
|
|
315
|
-
// Single shell pipeline: for each LevelDB file, extract strings, find lines
|
|
316
|
-
// after the extension ID, and filter for base64url token pattern.
|
|
317
|
-
//
|
|
318
|
-
// LevelDB `strings` output for the extension's auth-token entry:
|
|
319
|
-
// auth-token ← key name
|
|
320
|
-
// 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
|
|
321
|
-
// hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
|
|
322
|
-
//
|
|
323
|
-
// We get the line immediately after any EXTENSION_ID mention and check
|
|
324
|
-
// if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
|
|
325
|
-
const shellDir = dir.replace(/'/g, "'\\''");
|
|
326
|
-
const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
|
|
327
|
-
`[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
|
|
328
|
-
`grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
329
|
-
`grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
330
|
-
`grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
|
|
331
|
-
`done 2>/dev/null`;
|
|
332
|
-
const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
333
|
-
|
|
334
|
-
// Take the first non-empty line
|
|
335
|
-
for (const line of result.split('\n')) {
|
|
336
|
-
const token = line.trim();
|
|
337
|
-
if (token && validateBase64urlToken(token)) return token;
|
|
338
|
-
}
|
|
339
|
-
} catch {}
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
317
|
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
|
318
|
+
// LevelDB fragments strings across byte boundaries, so we can't search
|
|
319
|
+
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
|
320
|
+
// search for a short prefix of the extension ID that reliably appears as
|
|
321
|
+
// contiguous bytes, then scan a window around each match for a base64url
|
|
322
|
+
// token value.
|
|
323
|
+
//
|
|
324
|
+
// Observed LevelDB layout near the auth-token entry:
|
|
325
|
+
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
|
326
|
+
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
|
327
|
+
//
|
|
328
|
+
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
|
344
329
|
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
345
|
-
const
|
|
330
|
+
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
346
331
|
|
|
347
332
|
let files: string[];
|
|
348
333
|
try {
|
|
@@ -351,7 +336,7 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
|
|
351
336
|
.map(f => path.join(dir, f));
|
|
352
337
|
} catch { return null; }
|
|
353
338
|
|
|
354
|
-
// Sort by mtime descending
|
|
339
|
+
// Sort by mtime descending so we find the freshest token first
|
|
355
340
|
files.sort((a, b) => {
|
|
356
341
|
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
|
357
342
|
});
|
|
@@ -360,14 +345,30 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
|
|
360
345
|
let data: Buffer;
|
|
361
346
|
try { data = fs.readFileSync(file); } catch { continue; }
|
|
362
347
|
|
|
363
|
-
// Quick check:
|
|
364
|
-
|
|
365
|
-
if (extPos === -1) continue;
|
|
366
|
-
const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
|
|
367
|
-
if (keyPos === -1) continue;
|
|
348
|
+
// Quick check: file must contain at least the prefix
|
|
349
|
+
if (data.indexOf(extIdPrefix) === -1) continue;
|
|
368
350
|
|
|
369
|
-
//
|
|
351
|
+
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
352
|
+
// for base64url tokens within a 500-byte window
|
|
370
353
|
let idx = 0;
|
|
354
|
+
while (true) {
|
|
355
|
+
const pos = data.indexOf(extIdPrefix, idx);
|
|
356
|
+
if (pos === -1) break;
|
|
357
|
+
|
|
358
|
+
const scanStart = pos;
|
|
359
|
+
const scanEnd = Math.min(data.length, pos + 500);
|
|
360
|
+
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
|
361
|
+
const m = window.match(tokenRe);
|
|
362
|
+
if (m && validateBase64urlToken(m[1])) {
|
|
363
|
+
// Make sure this isn't another extension ID that happens to match
|
|
364
|
+
if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
|
|
365
|
+
}
|
|
366
|
+
idx = pos + 1;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
|
370
|
+
const keyBuf = Buffer.from('auth-token');
|
|
371
|
+
idx = 0;
|
|
371
372
|
while (true) {
|
|
372
373
|
const kp = data.indexOf(keyBuf, idx);
|
|
373
374
|
if (kp === -1) break;
|
|
@@ -393,6 +394,69 @@ function validateBase64urlToken(token: string): boolean {
|
|
|
393
394
|
}
|
|
394
395
|
|
|
395
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
399
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
400
|
+
*/
|
|
401
|
+
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
|
|
402
|
+
const home = os.homedir();
|
|
403
|
+
const platform = os.platform();
|
|
404
|
+
const browserDirs: Array<{ name: string; base: string }> = [];
|
|
405
|
+
|
|
406
|
+
if (platform === 'darwin') {
|
|
407
|
+
browserDirs.push(
|
|
408
|
+
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
|
409
|
+
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
|
410
|
+
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
|
411
|
+
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
|
412
|
+
);
|
|
413
|
+
} else if (platform === 'linux') {
|
|
414
|
+
browserDirs.push(
|
|
415
|
+
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
|
416
|
+
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
|
417
|
+
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
|
418
|
+
);
|
|
419
|
+
} else if (platform === 'win32') {
|
|
420
|
+
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
421
|
+
browserDirs.push(
|
|
422
|
+
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
|
423
|
+
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
428
|
+
const foundBrowsers: string[] = [];
|
|
429
|
+
|
|
430
|
+
for (const { name, base } of browserDirs) {
|
|
431
|
+
for (const profile of profiles) {
|
|
432
|
+
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
|
433
|
+
if (fileExists(extDir)) {
|
|
434
|
+
foundBrowsers.push(name);
|
|
435
|
+
break; // one match per browser is enough
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
445
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
446
|
+
*/
|
|
447
|
+
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
|
448
|
+
const timeout = opts?.timeout ?? 8;
|
|
449
|
+
const start = Date.now();
|
|
450
|
+
try {
|
|
451
|
+
const mcp = new PlaywrightMCP();
|
|
452
|
+
await mcp.connect({ timeout });
|
|
453
|
+
await mcp.close();
|
|
454
|
+
return { ok: true, durationMs: Date.now() - start };
|
|
455
|
+
} catch (err: any) {
|
|
456
|
+
return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
396
460
|
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
397
461
|
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
398
462
|
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
@@ -418,24 +482,38 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
418
482
|
const uniqueTokens = [...new Set(allTokens)];
|
|
419
483
|
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
420
484
|
|
|
485
|
+
// Check extension installation
|
|
486
|
+
const extInstall = checkExtensionInstalled();
|
|
487
|
+
|
|
488
|
+
// Connectivity test (only when --live)
|
|
489
|
+
let connectivity: ConnectivityResult | undefined;
|
|
490
|
+
if (opts.live) {
|
|
491
|
+
connectivity = await checkTokenConnectivity();
|
|
492
|
+
}
|
|
493
|
+
|
|
421
494
|
const report: DoctorReport = {
|
|
422
495
|
cliVersion: opts.cliVersion,
|
|
423
496
|
envToken,
|
|
424
497
|
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
425
498
|
extensionToken,
|
|
426
499
|
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
500
|
+
extensionInstalled: extInstall.installed,
|
|
501
|
+
extensionBrowsers: extInstall.browsers,
|
|
427
502
|
shellFiles,
|
|
428
503
|
configs,
|
|
429
504
|
recommendedToken,
|
|
430
505
|
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
506
|
+
connectivity,
|
|
431
507
|
warnings: [],
|
|
432
508
|
issues: [],
|
|
433
509
|
};
|
|
434
510
|
|
|
511
|
+
if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
435
512
|
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
436
513
|
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
|
437
514
|
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
438
515
|
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
516
|
+
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
439
517
|
for (const config of configs) {
|
|
440
518
|
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
441
519
|
}
|
|
@@ -456,6 +534,12 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
456
534
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
457
535
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
458
536
|
|
|
537
|
+
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
538
|
+
const installDetail = report.extensionInstalled
|
|
539
|
+
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
540
|
+
: 'Extension not installed in any browser';
|
|
541
|
+
lines.push(statusLine(installStatus, installDetail));
|
|
542
|
+
|
|
459
543
|
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
460
544
|
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
461
545
|
|
|
@@ -489,6 +573,18 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
489
573
|
}
|
|
490
574
|
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
491
575
|
lines.push('');
|
|
576
|
+
|
|
577
|
+
// Connectivity result
|
|
578
|
+
if (report.connectivity) {
|
|
579
|
+
const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
|
580
|
+
const connDetail = report.connectivity.ok
|
|
581
|
+
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
582
|
+
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
|
583
|
+
lines.push(statusLine(connStatus, connDetail));
|
|
584
|
+
} else {
|
|
585
|
+
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
|
586
|
+
}
|
|
587
|
+
|
|
492
588
|
lines.push(statusLine(
|
|
493
589
|
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
|
494
590
|
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
package/src/main.ts
CHANGED
|
@@ -100,12 +100,13 @@ program.command('doctor')
|
|
|
100
100
|
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
|
101
101
|
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
|
102
102
|
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
|
103
|
+
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
|
103
104
|
.option('--shell-rc <path>', 'Shell startup file to update')
|
|
104
105
|
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
|
105
106
|
.action(async (opts) => {
|
|
106
107
|
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
|
107
108
|
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
|
|
108
|
-
const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
109
|
+
const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
109
110
|
console.log(renderBrowserDoctorReport(report));
|
|
110
111
|
if (opts.fix) {
|
|
111
112
|
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|