@jackwener/opencli 0.7.3 → 0.7.5
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 +38 -32
- package/README.zh-CN.md +38 -31
- package/SKILL.md +9 -5
- package/dist/doctor.d.ts +31 -4
- package/dist/doctor.js +183 -69
- package/dist/doctor.test.js +83 -1
- package/dist/main.js +2 -1
- package/dist/setup.js +35 -6
- package/package.json +1 -1
- package/src/doctor.test.ts +94 -1
- package/src/doctor.ts +209 -66
- package/src/main.ts +2 -1
- package/src/setup.ts +35 -5
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';
|
|
@@ -61,6 +60,13 @@ export function getDefaultShellRcPath() {
|
|
|
61
60
|
return path.join(os.homedir(), '.config', 'fish', 'config.fish');
|
|
62
61
|
return path.join(os.homedir(), '.zshrc');
|
|
63
62
|
}
|
|
63
|
+
function isFishConfig(filePath) {
|
|
64
|
+
return filePath.endsWith('config.fish') || filePath.includes('/fish/');
|
|
65
|
+
}
|
|
66
|
+
/** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
|
|
67
|
+
function isOpenCodeConfig(filePath) {
|
|
68
|
+
return filePath.includes('opencode');
|
|
69
|
+
}
|
|
64
70
|
export function getDefaultMcpConfigPaths(cwd = process.cwd()) {
|
|
65
71
|
const home = os.homedir();
|
|
66
72
|
const candidates = [
|
|
@@ -84,7 +90,17 @@ export function readTokenFromShellContent(content) {
|
|
|
84
90
|
const m = content.match(TOKEN_LINE_RE);
|
|
85
91
|
return m?.[3] ?? null;
|
|
86
92
|
}
|
|
87
|
-
export function upsertShellToken(content, token) {
|
|
93
|
+
export function upsertShellToken(content, token, filePath) {
|
|
94
|
+
if (filePath && isFishConfig(filePath)) {
|
|
95
|
+
// Fish shell uses `set -gx` instead of `export`
|
|
96
|
+
const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
|
|
97
|
+
const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
|
|
98
|
+
if (!content.trim())
|
|
99
|
+
return `${fishLine}\n`;
|
|
100
|
+
if (fishRe.test(content))
|
|
101
|
+
return content.replace(fishRe, fishLine);
|
|
102
|
+
return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
|
|
103
|
+
}
|
|
88
104
|
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
|
89
105
|
if (!content.trim())
|
|
90
106
|
return `${nextLine}\n`;
|
|
@@ -105,30 +121,36 @@ function readTokenFromJsonObject(parsed) {
|
|
|
105
121
|
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
106
122
|
if (typeof direct === 'string' && direct)
|
|
107
123
|
return direct;
|
|
108
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.
|
|
124
|
+
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
109
125
|
if (typeof opencode === 'string' && opencode)
|
|
110
126
|
return opencode;
|
|
111
127
|
return null;
|
|
112
128
|
}
|
|
113
|
-
export function upsertJsonConfigToken(content, token) {
|
|
129
|
+
export function upsertJsonConfigToken(content, token, filePath) {
|
|
114
130
|
const parsed = content.trim() ? JSON.parse(content) : {};
|
|
115
|
-
if
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
131
|
+
// Determine format: use OpenCode format only if explicitly an opencode config,
|
|
132
|
+
// or if the existing content already uses `mcp` key (not `mcpServers`)
|
|
133
|
+
const useOpenCodeFormat = filePath
|
|
134
|
+
? isOpenCodeConfig(filePath)
|
|
135
|
+
: (!parsed.mcpServers && parsed.mcp);
|
|
136
|
+
if (useOpenCodeFormat) {
|
|
124
137
|
parsed.mcp = parsed.mcp ?? {};
|
|
125
138
|
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
126
139
|
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
|
127
140
|
enabled: true,
|
|
128
141
|
type: 'local',
|
|
129
142
|
};
|
|
130
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
131
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
143
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
|
|
144
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
parsed.mcpServers = parsed.mcpServers ?? {};
|
|
148
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
149
|
+
command: 'npx',
|
|
150
|
+
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
|
151
|
+
};
|
|
152
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
|
153
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
132
154
|
}
|
|
133
155
|
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
134
156
|
}
|
|
@@ -207,12 +229,37 @@ function readConfigStatus(filePath) {
|
|
|
207
229
|
};
|
|
208
230
|
}
|
|
209
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
|
|
234
|
+
* directories across all browser base paths. Falls back to ['Default'] if none found.
|
|
235
|
+
*/
|
|
236
|
+
function enumerateProfiles(baseDirs) {
|
|
237
|
+
const profiles = new Set();
|
|
238
|
+
for (const base of baseDirs) {
|
|
239
|
+
if (!fileExists(base))
|
|
240
|
+
continue;
|
|
241
|
+
try {
|
|
242
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
243
|
+
if (!entry.isDirectory())
|
|
244
|
+
continue;
|
|
245
|
+
if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
|
|
246
|
+
profiles.add(entry.name);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch { /* permission denied, etc. */ }
|
|
251
|
+
}
|
|
252
|
+
return profiles.size > 0 ? [...profiles].sort() : ['Default'];
|
|
253
|
+
}
|
|
210
254
|
/**
|
|
211
255
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
212
256
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
213
257
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
258
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
259
|
+
* extension ID near base64url token values. This works reliably across
|
|
260
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
261
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
262
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
216
263
|
*/
|
|
217
264
|
export function discoverExtensionToken() {
|
|
218
265
|
const home = os.homedir();
|
|
@@ -228,22 +275,13 @@ export function discoverExtensionToken() {
|
|
|
228
275
|
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
229
276
|
bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
|
|
230
277
|
}
|
|
231
|
-
const profiles =
|
|
232
|
-
// Token is 43 chars of base64url (from 32 random bytes)
|
|
278
|
+
const profiles = enumerateProfiles(bases);
|
|
233
279
|
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
234
280
|
for (const base of bases) {
|
|
235
281
|
for (const profile of profiles) {
|
|
236
282
|
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
237
283
|
if (!fileExists(dir))
|
|
238
284
|
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
285
|
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
248
286
|
if (token)
|
|
249
287
|
return token;
|
|
@@ -251,39 +289,20 @@ export function discoverExtensionToken() {
|
|
|
251
289
|
}
|
|
252
290
|
return null;
|
|
253
291
|
}
|
|
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
292
|
function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
293
|
+
// LevelDB fragments strings across byte boundaries, so we can't search
|
|
294
|
+
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
|
295
|
+
// search for a short prefix of the extension ID that reliably appears as
|
|
296
|
+
// contiguous bytes, then scan a window around each match for a base64url
|
|
297
|
+
// token value.
|
|
298
|
+
//
|
|
299
|
+
// Observed LevelDB layout near the auth-token entry:
|
|
300
|
+
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
|
301
|
+
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
|
302
|
+
//
|
|
303
|
+
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
|
285
304
|
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
286
|
-
const
|
|
305
|
+
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
287
306
|
let files;
|
|
288
307
|
try {
|
|
289
308
|
files = fs.readdirSync(dir)
|
|
@@ -293,7 +312,7 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
|
293
312
|
catch {
|
|
294
313
|
return null;
|
|
295
314
|
}
|
|
296
|
-
// Sort by mtime descending
|
|
315
|
+
// Sort by mtime descending so we find the freshest token first
|
|
297
316
|
files.sort((a, b) => {
|
|
298
317
|
try {
|
|
299
318
|
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
|
|
@@ -310,15 +329,30 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
|
310
329
|
catch {
|
|
311
330
|
continue;
|
|
312
331
|
}
|
|
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)
|
|
332
|
+
// Quick check: file must contain at least the prefix
|
|
333
|
+
if (data.indexOf(extIdPrefix) === -1)
|
|
319
334
|
continue;
|
|
320
|
-
//
|
|
335
|
+
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
336
|
+
// for base64url tokens within a 500-byte window
|
|
321
337
|
let idx = 0;
|
|
338
|
+
while (true) {
|
|
339
|
+
const pos = data.indexOf(extIdPrefix, idx);
|
|
340
|
+
if (pos === -1)
|
|
341
|
+
break;
|
|
342
|
+
const scanStart = pos;
|
|
343
|
+
const scanEnd = Math.min(data.length, pos + 500);
|
|
344
|
+
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
|
345
|
+
const m = window.match(tokenRe);
|
|
346
|
+
if (m && validateBase64urlToken(m[1])) {
|
|
347
|
+
// Make sure this isn't another extension ID that happens to match
|
|
348
|
+
if (m[1] !== PLAYWRIGHT_EXTENSION_ID)
|
|
349
|
+
return m[1];
|
|
350
|
+
}
|
|
351
|
+
idx = pos + 1;
|
|
352
|
+
}
|
|
353
|
+
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
|
354
|
+
const keyBuf = Buffer.from('auth-token');
|
|
355
|
+
idx = 0;
|
|
322
356
|
while (true) {
|
|
323
357
|
const kp = data.indexOf(keyBuf, idx);
|
|
324
358
|
if (kp === -1)
|
|
@@ -345,6 +379,54 @@ function validateBase64urlToken(token) {
|
|
|
345
379
|
return false;
|
|
346
380
|
}
|
|
347
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
384
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
385
|
+
*/
|
|
386
|
+
export function checkExtensionInstalled() {
|
|
387
|
+
const home = os.homedir();
|
|
388
|
+
const platform = os.platform();
|
|
389
|
+
const browserDirs = [];
|
|
390
|
+
if (platform === 'darwin') {
|
|
391
|
+
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') });
|
|
392
|
+
}
|
|
393
|
+
else if (platform === 'linux') {
|
|
394
|
+
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') });
|
|
395
|
+
}
|
|
396
|
+
else if (platform === 'win32') {
|
|
397
|
+
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
398
|
+
browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
|
|
399
|
+
}
|
|
400
|
+
const profiles = enumerateProfiles(browserDirs.map(d => d.base));
|
|
401
|
+
const foundBrowsers = [];
|
|
402
|
+
for (const { name, base } of browserDirs) {
|
|
403
|
+
for (const profile of profiles) {
|
|
404
|
+
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
|
405
|
+
if (fileExists(extDir)) {
|
|
406
|
+
foundBrowsers.push(name);
|
|
407
|
+
break; // one match per browser is enough
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
415
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
416
|
+
*/
|
|
417
|
+
export async function checkTokenConnectivity(opts) {
|
|
418
|
+
const timeout = opts?.timeout ?? 8;
|
|
419
|
+
const start = Date.now();
|
|
420
|
+
try {
|
|
421
|
+
const mcp = new PlaywrightMCP();
|
|
422
|
+
await mcp.connect({ timeout });
|
|
423
|
+
await mcp.close();
|
|
424
|
+
return { ok: true, durationMs: Date.now() - start };
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
348
430
|
export async function runBrowserDoctor(opts = {}) {
|
|
349
431
|
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
350
432
|
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
@@ -368,19 +450,31 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
368
450
|
].filter((v) => !!v);
|
|
369
451
|
const uniqueTokens = [...new Set(allTokens)];
|
|
370
452
|
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
453
|
+
// Check extension installation
|
|
454
|
+
const extInstall = checkExtensionInstalled();
|
|
455
|
+
// Connectivity test (only when --live)
|
|
456
|
+
let connectivity;
|
|
457
|
+
if (opts.live) {
|
|
458
|
+
connectivity = await checkTokenConnectivity();
|
|
459
|
+
}
|
|
371
460
|
const report = {
|
|
372
461
|
cliVersion: opts.cliVersion,
|
|
373
462
|
envToken,
|
|
374
463
|
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
375
464
|
extensionToken,
|
|
376
465
|
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
466
|
+
extensionInstalled: extInstall.installed,
|
|
467
|
+
extensionBrowsers: extInstall.browsers,
|
|
377
468
|
shellFiles,
|
|
378
469
|
configs,
|
|
379
470
|
recommendedToken,
|
|
380
471
|
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
472
|
+
connectivity,
|
|
381
473
|
warnings: [],
|
|
382
474
|
issues: [],
|
|
383
475
|
};
|
|
476
|
+
if (!extInstall.installed)
|
|
477
|
+
report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
384
478
|
if (!envToken)
|
|
385
479
|
report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
386
480
|
if (!shellFiles.some(s => s.token))
|
|
@@ -389,6 +483,8 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
389
483
|
report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
390
484
|
if (uniqueTokens.length > 1)
|
|
391
485
|
report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
486
|
+
if (connectivity && !connectivity.ok)
|
|
487
|
+
report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
392
488
|
for (const config of configs) {
|
|
393
489
|
if (config.parseError)
|
|
394
490
|
report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
@@ -408,6 +504,11 @@ export function renderBrowserDoctorReport(report) {
|
|
|
408
504
|
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
409
505
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
410
506
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
507
|
+
const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
508
|
+
const installDetail = report.extensionInstalled
|
|
509
|
+
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
510
|
+
: 'Extension not installed in any browser';
|
|
511
|
+
lines.push(statusLine(installStatus, installDetail));
|
|
411
512
|
const extStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
412
513
|
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
413
514
|
const envStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
@@ -441,6 +542,17 @@ export function renderBrowserDoctorReport(report) {
|
|
|
441
542
|
if (missingConfigCount > 0)
|
|
442
543
|
lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
443
544
|
lines.push('');
|
|
545
|
+
// Connectivity result
|
|
546
|
+
if (report.connectivity) {
|
|
547
|
+
const connStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
|
548
|
+
const connDetail = report.connectivity.ok
|
|
549
|
+
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
550
|
+
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
|
551
|
+
lines.push(statusLine(connStatus, connDetail));
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
|
555
|
+
}
|
|
444
556
|
lines.push(statusLine(hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`));
|
|
445
557
|
if (report.issues.length) {
|
|
446
558
|
lines.push('', chalk.yellow('Issues:'));
|
|
@@ -497,7 +609,7 @@ export async function applyBrowserDoctorFix(report, opts = {}) {
|
|
|
497
609
|
const written = [];
|
|
498
610
|
if (plannedWrites.includes(shellPath)) {
|
|
499
611
|
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
|
500
|
-
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
|
|
612
|
+
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
|
|
501
613
|
written.push(shellPath);
|
|
502
614
|
}
|
|
503
615
|
for (const config of report.configs) {
|
|
@@ -506,7 +618,9 @@ export async function applyBrowserDoctorFix(report, opts = {}) {
|
|
|
506
618
|
if (config.parseError)
|
|
507
619
|
continue;
|
|
508
620
|
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
|
509
|
-
const next = config.format === 'toml'
|
|
621
|
+
const next = config.format === 'toml'
|
|
622
|
+
? upsertTomlConfigToken(before, token)
|
|
623
|
+
: upsertJsonConfigToken(before, token, config.path);
|
|
510
624
|
writeFileWithMkdir(config.path, next);
|
|
511
625
|
written.push(config.path);
|
|
512
626
|
}
|
package/dist/doctor.test.js
CHANGED
|
@@ -66,7 +66,48 @@ 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
|
+
});
|
|
71
|
+
it('creates standard mcpServers format for empty file (not OpenCode)', () => {
|
|
72
|
+
const next = upsertJsonConfigToken('', 'abc123');
|
|
73
|
+
const parsed = JSON.parse(next);
|
|
74
|
+
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
75
|
+
expect(parsed.mcp).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
it('creates OpenCode format when filePath contains opencode', () => {
|
|
78
|
+
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
|
|
79
|
+
const parsed = JSON.parse(next);
|
|
80
|
+
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
81
|
+
expect(parsed.mcpServers).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
it('creates standard format when filePath is claude.json', () => {
|
|
84
|
+
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
|
|
85
|
+
const parsed = JSON.parse(next);
|
|
86
|
+
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('fish shell support', () => {
|
|
90
|
+
it('generates fish set -gx syntax for fish config path', () => {
|
|
91
|
+
const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
|
|
92
|
+
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
|
93
|
+
expect(next).not.toContain('export');
|
|
94
|
+
});
|
|
95
|
+
it('replaces existing fish set line', () => {
|
|
96
|
+
const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
|
|
97
|
+
const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
|
|
98
|
+
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
|
|
99
|
+
expect(next).not.toContain('"old"');
|
|
100
|
+
});
|
|
101
|
+
it('appends fish syntax to existing fish config', () => {
|
|
102
|
+
const content = 'set -gx PATH /usr/bin\n';
|
|
103
|
+
const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
|
|
104
|
+
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
|
105
|
+
expect(next).toContain('set -gx PATH /usr/bin');
|
|
106
|
+
});
|
|
107
|
+
it('uses export syntax for zshrc even with filePath', () => {
|
|
108
|
+
const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
|
|
109
|
+
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
|
110
|
+
expect(next).not.toContain('set -gx');
|
|
70
111
|
});
|
|
71
112
|
});
|
|
72
113
|
describe('doctor report rendering', () => {
|
|
@@ -77,6 +118,8 @@ describe('doctor report rendering', () => {
|
|
|
77
118
|
envFingerprint: 'fp1',
|
|
78
119
|
extensionToken: 'abc123',
|
|
79
120
|
extensionFingerprint: 'fp1',
|
|
121
|
+
extensionInstalled: true,
|
|
122
|
+
extensionBrowsers: ['Chrome'],
|
|
80
123
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
81
124
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
82
125
|
recommendedToken: 'abc123',
|
|
@@ -84,6 +127,7 @@ describe('doctor report rendering', () => {
|
|
|
84
127
|
warnings: [],
|
|
85
128
|
issues: [],
|
|
86
129
|
}));
|
|
130
|
+
expect(text).toContain('[OK] Extension installed (Chrome)');
|
|
87
131
|
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
|
88
132
|
expect(text).toContain('[OK] /tmp/mcp.json');
|
|
89
133
|
expect(text).toContain('configured (fp1)');
|
|
@@ -94,6 +138,8 @@ describe('doctor report rendering', () => {
|
|
|
94
138
|
envFingerprint: 'fp1',
|
|
95
139
|
extensionToken: null,
|
|
96
140
|
extensionFingerprint: null,
|
|
141
|
+
extensionInstalled: false,
|
|
142
|
+
extensionBrowsers: [],
|
|
97
143
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
98
144
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
99
145
|
recommendedToken: 'abc123',
|
|
@@ -101,9 +147,45 @@ describe('doctor report rendering', () => {
|
|
|
101
147
|
warnings: [],
|
|
102
148
|
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
103
149
|
}));
|
|
150
|
+
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
|
104
151
|
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
|
105
152
|
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
|
106
153
|
expect(text).toContain('configured (fp2)');
|
|
107
154
|
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
108
155
|
});
|
|
156
|
+
it('renders connectivity OK when live test succeeds', () => {
|
|
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
|
+
connectivity: { ok: true, durationMs: 1234 },
|
|
169
|
+
warnings: [],
|
|
170
|
+
issues: [],
|
|
171
|
+
}));
|
|
172
|
+
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
|
173
|
+
});
|
|
174
|
+
it('renders connectivity WARN when not tested', () => {
|
|
175
|
+
const text = strip(renderBrowserDoctorReport({
|
|
176
|
+
envToken: 'abc123',
|
|
177
|
+
envFingerprint: 'fp1',
|
|
178
|
+
extensionToken: 'abc123',
|
|
179
|
+
extensionFingerprint: 'fp1',
|
|
180
|
+
extensionInstalled: true,
|
|
181
|
+
extensionBrowsers: ['Chrome'],
|
|
182
|
+
shellFiles: [],
|
|
183
|
+
configs: [],
|
|
184
|
+
recommendedToken: 'abc123',
|
|
185
|
+
recommendedFingerprint: 'fp1',
|
|
186
|
+
warnings: [],
|
|
187
|
+
issues: [],
|
|
188
|
+
}));
|
|
189
|
+
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
|
190
|
+
});
|
|
109
191
|
});
|
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/dist/setup.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { createInterface } from 'node:readline/promises';
|
|
10
10
|
import { stdin as input, stdout as output } from 'node:process';
|
|
11
|
-
import { PLAYWRIGHT_TOKEN_ENV, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
|
|
11
|
+
import { PLAYWRIGHT_TOKEN_ENV, checkExtensionInstalled, checkTokenConnectivity, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
|
|
12
12
|
import { getTokenFingerprint } from './browser.js';
|
|
13
13
|
import { checkboxPrompt } from './tui.js';
|
|
14
14
|
export async function runSetup(opts = {}) {
|
|
@@ -45,11 +45,24 @@ export async function runSetup(opts = {}) {
|
|
|
45
45
|
chalk.dim(`(${getTokenFingerprint(token)})`));
|
|
46
46
|
}
|
|
47
47
|
if (!token) {
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Give precise diagnosis of why token scan failed
|
|
49
|
+
const extInstall = checkExtensionInstalled();
|
|
50
|
+
console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
|
|
51
|
+
if (!extInstall.installed) {
|
|
52
|
+
console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
|
|
53
|
+
console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
|
|
54
|
+
console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
|
|
58
|
+
console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
|
|
59
|
+
console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
|
|
60
|
+
}
|
|
61
|
+
console.log();
|
|
62
|
+
console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
|
|
50
63
|
console.log();
|
|
51
64
|
const rl = createInterface({ input, output });
|
|
52
|
-
const answer = await rl.question(' Token: ');
|
|
65
|
+
const answer = await rl.question(' Token (press Enter to abort): ');
|
|
53
66
|
rl.close();
|
|
54
67
|
token = answer.trim();
|
|
55
68
|
if (!token) {
|
|
@@ -105,7 +118,7 @@ export async function runSetup(opts = {}) {
|
|
|
105
118
|
if (sel.startsWith('shell:')) {
|
|
106
119
|
const p = sel.slice('shell:'.length);
|
|
107
120
|
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
108
|
-
writeFileWithMkdir(p, upsertShellToken(before, token));
|
|
121
|
+
writeFileWithMkdir(p, upsertShellToken(before, token, p));
|
|
109
122
|
written.push(p);
|
|
110
123
|
wroteShell = true;
|
|
111
124
|
}
|
|
@@ -116,7 +129,7 @@ export async function runSetup(opts = {}) {
|
|
|
116
129
|
continue;
|
|
117
130
|
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
118
131
|
const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
|
|
119
|
-
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
|
132
|
+
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
|
|
120
133
|
writeFileWithMkdir(p, next);
|
|
121
134
|
written.push(p);
|
|
122
135
|
}
|
|
@@ -138,6 +151,22 @@ export async function runSetup(opts = {}) {
|
|
|
138
151
|
console.log(chalk.yellow(' No files were changed.'));
|
|
139
152
|
}
|
|
140
153
|
console.log();
|
|
154
|
+
// Step 7: Auto-verify browser connectivity
|
|
155
|
+
console.log(chalk.dim(' Verifying browser connectivity...'));
|
|
156
|
+
try {
|
|
157
|
+
const result = await checkTokenConnectivity({ timeout: 5 });
|
|
158
|
+
if (result.ok) {
|
|
159
|
+
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
|
|
163
|
+
console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
|
|
168
|
+
}
|
|
169
|
+
console.log();
|
|
141
170
|
}
|
|
142
171
|
function padRight(s, n) {
|
|
143
172
|
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|