@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/src/doctor.test.ts
CHANGED
|
@@ -81,7 +81,55 @@ 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
|
+
});
|
|
86
|
+
|
|
87
|
+
it('creates standard mcpServers format for empty file (not OpenCode)', () => {
|
|
88
|
+
const next = upsertJsonConfigToken('', 'abc123');
|
|
89
|
+
const parsed = JSON.parse(next);
|
|
90
|
+
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
91
|
+
expect(parsed.mcp).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('creates OpenCode format when filePath contains opencode', () => {
|
|
95
|
+
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
|
|
96
|
+
const parsed = JSON.parse(next);
|
|
97
|
+
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
98
|
+
expect(parsed.mcpServers).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('creates standard format when filePath is claude.json', () => {
|
|
102
|
+
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
|
|
103
|
+
const parsed = JSON.parse(next);
|
|
104
|
+
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('fish shell support', () => {
|
|
109
|
+
it('generates fish set -gx syntax for fish config path', () => {
|
|
110
|
+
const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
|
|
111
|
+
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
|
112
|
+
expect(next).not.toContain('export');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('replaces existing fish set line', () => {
|
|
116
|
+
const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
|
|
117
|
+
const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
|
|
118
|
+
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
|
|
119
|
+
expect(next).not.toContain('"old"');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('appends fish syntax to existing fish config', () => {
|
|
123
|
+
const content = 'set -gx PATH /usr/bin\n';
|
|
124
|
+
const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
|
|
125
|
+
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
|
126
|
+
expect(next).toContain('set -gx PATH /usr/bin');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('uses export syntax for zshrc even with filePath', () => {
|
|
130
|
+
const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
|
|
131
|
+
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
|
132
|
+
expect(next).not.toContain('set -gx');
|
|
85
133
|
});
|
|
86
134
|
});
|
|
87
135
|
|
|
@@ -94,6 +142,8 @@ describe('doctor report rendering', () => {
|
|
|
94
142
|
envFingerprint: 'fp1',
|
|
95
143
|
extensionToken: 'abc123',
|
|
96
144
|
extensionFingerprint: 'fp1',
|
|
145
|
+
extensionInstalled: true,
|
|
146
|
+
extensionBrowsers: ['Chrome'],
|
|
97
147
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
98
148
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
99
149
|
recommendedToken: 'abc123',
|
|
@@ -102,6 +152,7 @@ describe('doctor report rendering', () => {
|
|
|
102
152
|
issues: [],
|
|
103
153
|
}));
|
|
104
154
|
|
|
155
|
+
expect(text).toContain('[OK] Extension installed (Chrome)');
|
|
105
156
|
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
|
106
157
|
expect(text).toContain('[OK] /tmp/mcp.json');
|
|
107
158
|
expect(text).toContain('configured (fp1)');
|
|
@@ -113,6 +164,8 @@ describe('doctor report rendering', () => {
|
|
|
113
164
|
envFingerprint: 'fp1',
|
|
114
165
|
extensionToken: null,
|
|
115
166
|
extensionFingerprint: null,
|
|
167
|
+
extensionInstalled: false,
|
|
168
|
+
extensionBrowsers: [],
|
|
116
169
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
117
170
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
118
171
|
recommendedToken: 'abc123',
|
|
@@ -121,10 +174,50 @@ describe('doctor report rendering', () => {
|
|
|
121
174
|
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
122
175
|
}));
|
|
123
176
|
|
|
177
|
+
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
|
124
178
|
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
|
125
179
|
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
|
126
180
|
expect(text).toContain('configured (fp2)');
|
|
127
181
|
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
128
182
|
});
|
|
183
|
+
|
|
184
|
+
it('renders connectivity OK when live test succeeds', () => {
|
|
185
|
+
const text = strip(renderBrowserDoctorReport({
|
|
186
|
+
envToken: 'abc123',
|
|
187
|
+
envFingerprint: 'fp1',
|
|
188
|
+
extensionToken: 'abc123',
|
|
189
|
+
extensionFingerprint: 'fp1',
|
|
190
|
+
extensionInstalled: true,
|
|
191
|
+
extensionBrowsers: ['Chrome'],
|
|
192
|
+
shellFiles: [],
|
|
193
|
+
configs: [],
|
|
194
|
+
recommendedToken: 'abc123',
|
|
195
|
+
recommendedFingerprint: 'fp1',
|
|
196
|
+
connectivity: { ok: true, durationMs: 1234 },
|
|
197
|
+
warnings: [],
|
|
198
|
+
issues: [],
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('renders connectivity WARN when not tested', () => {
|
|
205
|
+
const text = strip(renderBrowserDoctorReport({
|
|
206
|
+
envToken: 'abc123',
|
|
207
|
+
envFingerprint: 'fp1',
|
|
208
|
+
extensionToken: 'abc123',
|
|
209
|
+
extensionFingerprint: 'fp1',
|
|
210
|
+
extensionInstalled: true,
|
|
211
|
+
extensionBrowsers: ['Chrome'],
|
|
212
|
+
shellFiles: [],
|
|
213
|
+
configs: [],
|
|
214
|
+
recommendedToken: 'abc123',
|
|
215
|
+
recommendedFingerprint: 'fp1',
|
|
216
|
+
warnings: [],
|
|
217
|
+
issues: [],
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
|
221
|
+
});
|
|
129
222
|
});
|
|
130
223
|
|
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
|
};
|
|
@@ -101,6 +111,15 @@ export function getDefaultShellRcPath(): string {
|
|
|
101
111
|
return path.join(os.homedir(), '.zshrc');
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
function isFishConfig(filePath: string): boolean {
|
|
115
|
+
return filePath.endsWith('config.fish') || filePath.includes('/fish/');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
|
|
119
|
+
function isOpenCodeConfig(filePath: string): boolean {
|
|
120
|
+
return filePath.includes('opencode');
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
|
|
105
124
|
const home = os.homedir();
|
|
106
125
|
const candidates = [
|
|
@@ -126,7 +145,15 @@ export function readTokenFromShellContent(content: string): string | null {
|
|
|
126
145
|
return m?.[3] ?? null;
|
|
127
146
|
}
|
|
128
147
|
|
|
129
|
-
export function upsertShellToken(content: string, token: string): string {
|
|
148
|
+
export function upsertShellToken(content: string, token: string, filePath?: string): string {
|
|
149
|
+
if (filePath && isFishConfig(filePath)) {
|
|
150
|
+
// Fish shell uses `set -gx` instead of `export`
|
|
151
|
+
const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
|
|
152
|
+
const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
|
|
153
|
+
if (!content.trim()) return `${fishLine}\n`;
|
|
154
|
+
if (fishRe.test(content)) return content.replace(fishRe, fishLine);
|
|
155
|
+
return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
|
|
156
|
+
}
|
|
130
157
|
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
|
131
158
|
if (!content.trim()) return `${nextLine}\n`;
|
|
132
159
|
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
|
|
@@ -147,29 +174,37 @@ function readJsonConfigToken(content: string): string | null {
|
|
|
147
174
|
function readTokenFromJsonObject(parsed: any): string | null {
|
|
148
175
|
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
149
176
|
if (typeof direct === 'string' && direct) return direct;
|
|
150
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.
|
|
177
|
+
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
151
178
|
if (typeof opencode === 'string' && opencode) return opencode;
|
|
152
179
|
return null;
|
|
153
180
|
}
|
|
154
181
|
|
|
155
|
-
export function upsertJsonConfigToken(content: string, token: string): string {
|
|
182
|
+
export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
|
|
156
183
|
const parsed = content.trim() ? JSON.parse(content) : {};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
parsed.mcpServers
|
|
163
|
-
|
|
164
|
-
|
|
184
|
+
|
|
185
|
+
// Determine format: use OpenCode format only if explicitly an opencode config,
|
|
186
|
+
// or if the existing content already uses `mcp` key (not `mcpServers`)
|
|
187
|
+
const useOpenCodeFormat = filePath
|
|
188
|
+
? isOpenCodeConfig(filePath)
|
|
189
|
+
: (!parsed.mcpServers && parsed.mcp);
|
|
190
|
+
|
|
191
|
+
if (useOpenCodeFormat) {
|
|
165
192
|
parsed.mcp = parsed.mcp ?? {};
|
|
166
193
|
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
167
194
|
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
|
168
195
|
enabled: true,
|
|
169
196
|
type: 'local',
|
|
170
197
|
};
|
|
171
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
172
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
198
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
|
|
199
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
200
|
+
} else {
|
|
201
|
+
parsed.mcpServers = parsed.mcpServers ?? {};
|
|
202
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
203
|
+
command: 'npx',
|
|
204
|
+
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
|
205
|
+
};
|
|
206
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
|
207
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
173
208
|
}
|
|
174
209
|
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
175
210
|
}
|
|
@@ -252,12 +287,35 @@ function readConfigStatus(filePath: string): McpConfigStatus {
|
|
|
252
287
|
}
|
|
253
288
|
}
|
|
254
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
|
|
292
|
+
* directories across all browser base paths. Falls back to ['Default'] if none found.
|
|
293
|
+
*/
|
|
294
|
+
function enumerateProfiles(baseDirs: string[]): string[] {
|
|
295
|
+
const profiles = new Set<string>();
|
|
296
|
+
for (const base of baseDirs) {
|
|
297
|
+
if (!fileExists(base)) continue;
|
|
298
|
+
try {
|
|
299
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
300
|
+
if (!entry.isDirectory()) continue;
|
|
301
|
+
if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
|
|
302
|
+
profiles.add(entry.name);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch { /* permission denied, etc. */ }
|
|
306
|
+
}
|
|
307
|
+
return profiles.size > 0 ? [...profiles].sort() : ['Default'];
|
|
308
|
+
}
|
|
309
|
+
|
|
255
310
|
/**
|
|
256
311
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
257
312
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
258
313
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
314
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
315
|
+
* extension ID near base64url token values. This works reliably across
|
|
316
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
317
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
318
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
261
319
|
*/
|
|
262
320
|
export function discoverExtensionToken(): string | null {
|
|
263
321
|
const home = os.homedir();
|
|
@@ -285,8 +343,7 @@ export function discoverExtensionToken(): string | null {
|
|
|
285
343
|
);
|
|
286
344
|
}
|
|
287
345
|
|
|
288
|
-
const profiles =
|
|
289
|
-
// Token is 43 chars of base64url (from 32 random bytes)
|
|
346
|
+
const profiles = enumerateProfiles(bases);
|
|
290
347
|
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
291
348
|
|
|
292
349
|
for (const base of bases) {
|
|
@@ -294,14 +351,6 @@ export function discoverExtensionToken(): string | null {
|
|
|
294
351
|
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
295
352
|
if (!fileExists(dir)) continue;
|
|
296
353
|
|
|
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
354
|
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
306
355
|
if (token) return token;
|
|
307
356
|
}
|
|
@@ -310,39 +359,20 @@ export function discoverExtensionToken(): string | null {
|
|
|
310
359
|
return null;
|
|
311
360
|
}
|
|
312
361
|
|
|
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
362
|
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
|
363
|
+
// LevelDB fragments strings across byte boundaries, so we can't search
|
|
364
|
+
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
|
365
|
+
// search for a short prefix of the extension ID that reliably appears as
|
|
366
|
+
// contiguous bytes, then scan a window around each match for a base64url
|
|
367
|
+
// token value.
|
|
368
|
+
//
|
|
369
|
+
// Observed LevelDB layout near the auth-token entry:
|
|
370
|
+
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
|
371
|
+
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
|
372
|
+
//
|
|
373
|
+
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
|
344
374
|
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
345
|
-
const
|
|
375
|
+
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
346
376
|
|
|
347
377
|
let files: string[];
|
|
348
378
|
try {
|
|
@@ -351,7 +381,7 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
|
|
351
381
|
.map(f => path.join(dir, f));
|
|
352
382
|
} catch { return null; }
|
|
353
383
|
|
|
354
|
-
// Sort by mtime descending
|
|
384
|
+
// Sort by mtime descending so we find the freshest token first
|
|
355
385
|
files.sort((a, b) => {
|
|
356
386
|
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
|
357
387
|
});
|
|
@@ -360,14 +390,30 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
|
|
360
390
|
let data: Buffer;
|
|
361
391
|
try { data = fs.readFileSync(file); } catch { continue; }
|
|
362
392
|
|
|
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;
|
|
393
|
+
// Quick check: file must contain at least the prefix
|
|
394
|
+
if (data.indexOf(extIdPrefix) === -1) continue;
|
|
368
395
|
|
|
369
|
-
//
|
|
396
|
+
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
397
|
+
// for base64url tokens within a 500-byte window
|
|
370
398
|
let idx = 0;
|
|
399
|
+
while (true) {
|
|
400
|
+
const pos = data.indexOf(extIdPrefix, idx);
|
|
401
|
+
if (pos === -1) break;
|
|
402
|
+
|
|
403
|
+
const scanStart = pos;
|
|
404
|
+
const scanEnd = Math.min(data.length, pos + 500);
|
|
405
|
+
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
|
406
|
+
const m = window.match(tokenRe);
|
|
407
|
+
if (m && validateBase64urlToken(m[1])) {
|
|
408
|
+
// Make sure this isn't another extension ID that happens to match
|
|
409
|
+
if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
|
|
410
|
+
}
|
|
411
|
+
idx = pos + 1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
|
415
|
+
const keyBuf = Buffer.from('auth-token');
|
|
416
|
+
idx = 0;
|
|
371
417
|
while (true) {
|
|
372
418
|
const kp = data.indexOf(keyBuf, idx);
|
|
373
419
|
if (kp === -1) break;
|
|
@@ -393,6 +439,69 @@ function validateBase64urlToken(token: string): boolean {
|
|
|
393
439
|
}
|
|
394
440
|
|
|
395
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
444
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
445
|
+
*/
|
|
446
|
+
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
|
|
447
|
+
const home = os.homedir();
|
|
448
|
+
const platform = os.platform();
|
|
449
|
+
const browserDirs: Array<{ name: string; base: string }> = [];
|
|
450
|
+
|
|
451
|
+
if (platform === 'darwin') {
|
|
452
|
+
browserDirs.push(
|
|
453
|
+
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
|
454
|
+
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
|
455
|
+
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
|
456
|
+
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
|
457
|
+
);
|
|
458
|
+
} else if (platform === 'linux') {
|
|
459
|
+
browserDirs.push(
|
|
460
|
+
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
|
461
|
+
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
|
462
|
+
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
|
463
|
+
);
|
|
464
|
+
} else if (platform === 'win32') {
|
|
465
|
+
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
466
|
+
browserDirs.push(
|
|
467
|
+
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
|
468
|
+
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const profiles = enumerateProfiles(browserDirs.map(d => d.base));
|
|
473
|
+
const foundBrowsers: string[] = [];
|
|
474
|
+
|
|
475
|
+
for (const { name, base } of browserDirs) {
|
|
476
|
+
for (const profile of profiles) {
|
|
477
|
+
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
|
478
|
+
if (fileExists(extDir)) {
|
|
479
|
+
foundBrowsers.push(name);
|
|
480
|
+
break; // one match per browser is enough
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
490
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
491
|
+
*/
|
|
492
|
+
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
|
493
|
+
const timeout = opts?.timeout ?? 8;
|
|
494
|
+
const start = Date.now();
|
|
495
|
+
try {
|
|
496
|
+
const mcp = new PlaywrightMCP();
|
|
497
|
+
await mcp.connect({ timeout });
|
|
498
|
+
await mcp.close();
|
|
499
|
+
return { ok: true, durationMs: Date.now() - start };
|
|
500
|
+
} catch (err: any) {
|
|
501
|
+
return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
396
505
|
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
397
506
|
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
398
507
|
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
@@ -418,24 +527,38 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
418
527
|
const uniqueTokens = [...new Set(allTokens)];
|
|
419
528
|
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
420
529
|
|
|
530
|
+
// Check extension installation
|
|
531
|
+
const extInstall = checkExtensionInstalled();
|
|
532
|
+
|
|
533
|
+
// Connectivity test (only when --live)
|
|
534
|
+
let connectivity: ConnectivityResult | undefined;
|
|
535
|
+
if (opts.live) {
|
|
536
|
+
connectivity = await checkTokenConnectivity();
|
|
537
|
+
}
|
|
538
|
+
|
|
421
539
|
const report: DoctorReport = {
|
|
422
540
|
cliVersion: opts.cliVersion,
|
|
423
541
|
envToken,
|
|
424
542
|
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
425
543
|
extensionToken,
|
|
426
544
|
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
545
|
+
extensionInstalled: extInstall.installed,
|
|
546
|
+
extensionBrowsers: extInstall.browsers,
|
|
427
547
|
shellFiles,
|
|
428
548
|
configs,
|
|
429
549
|
recommendedToken,
|
|
430
550
|
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
551
|
+
connectivity,
|
|
431
552
|
warnings: [],
|
|
432
553
|
issues: [],
|
|
433
554
|
};
|
|
434
555
|
|
|
556
|
+
if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
435
557
|
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
436
558
|
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
|
437
559
|
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
438
560
|
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
561
|
+
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
439
562
|
for (const config of configs) {
|
|
440
563
|
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
441
564
|
}
|
|
@@ -456,6 +579,12 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
456
579
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
457
580
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
458
581
|
|
|
582
|
+
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
583
|
+
const installDetail = report.extensionInstalled
|
|
584
|
+
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
585
|
+
: 'Extension not installed in any browser';
|
|
586
|
+
lines.push(statusLine(installStatus, installDetail));
|
|
587
|
+
|
|
459
588
|
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
460
589
|
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
461
590
|
|
|
@@ -489,6 +618,18 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
489
618
|
}
|
|
490
619
|
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
491
620
|
lines.push('');
|
|
621
|
+
|
|
622
|
+
// Connectivity result
|
|
623
|
+
if (report.connectivity) {
|
|
624
|
+
const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
|
625
|
+
const connDetail = report.connectivity.ok
|
|
626
|
+
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
627
|
+
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
|
628
|
+
lines.push(statusLine(connStatus, connDetail));
|
|
629
|
+
} else {
|
|
630
|
+
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
|
631
|
+
}
|
|
632
|
+
|
|
492
633
|
lines.push(statusLine(
|
|
493
634
|
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
|
494
635
|
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
|
@@ -547,7 +688,7 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp
|
|
|
547
688
|
const written: string[] = [];
|
|
548
689
|
if (plannedWrites.includes(shellPath)) {
|
|
549
690
|
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
|
550
|
-
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
|
|
691
|
+
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
|
|
551
692
|
written.push(shellPath);
|
|
552
693
|
}
|
|
553
694
|
|
|
@@ -555,7 +696,9 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp
|
|
|
555
696
|
if (!plannedWrites.includes(config.path)) continue;
|
|
556
697
|
if (config.parseError) continue;
|
|
557
698
|
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
|
558
|
-
const next = config.format === 'toml'
|
|
699
|
+
const next = config.format === 'toml'
|
|
700
|
+
? upsertTomlConfigToken(before, token)
|
|
701
|
+
: upsertJsonConfigToken(before, token, config.path);
|
|
559
702
|
writeFileWithMkdir(config.path, next);
|
|
560
703
|
written.push(config.path);
|
|
561
704
|
}
|
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 });
|
package/src/setup.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { stdin as input, stdout as output } from 'node:process';
|
|
|
11
11
|
import {
|
|
12
12
|
type DoctorReport,
|
|
13
13
|
PLAYWRIGHT_TOKEN_ENV,
|
|
14
|
+
checkExtensionInstalled,
|
|
15
|
+
checkTokenConnectivity,
|
|
14
16
|
discoverExtensionToken,
|
|
15
17
|
fileExists,
|
|
16
18
|
getDefaultShellRcPath,
|
|
@@ -60,11 +62,24 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
if (!token) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
// Give precise diagnosis of why token scan failed
|
|
66
|
+
const extInstall = checkExtensionInstalled();
|
|
67
|
+
|
|
68
|
+
console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
|
|
69
|
+
if (!extInstall.installed) {
|
|
70
|
+
console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
|
|
71
|
+
console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
|
|
72
|
+
console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
|
|
73
|
+
} else {
|
|
74
|
+
console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
|
|
75
|
+
console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
|
|
76
|
+
console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
|
|
77
|
+
}
|
|
78
|
+
console.log();
|
|
79
|
+
console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
|
|
65
80
|
console.log();
|
|
66
81
|
const rl = createInterface({ input, output });
|
|
67
|
-
const answer = await rl.question(' Token: ');
|
|
82
|
+
const answer = await rl.question(' Token (press Enter to abort): ');
|
|
68
83
|
rl.close();
|
|
69
84
|
token = answer.trim();
|
|
70
85
|
if (!token) {
|
|
@@ -129,7 +144,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
|
|
|
129
144
|
if (sel.startsWith('shell:')) {
|
|
130
145
|
const p = sel.slice('shell:'.length);
|
|
131
146
|
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
132
|
-
writeFileWithMkdir(p, upsertShellToken(before, token));
|
|
147
|
+
writeFileWithMkdir(p, upsertShellToken(before, token, p));
|
|
133
148
|
written.push(p);
|
|
134
149
|
wroteShell = true;
|
|
135
150
|
} else if (sel.startsWith('config:')) {
|
|
@@ -138,7 +153,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
|
|
|
138
153
|
if (config && config.parseError) continue;
|
|
139
154
|
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
140
155
|
const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
|
|
141
|
-
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
|
156
|
+
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
|
|
142
157
|
writeFileWithMkdir(p, next);
|
|
143
158
|
written.push(p);
|
|
144
159
|
}
|
|
@@ -161,6 +176,21 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
|
|
|
161
176
|
console.log(chalk.yellow(' No files were changed.'));
|
|
162
177
|
}
|
|
163
178
|
console.log();
|
|
179
|
+
|
|
180
|
+
// Step 7: Auto-verify browser connectivity
|
|
181
|
+
console.log(chalk.dim(' Verifying browser connectivity...'));
|
|
182
|
+
try {
|
|
183
|
+
const result = await checkTokenConnectivity({ timeout: 5 });
|
|
184
|
+
if (result.ok) {
|
|
185
|
+
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
186
|
+
} else {
|
|
187
|
+
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
|
|
188
|
+
console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
|
|
192
|
+
}
|
|
193
|
+
console.log();
|
|
164
194
|
}
|
|
165
195
|
|
|
166
196
|
function padRight(s: string, n: number): string {
|