@jackwener/opencli 0.9.8 → 1.0.1
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/CDP.md +1 -1
- package/CDP.zh-CN.md +1 -1
- package/CLI-ELECTRON.md +2 -2
- package/CLI-EXPLORER.md +4 -4
- package/README.md +35 -58
- package/README.zh-CN.md +36 -60
- package/SKILL.md +10 -8
- package/TESTING.md +7 -7
- package/dist/browser/daemon-client.d.ts +37 -0
- package/dist/browser/daemon-client.js +82 -0
- package/dist/browser/discover.d.ts +11 -34
- package/dist/browser/discover.js +15 -205
- package/dist/browser/errors.d.ts +6 -20
- package/dist/browser/errors.js +24 -63
- package/dist/browser/index.d.ts +2 -12
- package/dist/browser/index.js +2 -12
- package/dist/browser/mcp.d.ts +9 -21
- package/dist/browser/mcp.js +70 -285
- package/dist/browser/page.d.ts +36 -7
- package/dist/browser/page.js +212 -81
- package/dist/browser.test.js +10 -231
- package/dist/cli-manifest.json +561 -14
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/neteasemusic/like.d.ts +1 -0
- package/dist/clis/neteasemusic/like.js +25 -0
- package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
- package/dist/clis/neteasemusic/lyrics.js +47 -0
- package/dist/clis/neteasemusic/next.d.ts +1 -0
- package/dist/clis/neteasemusic/next.js +26 -0
- package/dist/clis/neteasemusic/play.d.ts +1 -0
- package/dist/clis/neteasemusic/play.js +26 -0
- package/dist/clis/neteasemusic/playing.d.ts +1 -0
- package/dist/clis/neteasemusic/playing.js +59 -0
- package/dist/clis/neteasemusic/playlist.d.ts +1 -0
- package/dist/clis/neteasemusic/playlist.js +46 -0
- package/dist/clis/neteasemusic/prev.d.ts +1 -0
- package/dist/clis/neteasemusic/prev.js +25 -0
- package/dist/clis/neteasemusic/search.d.ts +1 -0
- package/dist/clis/neteasemusic/search.js +52 -0
- package/dist/clis/neteasemusic/status.d.ts +1 -0
- package/dist/clis/neteasemusic/status.js +16 -0
- package/dist/clis/neteasemusic/volume.d.ts +1 -0
- package/dist/clis/neteasemusic/volume.js +54 -0
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/daemon.d.ts +13 -0
- package/dist/daemon.js +187 -0
- package/dist/doctor.d.ts +10 -65
- package/dist/doctor.js +49 -602
- package/dist/doctor.test.js +30 -170
- package/dist/main.js +12 -41
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/browser.js +2 -2
- package/dist/pipeline/steps/intercept.js +1 -2
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +46 -160
- package/dist/types.d.ts +6 -0
- package/extension/dist/background.js +484 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +31 -0
- package/extension/package.json +16 -0
- package/extension/src/background.ts +370 -0
- package/extension/src/cdp.ts +125 -0
- package/extension/src/protocol.ts +57 -0
- package/extension/store-assets/screenshot-1280x800.png +0 -0
- package/extension/tsconfig.json +15 -0
- package/extension/vite.config.ts +18 -0
- package/package.json +5 -5
- package/src/browser/daemon-client.ts +113 -0
- package/src/browser/discover.ts +18 -232
- package/src/browser/errors.ts +30 -100
- package/src/browser/index.ts +2 -13
- package/src/browser/mcp.ts +81 -282
- package/src/browser/page.ts +223 -83
- package/src/browser.test.ts +9 -239
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/chatgpt/README.md +1 -1
- package/src/clis/chatgpt/README.zh-CN.md +1 -1
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/neteasemusic/README.md +31 -0
- package/src/clis/neteasemusic/README.zh-CN.md +31 -0
- package/src/clis/neteasemusic/like.ts +28 -0
- package/src/clis/neteasemusic/lyrics.ts +53 -0
- package/src/clis/neteasemusic/next.ts +30 -0
- package/src/clis/neteasemusic/play.ts +30 -0
- package/src/clis/neteasemusic/playing.ts +62 -0
- package/src/clis/neteasemusic/playlist.ts +51 -0
- package/src/clis/neteasemusic/prev.ts +29 -0
- package/src/clis/neteasemusic/search.ts +58 -0
- package/src/clis/neteasemusic/status.ts +18 -0
- package/src/clis/neteasemusic/volume.ts +61 -0
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/daemon.ts +217 -0
- package/src/doctor.test.ts +32 -193
- package/src/doctor.ts +58 -669
- package/src/main.ts +11 -34
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/browser.ts +2 -2
- package/src/pipeline/steps/intercept.ts +1 -2
- package/src/runtime.ts +2 -6
- package/src/setup.ts +47 -183
- package/src/types.ts +1 -0
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
package/src/doctor.ts
CHANGED
|
@@ -1,47 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* opencli doctor — diagnose and fix browser connectivity.
|
|
3
|
+
*
|
|
4
|
+
* Simplified for the daemon-based architecture. No more token management,
|
|
5
|
+
* MCP path discovery, or config file scanning.
|
|
6
|
+
*/
|
|
4
7
|
|
|
5
|
-
import { createInterface } from 'node:readline/promises';
|
|
6
|
-
import { stdin as input, stdout as output } from 'node:process';
|
|
7
8
|
import chalk from 'chalk';
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
9
|
+
import { checkDaemonStatus } from './browser/discover.js';
|
|
10
|
+
import { BrowserBridge } from './browser/index.js';
|
|
10
11
|
import { browserSession } from './runtime.js';
|
|
11
12
|
|
|
12
|
-
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
13
|
-
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
|
14
|
-
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
|
15
|
-
const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
|
|
16
13
|
export type DoctorOptions = {
|
|
17
14
|
fix?: boolean;
|
|
18
15
|
yes?: boolean;
|
|
19
16
|
live?: boolean;
|
|
20
|
-
shellRc?: string;
|
|
21
|
-
configPaths?: string[];
|
|
22
|
-
token?: string;
|
|
23
17
|
cliVersion?: string;
|
|
24
18
|
};
|
|
25
19
|
|
|
26
|
-
export type ShellFileStatus = {
|
|
27
|
-
path: string;
|
|
28
|
-
exists: boolean;
|
|
29
|
-
token: string | null;
|
|
30
|
-
fingerprint: string | null;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export type McpConfigFormat = 'json' | 'toml';
|
|
34
|
-
|
|
35
|
-
export type McpConfigStatus = {
|
|
36
|
-
path: string;
|
|
37
|
-
exists: boolean;
|
|
38
|
-
format: McpConfigFormat;
|
|
39
|
-
token: string | null;
|
|
40
|
-
fingerprint: string | null;
|
|
41
|
-
writable: boolean;
|
|
42
|
-
parseError?: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
20
|
export type ConnectivityResult = {
|
|
46
21
|
ok: boolean;
|
|
47
22
|
error?: string;
|
|
@@ -50,463 +25,22 @@ export type ConnectivityResult = {
|
|
|
50
25
|
|
|
51
26
|
export type DoctorReport = {
|
|
52
27
|
cliVersion?: string;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
extensionToken: string | null;
|
|
56
|
-
extensionFingerprint: string | null;
|
|
57
|
-
extensionInstalled: boolean;
|
|
58
|
-
extensionBrowsers: string[];
|
|
59
|
-
shellFiles: ShellFileStatus[];
|
|
60
|
-
configs: McpConfigStatus[];
|
|
61
|
-
recommendedToken: string | null;
|
|
62
|
-
recommendedFingerprint: string | null;
|
|
28
|
+
daemonRunning: boolean;
|
|
29
|
+
extensionConnected: boolean;
|
|
63
30
|
connectivity?: ConnectivityResult;
|
|
64
|
-
warnings: string[];
|
|
65
31
|
issues: string[];
|
|
66
32
|
};
|
|
67
33
|
|
|
68
|
-
type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
|
|
69
|
-
|
|
70
|
-
function colorLabel(status: ReportStatus): string {
|
|
71
|
-
switch (status) {
|
|
72
|
-
case 'OK': return chalk.green('[OK]');
|
|
73
|
-
case 'MISSING': return chalk.red('[MISSING]');
|
|
74
|
-
case 'MISMATCH': return chalk.yellow('[MISMATCH]');
|
|
75
|
-
case 'WARN': return chalk.yellow('[WARN]');
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function statusLine(status: ReportStatus, text: string): string {
|
|
80
|
-
return `${colorLabel(status)} ${text}`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function tokenSummary(token: string | null, fingerprint: string | null): string {
|
|
84
|
-
if (!token) return chalk.dim('missing');
|
|
85
|
-
return `configured ${chalk.dim(`(${fingerprint})`)}`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function shortenPath(p: string): string {
|
|
89
|
-
const home = os.homedir();
|
|
90
|
-
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function toolName(p: string): string {
|
|
94
|
-
if (p.includes('.codex/')) return 'Codex';
|
|
95
|
-
if (p.includes('.cursor/')) return 'Cursor';
|
|
96
|
-
if (p.includes('.claude.json')) return 'Claude Code';
|
|
97
|
-
if (p.includes('antigravity')) return 'Antigravity';
|
|
98
|
-
if (p.includes('.gemini/settings')) return 'Gemini CLI';
|
|
99
|
-
if (p.includes('opencode')) return 'OpenCode';
|
|
100
|
-
if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
|
|
101
|
-
if (p.includes('.vscode/')) return 'VS Code';
|
|
102
|
-
if (p.includes('.mcp.json')) return 'Project MCP';
|
|
103
|
-
if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
|
|
104
|
-
return '';
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function getDefaultShellRcPath(): string {
|
|
108
|
-
const shell = process.env.SHELL ?? '';
|
|
109
|
-
if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc');
|
|
110
|
-
if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish');
|
|
111
|
-
return path.join(os.homedir(), '.zshrc');
|
|
112
|
-
}
|
|
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
|
-
|
|
123
|
-
export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
|
|
124
|
-
const home = os.homedir();
|
|
125
|
-
const candidates = [
|
|
126
|
-
path.join(home, '.codex', 'config.toml'),
|
|
127
|
-
path.join(home, '.codex', 'mcp.json'),
|
|
128
|
-
path.join(home, '.cursor', 'mcp.json'),
|
|
129
|
-
path.join(home, '.claude.json'),
|
|
130
|
-
path.join(home, '.gemini', 'settings.json'),
|
|
131
|
-
path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
|
|
132
|
-
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
133
|
-
path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
134
|
-
path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
|
|
135
|
-
path.join(cwd, '.cursor', 'mcp.json'),
|
|
136
|
-
path.join(cwd, '.vscode', 'mcp.json'),
|
|
137
|
-
path.join(cwd, '.opencode', 'opencode.json'),
|
|
138
|
-
path.join(cwd, '.mcp.json'),
|
|
139
|
-
];
|
|
140
|
-
return [...new Set(candidates)];
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function readTokenFromShellContent(content: string): string | null {
|
|
144
|
-
const m = content.match(TOKEN_LINE_RE);
|
|
145
|
-
return m?.[3] ?? null;
|
|
146
|
-
}
|
|
147
|
-
|
|
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
|
-
}
|
|
157
|
-
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
|
158
|
-
if (!content.trim()) return `${nextLine}\n`;
|
|
159
|
-
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
|
|
160
|
-
token
|
|
161
|
-
}"`);
|
|
162
|
-
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function readJsonConfigToken(content: string): string | null {
|
|
166
|
-
try {
|
|
167
|
-
const parsed = JSON.parse(content);
|
|
168
|
-
return readTokenFromJsonObject(parsed);
|
|
169
|
-
} catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function readTokenFromJsonObject(parsed: any): string | null {
|
|
175
|
-
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
176
|
-
if (typeof direct === 'string' && direct) return direct;
|
|
177
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
178
|
-
if (typeof opencode === 'string' && opencode) return opencode;
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
|
|
183
|
-
const parsed = content.trim() ? JSON.parse(content) : {};
|
|
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) {
|
|
192
|
-
parsed.mcp = parsed.mcp ?? {};
|
|
193
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
194
|
-
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
|
195
|
-
enabled: true,
|
|
196
|
-
type: 'local',
|
|
197
|
-
};
|
|
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;
|
|
208
|
-
}
|
|
209
|
-
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function readTomlConfigToken(content: string): string | null {
|
|
213
|
-
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
|
|
214
|
-
if (!sectionMatch) return null;
|
|
215
|
-
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
|
|
216
|
-
return tokenMatch?.[1] ?? null;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function upsertTomlConfigToken(content: string, token: string): string {
|
|
220
|
-
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
|
|
221
|
-
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
|
|
222
|
-
if (envSectionRe.test(content)) {
|
|
223
|
-
return content.replace(envSectionRe, (section) => {
|
|
224
|
-
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
|
|
225
|
-
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
|
|
226
|
-
}
|
|
227
|
-
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
|
|
232
|
-
if (baseSectionRe.test(content)) {
|
|
233
|
-
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
|
|
237
|
-
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function fileExists(filePath: string): boolean {
|
|
241
|
-
try {
|
|
242
|
-
return fs.existsSync(filePath);
|
|
243
|
-
} catch {
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function canWrite(filePath: string): boolean {
|
|
249
|
-
try {
|
|
250
|
-
if (fileExists(filePath)) {
|
|
251
|
-
fs.accessSync(filePath, fs.constants.W_OK);
|
|
252
|
-
return true;
|
|
253
|
-
}
|
|
254
|
-
fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
|
|
255
|
-
return true;
|
|
256
|
-
} catch {
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function readConfigStatus(filePath: string): McpConfigStatus {
|
|
262
|
-
const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
|
|
263
|
-
if (!fileExists(filePath)) {
|
|
264
|
-
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
268
|
-
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
|
|
269
|
-
return {
|
|
270
|
-
path: filePath,
|
|
271
|
-
exists: true,
|
|
272
|
-
format,
|
|
273
|
-
token,
|
|
274
|
-
fingerprint: getTokenFingerprint(token ?? undefined),
|
|
275
|
-
writable: canWrite(filePath),
|
|
276
|
-
};
|
|
277
|
-
} catch (error: any) {
|
|
278
|
-
return {
|
|
279
|
-
path: filePath,
|
|
280
|
-
exists: true,
|
|
281
|
-
format,
|
|
282
|
-
token: null,
|
|
283
|
-
fingerprint: null,
|
|
284
|
-
writable: canWrite(filePath),
|
|
285
|
-
parseError: error?.message ?? String(error),
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
}
|
|
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
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
312
|
-
* by scanning Chrome's LevelDB localStorage files directly.
|
|
313
|
-
*
|
|
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.
|
|
319
|
-
*/
|
|
320
|
-
export function discoverExtensionToken(): string | null {
|
|
321
|
-
const home = os.homedir();
|
|
322
|
-
const platform = os.platform();
|
|
323
|
-
const bases: string[] = [];
|
|
324
|
-
|
|
325
|
-
if (platform === 'darwin') {
|
|
326
|
-
bases.push(
|
|
327
|
-
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
|
328
|
-
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
|
|
329
|
-
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
|
|
330
|
-
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
|
|
331
|
-
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
|
332
|
-
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
|
333
|
-
);
|
|
334
|
-
} else if (platform === 'linux') {
|
|
335
|
-
bases.push(
|
|
336
|
-
path.join(home, '.config', 'google-chrome'),
|
|
337
|
-
path.join(home, '.config', 'google-chrome-unstable'),
|
|
338
|
-
path.join(home, '.config', 'google-chrome-beta'),
|
|
339
|
-
path.join(home, '.config', 'chromium'),
|
|
340
|
-
path.join(home, '.config', 'microsoft-edge'),
|
|
341
|
-
);
|
|
342
|
-
} else if (platform === 'win32') {
|
|
343
|
-
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
344
|
-
bases.push(
|
|
345
|
-
path.join(appData, 'Google', 'Chrome', 'User Data'),
|
|
346
|
-
path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
|
|
347
|
-
path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
|
|
348
|
-
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const profiles = enumerateProfiles(bases);
|
|
353
|
-
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
354
|
-
|
|
355
|
-
for (const base of bases) {
|
|
356
|
-
for (const profile of profiles) {
|
|
357
|
-
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
358
|
-
if (!fileExists(dir)) continue;
|
|
359
|
-
|
|
360
|
-
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
361
|
-
if (token) return token;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
return null;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
|
369
|
-
// LevelDB fragments strings across byte boundaries, so we can't search
|
|
370
|
-
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
|
371
|
-
// search for a short prefix of the extension ID that reliably appears as
|
|
372
|
-
// contiguous bytes, then scan a window around each match for a base64url
|
|
373
|
-
// token value.
|
|
374
|
-
//
|
|
375
|
-
// Observed LevelDB layout near the auth-token entry:
|
|
376
|
-
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
|
377
|
-
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
|
378
|
-
//
|
|
379
|
-
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
|
380
|
-
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
381
|
-
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
382
|
-
|
|
383
|
-
let files: string[];
|
|
384
|
-
try {
|
|
385
|
-
files = fs.readdirSync(dir)
|
|
386
|
-
.filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
|
|
387
|
-
.map(f => path.join(dir, f));
|
|
388
|
-
} catch { return null; }
|
|
389
|
-
|
|
390
|
-
// Sort by mtime descending so we find the freshest token first
|
|
391
|
-
files.sort((a, b) => {
|
|
392
|
-
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
for (const file of files) {
|
|
396
|
-
let data: Buffer;
|
|
397
|
-
try { data = fs.readFileSync(file); } catch { continue; }
|
|
398
|
-
|
|
399
|
-
// Quick check: file must contain at least the prefix
|
|
400
|
-
if (data.indexOf(extIdPrefix) === -1) continue;
|
|
401
|
-
|
|
402
|
-
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
403
|
-
// for base64url tokens within a 500-byte window
|
|
404
|
-
let idx = 0;
|
|
405
|
-
while (true) {
|
|
406
|
-
const pos = data.indexOf(extIdPrefix, idx);
|
|
407
|
-
if (pos === -1) break;
|
|
408
|
-
|
|
409
|
-
const scanStart = pos;
|
|
410
|
-
const scanEnd = Math.min(data.length, pos + 500);
|
|
411
|
-
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
|
412
|
-
const m = window.match(tokenRe);
|
|
413
|
-
if (m && validateBase64urlToken(m[1])) {
|
|
414
|
-
// Make sure this isn't another extension ID that happens to match
|
|
415
|
-
if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
|
|
416
|
-
}
|
|
417
|
-
idx = pos + 1;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
|
421
|
-
const keyBuf = Buffer.from('auth-token');
|
|
422
|
-
idx = 0;
|
|
423
|
-
while (true) {
|
|
424
|
-
const kp = data.indexOf(keyBuf, idx);
|
|
425
|
-
if (kp === -1) break;
|
|
426
|
-
|
|
427
|
-
const contextStart = Math.max(0, kp - 500);
|
|
428
|
-
if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
|
|
429
|
-
const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
|
|
430
|
-
const m = after.match(tokenRe);
|
|
431
|
-
if (m && validateBase64urlToken(m[1])) return m[1];
|
|
432
|
-
}
|
|
433
|
-
idx = kp + 1;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return null;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function validateBase64urlToken(token: string): boolean {
|
|
440
|
-
try {
|
|
441
|
-
const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
|
|
442
|
-
const decoded = Buffer.from(b64, 'base64');
|
|
443
|
-
return decoded.length >= 28 && decoded.length <= 36;
|
|
444
|
-
} catch { return false; }
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
450
|
-
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
451
|
-
*/
|
|
452
|
-
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
|
|
453
|
-
const home = os.homedir();
|
|
454
|
-
const platform = os.platform();
|
|
455
|
-
const browserDirs: Array<{ name: string; base: string }> = [];
|
|
456
|
-
|
|
457
|
-
if (platform === 'darwin') {
|
|
458
|
-
browserDirs.push(
|
|
459
|
-
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
|
460
|
-
{ name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
|
|
461
|
-
{ name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
|
|
462
|
-
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
|
463
|
-
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
|
464
|
-
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
|
465
|
-
);
|
|
466
|
-
} else if (platform === 'linux') {
|
|
467
|
-
browserDirs.push(
|
|
468
|
-
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
|
469
|
-
{ name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
|
|
470
|
-
{ name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
|
|
471
|
-
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
|
472
|
-
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
|
473
|
-
);
|
|
474
|
-
} else if (platform === 'win32') {
|
|
475
|
-
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
476
|
-
browserDirs.push(
|
|
477
|
-
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
|
478
|
-
{ name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
|
|
479
|
-
{ name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
|
|
480
|
-
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
|
481
|
-
);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const profiles = enumerateProfiles(browserDirs.map(d => d.base));
|
|
485
|
-
const foundBrowsers: string[] = [];
|
|
486
|
-
|
|
487
|
-
for (const { name, base } of browserDirs) {
|
|
488
|
-
for (const profile of profiles) {
|
|
489
|
-
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
|
490
|
-
if (fileExists(extDir)) {
|
|
491
|
-
foundBrowsers.push(name);
|
|
492
|
-
break; // one match per browser is enough
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
34
|
/**
|
|
501
|
-
* Test
|
|
502
|
-
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
35
|
+
* Test connectivity by attempting a real browser command.
|
|
503
36
|
*/
|
|
504
|
-
export async function
|
|
505
|
-
const timeout = opts?.timeout ?? 8;
|
|
37
|
+
export async function checkConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
|
506
38
|
const start = Date.now();
|
|
507
39
|
try {
|
|
508
|
-
const mcp = new
|
|
509
|
-
await mcp.connect({ timeout });
|
|
40
|
+
const mcp = new BrowserBridge();
|
|
41
|
+
const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
|
|
42
|
+
// Try a simple eval to verify end-to-end connectivity
|
|
43
|
+
await page.evaluate('1 + 1');
|
|
510
44
|
await mcp.close();
|
|
511
45
|
return { ok: true, durationMs: Date.now() - start };
|
|
512
46
|
} catch (err: any) {
|
|
@@ -515,215 +49,70 @@ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promi
|
|
|
515
49
|
}
|
|
516
50
|
|
|
517
51
|
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
518
|
-
const
|
|
519
|
-
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
520
|
-
const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
|
|
521
|
-
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
|
|
522
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
523
|
-
const token = readTokenFromShellContent(content);
|
|
524
|
-
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
|
|
525
|
-
});
|
|
526
|
-
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
|
527
|
-
const configs = configPaths.map(readConfigStatus);
|
|
52
|
+
const status = await checkDaemonStatus();
|
|
528
53
|
|
|
529
|
-
// Try to discover the token directly from the Chrome extension's localStorage
|
|
530
|
-
const extensionToken = discoverExtensionToken();
|
|
531
|
-
|
|
532
|
-
const allTokens = [
|
|
533
|
-
opts.token ?? null,
|
|
534
|
-
extensionToken,
|
|
535
|
-
envToken,
|
|
536
|
-
...shellFiles.map(s => s.token),
|
|
537
|
-
...configs.map(c => c.token),
|
|
538
|
-
].filter((v): v is string => !!v);
|
|
539
|
-
const uniqueTokens = [...new Set(allTokens)];
|
|
540
|
-
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
541
|
-
|
|
542
|
-
// Check extension installation
|
|
543
|
-
const extInstall = checkExtensionInstalled();
|
|
544
|
-
|
|
545
|
-
// Connectivity test (only when --live)
|
|
546
54
|
let connectivity: ConnectivityResult | undefined;
|
|
547
55
|
if (opts.live) {
|
|
548
|
-
connectivity = await
|
|
56
|
+
connectivity = await checkConnectivity();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const issues: string[] = [];
|
|
60
|
+
if (!status.running) {
|
|
61
|
+
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
|
|
62
|
+
}
|
|
63
|
+
if (status.running && !status.extensionConnected) {
|
|
64
|
+
issues.push(
|
|
65
|
+
'Daemon is running but the Chrome extension is not connected.\n' +
|
|
66
|
+
'Please install the opencli Browser Bridge extension:\n' +
|
|
67
|
+
' 1. Download from GitHub Releases\n' +
|
|
68
|
+
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
|
69
|
+
' 3. Click "Load unpacked" → select the extension folder',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (connectivity && !connectivity.ok) {
|
|
73
|
+
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
549
74
|
}
|
|
550
75
|
|
|
551
|
-
|
|
76
|
+
return {
|
|
552
77
|
cliVersion: opts.cliVersion,
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
extensionToken,
|
|
556
|
-
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
557
|
-
extensionInstalled: extInstall.installed,
|
|
558
|
-
extensionBrowsers: extInstall.browsers,
|
|
559
|
-
shellFiles,
|
|
560
|
-
configs,
|
|
561
|
-
recommendedToken,
|
|
562
|
-
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
78
|
+
daemonRunning: status.running,
|
|
79
|
+
extensionConnected: status.extensionConnected,
|
|
563
80
|
connectivity,
|
|
564
|
-
|
|
565
|
-
issues: [],
|
|
81
|
+
issues,
|
|
566
82
|
};
|
|
567
|
-
|
|
568
|
-
if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
569
|
-
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
570
|
-
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
|
571
|
-
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
572
|
-
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
573
|
-
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
574
|
-
for (const config of configs) {
|
|
575
|
-
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
576
|
-
}
|
|
577
|
-
if (!recommendedToken) {
|
|
578
|
-
report.warnings.push('No token source found.');
|
|
579
|
-
}
|
|
580
|
-
return report;
|
|
581
83
|
}
|
|
582
84
|
|
|
583
85
|
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
584
|
-
const tokenFingerprints = [
|
|
585
|
-
report.extensionFingerprint,
|
|
586
|
-
report.envFingerprint,
|
|
587
|
-
...report.shellFiles.map(shell => shell.fingerprint),
|
|
588
|
-
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
|
589
|
-
].filter((value): value is string => !!value);
|
|
590
|
-
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
591
|
-
const hasMismatch = uniqueFingerprints.length > 1;
|
|
592
86
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
593
87
|
|
|
594
|
-
//
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
|
|
598
|
-
lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
|
|
599
|
-
lines.push('');
|
|
600
|
-
return lines.join('\n');
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
604
|
-
const installDetail = report.extensionInstalled
|
|
605
|
-
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
606
|
-
: 'Extension not installed in any browser';
|
|
607
|
-
lines.push(statusLine(installStatus, installDetail));
|
|
608
|
-
|
|
609
|
-
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
610
|
-
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
611
|
-
|
|
612
|
-
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
613
|
-
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
|
88
|
+
// Daemon status
|
|
89
|
+
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
|
|
90
|
+
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
|
|
614
91
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
|
619
|
-
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
|
|
620
|
-
}
|
|
621
|
-
const existingConfigs = report.configs.filter(config => config.exists);
|
|
622
|
-
const missingConfigCount = report.configs.length - existingConfigs.length;
|
|
623
|
-
if (existingConfigs.length > 0) {
|
|
624
|
-
for (const config of existingConfigs) {
|
|
625
|
-
const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
|
|
626
|
-
const configStatus: ReportStatus = config.parseError
|
|
627
|
-
? 'WARN'
|
|
628
|
-
: !config.token
|
|
629
|
-
? 'MISSING'
|
|
630
|
-
: hasMismatch
|
|
631
|
-
? 'MISMATCH'
|
|
632
|
-
: 'OK';
|
|
633
|
-
const tool = toolName(config.path);
|
|
634
|
-
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
|
635
|
-
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
|
|
636
|
-
}
|
|
637
|
-
} else {
|
|
638
|
-
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
|
|
639
|
-
}
|
|
640
|
-
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
641
|
-
lines.push('');
|
|
92
|
+
// Extension status
|
|
93
|
+
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
|
|
94
|
+
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
|
|
642
95
|
|
|
643
|
-
// Connectivity
|
|
96
|
+
// Connectivity
|
|
644
97
|
if (report.connectivity) {
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
? `
|
|
648
|
-
: `
|
|
649
|
-
lines.push(
|
|
98
|
+
const connIcon = report.connectivity.ok ? chalk.green('[OK]') : chalk.red('[FAIL]');
|
|
99
|
+
const detail = report.connectivity.ok
|
|
100
|
+
? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
101
|
+
: `failed (${report.connectivity.error ?? 'unknown'})`;
|
|
102
|
+
lines.push(`${connIcon} Connectivity: ${detail}`);
|
|
650
103
|
} else {
|
|
651
|
-
lines.push(
|
|
104
|
+
lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
|
|
652
105
|
}
|
|
653
106
|
|
|
654
|
-
lines.push(statusLine(
|
|
655
|
-
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
|
656
|
-
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
|
657
|
-
));
|
|
658
107
|
if (report.issues.length) {
|
|
659
108
|
lines.push('', chalk.yellow('Issues:'));
|
|
660
|
-
for (const issue of report.issues)
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
return lines.join('\n');
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
async function confirmPrompt(question: string): Promise<boolean> {
|
|
670
|
-
const rl = createInterface({ input, output });
|
|
671
|
-
try {
|
|
672
|
-
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
673
|
-
return answer === 'y' || answer === 'yes';
|
|
674
|
-
} finally {
|
|
675
|
-
rl.close();
|
|
109
|
+
for (const issue of report.issues) {
|
|
110
|
+
lines.push(chalk.dim(` • ${issue}`));
|
|
111
|
+
}
|
|
112
|
+
} else if (report.daemonRunning && report.extensionConnected) {
|
|
113
|
+
lines.push('', chalk.green('Everything looks good!'));
|
|
676
114
|
}
|
|
677
|
-
}
|
|
678
115
|
|
|
679
|
-
|
|
680
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
681
|
-
fs.writeFileSync(filePath, content, 'utf-8');
|
|
116
|
+
return lines.join('\n');
|
|
682
117
|
}
|
|
683
118
|
|
|
684
|
-
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
|
|
685
|
-
const token = opts.token ?? report.recommendedToken;
|
|
686
|
-
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
|
|
687
|
-
const fp = getTokenFingerprint(token);
|
|
688
|
-
|
|
689
|
-
const plannedWrites: string[] = [];
|
|
690
|
-
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
|
691
|
-
const shellStatus = report.shellFiles.find(s => s.path === shellPath);
|
|
692
|
-
if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
|
|
693
|
-
for (const config of report.configs) {
|
|
694
|
-
if (!config.writable) continue;
|
|
695
|
-
if (config.fingerprint === fp) continue; // already correct
|
|
696
|
-
plannedWrites.push(config.path);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
if (plannedWrites.length === 0) {
|
|
700
|
-
console.log(chalk.green('All config files are already up to date.'));
|
|
701
|
-
return [];
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
if (!opts.yes) {
|
|
705
|
-
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
|
|
706
|
-
if (!ok) return [];
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
const written: string[] = [];
|
|
710
|
-
if (plannedWrites.includes(shellPath)) {
|
|
711
|
-
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
|
712
|
-
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
|
|
713
|
-
written.push(shellPath);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
for (const config of report.configs) {
|
|
717
|
-
if (!plannedWrites.includes(config.path)) continue;
|
|
718
|
-
if (config.parseError) continue;
|
|
719
|
-
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
|
720
|
-
const next = config.format === 'toml'
|
|
721
|
-
? upsertTomlConfigToken(before, token)
|
|
722
|
-
: upsertJsonConfigToken(before, token, config.path);
|
|
723
|
-
writeFileWithMkdir(config.path, next);
|
|
724
|
-
written.push(config.path);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
728
|
-
return written;
|
|
729
|
-
}
|