@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/dist/doctor.js
CHANGED
|
@@ -1,425 +1,22 @@
|
|
|
1
|
-
import * as fs from 'node:fs';
|
|
2
|
-
import * as os from 'node:os';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
|
-
import { createInterface } from 'node:readline/promises';
|
|
5
|
-
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
-
import chalk from 'chalk';
|
|
7
|
-
import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
|
|
8
|
-
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
9
|
-
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
|
10
|
-
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
|
11
|
-
const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
|
|
12
|
-
function colorLabel(status) {
|
|
13
|
-
switch (status) {
|
|
14
|
-
case 'OK': return chalk.green('[OK]');
|
|
15
|
-
case 'MISSING': return chalk.red('[MISSING]');
|
|
16
|
-
case 'MISMATCH': return chalk.yellow('[MISMATCH]');
|
|
17
|
-
case 'WARN': return chalk.yellow('[WARN]');
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
function statusLine(status, text) {
|
|
21
|
-
return `${colorLabel(status)} ${text}`;
|
|
22
|
-
}
|
|
23
|
-
function tokenSummary(token, fingerprint) {
|
|
24
|
-
if (!token)
|
|
25
|
-
return chalk.dim('missing');
|
|
26
|
-
return `configured ${chalk.dim(`(${fingerprint})`)}`;
|
|
27
|
-
}
|
|
28
|
-
export function shortenPath(p) {
|
|
29
|
-
const home = os.homedir();
|
|
30
|
-
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
31
|
-
}
|
|
32
|
-
export function toolName(p) {
|
|
33
|
-
if (p.includes('.codex/'))
|
|
34
|
-
return 'Codex';
|
|
35
|
-
if (p.includes('.cursor/'))
|
|
36
|
-
return 'Cursor';
|
|
37
|
-
if (p.includes('.claude.json'))
|
|
38
|
-
return 'Claude Code';
|
|
39
|
-
if (p.includes('antigravity'))
|
|
40
|
-
return 'Antigravity';
|
|
41
|
-
if (p.includes('.gemini/settings'))
|
|
42
|
-
return 'Gemini CLI';
|
|
43
|
-
if (p.includes('opencode'))
|
|
44
|
-
return 'OpenCode';
|
|
45
|
-
if (p.includes('Claude/claude_desktop'))
|
|
46
|
-
return 'Claude Desktop';
|
|
47
|
-
if (p.includes('.vscode/'))
|
|
48
|
-
return 'VS Code';
|
|
49
|
-
if (p.includes('.mcp.json'))
|
|
50
|
-
return 'Project MCP';
|
|
51
|
-
if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile'))
|
|
52
|
-
return 'Shell';
|
|
53
|
-
return '';
|
|
54
|
-
}
|
|
55
|
-
export function getDefaultShellRcPath() {
|
|
56
|
-
const shell = process.env.SHELL ?? '';
|
|
57
|
-
if (shell.endsWith('/bash'))
|
|
58
|
-
return path.join(os.homedir(), '.bashrc');
|
|
59
|
-
if (shell.endsWith('/fish'))
|
|
60
|
-
return path.join(os.homedir(), '.config', 'fish', 'config.fish');
|
|
61
|
-
return path.join(os.homedir(), '.zshrc');
|
|
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
|
-
}
|
|
70
|
-
export function getDefaultMcpConfigPaths(cwd = process.cwd()) {
|
|
71
|
-
const home = os.homedir();
|
|
72
|
-
const candidates = [
|
|
73
|
-
path.join(home, '.codex', 'config.toml'),
|
|
74
|
-
path.join(home, '.codex', 'mcp.json'),
|
|
75
|
-
path.join(home, '.cursor', 'mcp.json'),
|
|
76
|
-
path.join(home, '.claude.json'),
|
|
77
|
-
path.join(home, '.gemini', 'settings.json'),
|
|
78
|
-
path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
|
|
79
|
-
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
80
|
-
path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
81
|
-
path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
|
|
82
|
-
path.join(cwd, '.cursor', 'mcp.json'),
|
|
83
|
-
path.join(cwd, '.vscode', 'mcp.json'),
|
|
84
|
-
path.join(cwd, '.opencode', 'opencode.json'),
|
|
85
|
-
path.join(cwd, '.mcp.json'),
|
|
86
|
-
];
|
|
87
|
-
return [...new Set(candidates)];
|
|
88
|
-
}
|
|
89
|
-
export function readTokenFromShellContent(content) {
|
|
90
|
-
const m = content.match(TOKEN_LINE_RE);
|
|
91
|
-
return m?.[3] ?? null;
|
|
92
|
-
}
|
|
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
|
-
}
|
|
104
|
-
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
|
105
|
-
if (!content.trim())
|
|
106
|
-
return `${nextLine}\n`;
|
|
107
|
-
if (TOKEN_LINE_RE.test(content))
|
|
108
|
-
return content.replace(TOKEN_LINE_RE, `$1"${token}"`);
|
|
109
|
-
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
|
|
110
|
-
}
|
|
111
|
-
function readJsonConfigToken(content) {
|
|
112
|
-
try {
|
|
113
|
-
const parsed = JSON.parse(content);
|
|
114
|
-
return readTokenFromJsonObject(parsed);
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
function readTokenFromJsonObject(parsed) {
|
|
121
|
-
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
122
|
-
if (typeof direct === 'string' && direct)
|
|
123
|
-
return direct;
|
|
124
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
125
|
-
if (typeof opencode === 'string' && opencode)
|
|
126
|
-
return opencode;
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
export function upsertJsonConfigToken(content, token, filePath) {
|
|
130
|
-
const parsed = content.trim() ? JSON.parse(content) : {};
|
|
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) {
|
|
137
|
-
parsed.mcp = parsed.mcp ?? {};
|
|
138
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
139
|
-
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
|
140
|
-
enabled: true,
|
|
141
|
-
type: 'local',
|
|
142
|
-
};
|
|
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;
|
|
154
|
-
}
|
|
155
|
-
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
156
|
-
}
|
|
157
|
-
export function readTomlConfigToken(content) {
|
|
158
|
-
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
|
|
159
|
-
if (!sectionMatch)
|
|
160
|
-
return null;
|
|
161
|
-
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
|
|
162
|
-
return tokenMatch?.[1] ?? null;
|
|
163
|
-
}
|
|
164
|
-
export function upsertTomlConfigToken(content, token) {
|
|
165
|
-
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
|
|
166
|
-
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
|
|
167
|
-
if (envSectionRe.test(content)) {
|
|
168
|
-
return content.replace(envSectionRe, (section) => {
|
|
169
|
-
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
|
|
170
|
-
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
|
|
171
|
-
}
|
|
172
|
-
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
|
|
176
|
-
if (baseSectionRe.test(content)) {
|
|
177
|
-
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
|
|
178
|
-
}
|
|
179
|
-
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
|
|
180
|
-
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
|
|
181
|
-
}
|
|
182
|
-
export function fileExists(filePath) {
|
|
183
|
-
try {
|
|
184
|
-
return fs.existsSync(filePath);
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
function canWrite(filePath) {
|
|
191
|
-
try {
|
|
192
|
-
if (fileExists(filePath)) {
|
|
193
|
-
fs.accessSync(filePath, fs.constants.W_OK);
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
catch {
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
function readConfigStatus(filePath) {
|
|
204
|
-
const format = filePath.endsWith('.toml') ? 'toml' : 'json';
|
|
205
|
-
if (!fileExists(filePath)) {
|
|
206
|
-
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
|
|
207
|
-
}
|
|
208
|
-
try {
|
|
209
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
210
|
-
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
|
|
211
|
-
return {
|
|
212
|
-
path: filePath,
|
|
213
|
-
exists: true,
|
|
214
|
-
format,
|
|
215
|
-
token,
|
|
216
|
-
fingerprint: getTokenFingerprint(token ?? undefined),
|
|
217
|
-
writable: canWrite(filePath),
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
catch (error) {
|
|
221
|
-
return {
|
|
222
|
-
path: filePath,
|
|
223
|
-
exists: true,
|
|
224
|
-
format,
|
|
225
|
-
token: null,
|
|
226
|
-
fingerprint: null,
|
|
227
|
-
writable: canWrite(filePath),
|
|
228
|
-
parseError: error?.message ?? String(error),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
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
|
-
}
|
|
254
1
|
/**
|
|
255
|
-
*
|
|
256
|
-
* by scanning Chrome's LevelDB localStorage files directly.
|
|
2
|
+
* opencli doctor — diagnose and fix browser connectivity.
|
|
257
3
|
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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.
|
|
4
|
+
* Simplified for the daemon-based architecture. No more token management,
|
|
5
|
+
* MCP path discovery, or config file scanning.
|
|
263
6
|
*/
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const bases = [];
|
|
268
|
-
if (platform === 'darwin') {
|
|
269
|
-
bases.push(path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'), path.join(home, 'Library', 'Application Support', 'Chromium'), path.join(home, 'Library', 'Application Support', 'Microsoft Edge'));
|
|
270
|
-
}
|
|
271
|
-
else if (platform === 'linux') {
|
|
272
|
-
bases.push(path.join(home, '.config', 'google-chrome'), path.join(home, '.config', 'google-chrome-unstable'), path.join(home, '.config', 'google-chrome-beta'), path.join(home, '.config', 'chromium'), path.join(home, '.config', 'microsoft-edge'));
|
|
273
|
-
}
|
|
274
|
-
else if (platform === 'win32') {
|
|
275
|
-
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
276
|
-
bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Google', 'Chrome Dev', 'User Data'), path.join(appData, 'Google', 'Chrome Beta', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
|
|
277
|
-
}
|
|
278
|
-
const profiles = enumerateProfiles(bases);
|
|
279
|
-
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
280
|
-
for (const base of bases) {
|
|
281
|
-
for (const profile of profiles) {
|
|
282
|
-
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
283
|
-
if (!fileExists(dir))
|
|
284
|
-
continue;
|
|
285
|
-
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
286
|
-
if (token)
|
|
287
|
-
return token;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
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.
|
|
304
|
-
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
305
|
-
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
306
|
-
let files;
|
|
307
|
-
try {
|
|
308
|
-
files = fs.readdirSync(dir)
|
|
309
|
-
.filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
|
|
310
|
-
.map(f => path.join(dir, f));
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
313
|
-
return null;
|
|
314
|
-
}
|
|
315
|
-
// Sort by mtime descending so we find the freshest token first
|
|
316
|
-
files.sort((a, b) => {
|
|
317
|
-
try {
|
|
318
|
-
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
|
|
319
|
-
}
|
|
320
|
-
catch {
|
|
321
|
-
return 0;
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
for (const file of files) {
|
|
325
|
-
let data;
|
|
326
|
-
try {
|
|
327
|
-
data = fs.readFileSync(file);
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
// Quick check: file must contain at least the prefix
|
|
333
|
-
if (data.indexOf(extIdPrefix) === -1)
|
|
334
|
-
continue;
|
|
335
|
-
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
336
|
-
// for base64url tokens within a 500-byte window
|
|
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;
|
|
356
|
-
while (true) {
|
|
357
|
-
const kp = data.indexOf(keyBuf, idx);
|
|
358
|
-
if (kp === -1)
|
|
359
|
-
break;
|
|
360
|
-
const contextStart = Math.max(0, kp - 500);
|
|
361
|
-
if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
|
|
362
|
-
const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
|
|
363
|
-
const m = after.match(tokenRe);
|
|
364
|
-
if (m && validateBase64urlToken(m[1]))
|
|
365
|
-
return m[1];
|
|
366
|
-
}
|
|
367
|
-
idx = kp + 1;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
return null;
|
|
371
|
-
}
|
|
372
|
-
function validateBase64urlToken(token) {
|
|
373
|
-
try {
|
|
374
|
-
const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
|
|
375
|
-
const decoded = Buffer.from(b64, 'base64');
|
|
376
|
-
return decoded.length >= 28 && decoded.length <= 36;
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
return false;
|
|
380
|
-
}
|
|
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 Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') }, { name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') }, { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') }, { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') }, { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') });
|
|
392
|
-
}
|
|
393
|
-
else if (platform === 'linux') {
|
|
394
|
-
browserDirs.push({ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') }, { name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') }, { name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') }, { name: 'Chromium', base: path.join(home, '.config', 'chromium') }, { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') });
|
|
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: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') }, { name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
|
|
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
|
-
}
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { checkDaemonStatus } from './browser/discover.js';
|
|
9
|
+
import { BrowserBridge } from './browser/index.js';
|
|
413
10
|
/**
|
|
414
|
-
* Test
|
|
415
|
-
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
11
|
+
* Test connectivity by attempting a real browser command.
|
|
416
12
|
*/
|
|
417
|
-
export async function
|
|
418
|
-
const timeout = opts?.timeout ?? 8;
|
|
13
|
+
export async function checkConnectivity(opts) {
|
|
419
14
|
const start = Date.now();
|
|
420
15
|
try {
|
|
421
|
-
const mcp = new
|
|
422
|
-
await mcp.connect({ timeout });
|
|
16
|
+
const mcp = new BrowserBridge();
|
|
17
|
+
const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
|
|
18
|
+
// Try a simple eval to verify end-to-end connectivity
|
|
19
|
+
await page.evaluate('1 + 1');
|
|
423
20
|
await mcp.close();
|
|
424
21
|
return { ok: true, durationMs: Date.now() - start };
|
|
425
22
|
}
|
|
@@ -428,210 +25,60 @@ export async function checkTokenConnectivity(opts) {
|
|
|
428
25
|
}
|
|
429
26
|
}
|
|
430
27
|
export async function runBrowserDoctor(opts = {}) {
|
|
431
|
-
const
|
|
432
|
-
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
433
|
-
const shellFiles = [shellPath].map((filePath) => {
|
|
434
|
-
if (!fileExists(filePath))
|
|
435
|
-
return { path: filePath, exists: false, token: null, fingerprint: null };
|
|
436
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
437
|
-
const token = readTokenFromShellContent(content);
|
|
438
|
-
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
|
|
439
|
-
});
|
|
440
|
-
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
|
441
|
-
const configs = configPaths.map(readConfigStatus);
|
|
442
|
-
// Try to discover the token directly from the Chrome extension's localStorage
|
|
443
|
-
const extensionToken = discoverExtensionToken();
|
|
444
|
-
const allTokens = [
|
|
445
|
-
opts.token ?? null,
|
|
446
|
-
extensionToken,
|
|
447
|
-
envToken,
|
|
448
|
-
...shellFiles.map(s => s.token),
|
|
449
|
-
...configs.map(c => c.token),
|
|
450
|
-
].filter((v) => !!v);
|
|
451
|
-
const uniqueTokens = [...new Set(allTokens)];
|
|
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)
|
|
28
|
+
const status = await checkDaemonStatus();
|
|
456
29
|
let connectivity;
|
|
457
30
|
if (opts.live) {
|
|
458
|
-
connectivity = await
|
|
31
|
+
connectivity = await checkConnectivity();
|
|
32
|
+
}
|
|
33
|
+
const issues = [];
|
|
34
|
+
if (!status.running) {
|
|
35
|
+
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
|
|
36
|
+
}
|
|
37
|
+
if (status.running && !status.extensionConnected) {
|
|
38
|
+
issues.push('Daemon is running but the Chrome extension is not connected.\n' +
|
|
39
|
+
'Please install the opencli Browser Bridge extension:\n' +
|
|
40
|
+
' 1. Download from GitHub Releases\n' +
|
|
41
|
+
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
|
42
|
+
' 3. Click "Load unpacked" → select the extension folder');
|
|
459
43
|
}
|
|
460
|
-
|
|
44
|
+
if (connectivity && !connectivity.ok) {
|
|
45
|
+
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
461
48
|
cliVersion: opts.cliVersion,
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
extensionToken,
|
|
465
|
-
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
466
|
-
extensionInstalled: extInstall.installed,
|
|
467
|
-
extensionBrowsers: extInstall.browsers,
|
|
468
|
-
shellFiles,
|
|
469
|
-
configs,
|
|
470
|
-
recommendedToken,
|
|
471
|
-
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
49
|
+
daemonRunning: status.running,
|
|
50
|
+
extensionConnected: status.extensionConnected,
|
|
472
51
|
connectivity,
|
|
473
|
-
|
|
474
|
-
issues: [],
|
|
52
|
+
issues,
|
|
475
53
|
};
|
|
476
|
-
if (!extInstall.installed)
|
|
477
|
-
report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
478
|
-
if (!envToken)
|
|
479
|
-
report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
480
|
-
if (!shellFiles.some(s => s.token))
|
|
481
|
-
report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
|
482
|
-
if (!configs.some(c => c.token))
|
|
483
|
-
report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
484
|
-
if (uniqueTokens.length > 1)
|
|
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'}`);
|
|
488
|
-
for (const config of configs) {
|
|
489
|
-
if (config.parseError)
|
|
490
|
-
report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
491
|
-
}
|
|
492
|
-
if (!recommendedToken) {
|
|
493
|
-
report.warnings.push('No token source found.');
|
|
494
|
-
}
|
|
495
|
-
return report;
|
|
496
54
|
}
|
|
497
55
|
export function renderBrowserDoctorReport(report) {
|
|
498
|
-
const tokenFingerprints = [
|
|
499
|
-
report.extensionFingerprint,
|
|
500
|
-
report.envFingerprint,
|
|
501
|
-
...report.shellFiles.map(shell => shell.fingerprint),
|
|
502
|
-
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
|
503
|
-
].filter((value) => !!value);
|
|
504
|
-
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
505
|
-
const hasMismatch = uniqueFingerprints.length > 1;
|
|
506
56
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
507
|
-
//
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
516
|
-
const installDetail = report.extensionInstalled
|
|
517
|
-
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
518
|
-
: 'Extension not installed in any browser';
|
|
519
|
-
lines.push(statusLine(installStatus, installDetail));
|
|
520
|
-
const extStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
521
|
-
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
522
|
-
const envStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
523
|
-
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
|
524
|
-
for (const shell of report.shellFiles) {
|
|
525
|
-
const shellStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
526
|
-
const tool = toolName(shell.path);
|
|
527
|
-
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
|
528
|
-
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
|
|
529
|
-
}
|
|
530
|
-
const existingConfigs = report.configs.filter(config => config.exists);
|
|
531
|
-
const missingConfigCount = report.configs.length - existingConfigs.length;
|
|
532
|
-
if (existingConfigs.length > 0) {
|
|
533
|
-
for (const config of existingConfigs) {
|
|
534
|
-
const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
|
|
535
|
-
const configStatus = config.parseError
|
|
536
|
-
? 'WARN'
|
|
537
|
-
: !config.token
|
|
538
|
-
? 'MISSING'
|
|
539
|
-
: hasMismatch
|
|
540
|
-
? 'MISMATCH'
|
|
541
|
-
: 'OK';
|
|
542
|
-
const tool = toolName(config.path);
|
|
543
|
-
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
|
544
|
-
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
|
|
549
|
-
}
|
|
550
|
-
if (missingConfigCount > 0)
|
|
551
|
-
lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
552
|
-
lines.push('');
|
|
553
|
-
// Connectivity result
|
|
57
|
+
// Daemon status
|
|
58
|
+
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
|
|
59
|
+
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
|
|
60
|
+
// Extension status
|
|
61
|
+
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
|
|
62
|
+
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
|
|
63
|
+
// Connectivity
|
|
554
64
|
if (report.connectivity) {
|
|
555
|
-
const
|
|
556
|
-
const
|
|
557
|
-
? `
|
|
558
|
-
: `
|
|
559
|
-
lines.push(
|
|
65
|
+
const connIcon = report.connectivity.ok ? chalk.green('[OK]') : chalk.red('[FAIL]');
|
|
66
|
+
const detail = report.connectivity.ok
|
|
67
|
+
? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
68
|
+
: `failed (${report.connectivity.error ?? 'unknown'})`;
|
|
69
|
+
lines.push(`${connIcon} Connectivity: ${detail}`);
|
|
560
70
|
}
|
|
561
71
|
else {
|
|
562
|
-
lines.push(
|
|
72
|
+
lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
|
|
563
73
|
}
|
|
564
|
-
lines.push(statusLine(hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`));
|
|
565
74
|
if (report.issues.length) {
|
|
566
75
|
lines.push('', chalk.yellow('Issues:'));
|
|
567
|
-
for (const issue of report.issues)
|
|
76
|
+
for (const issue of report.issues) {
|
|
568
77
|
lines.push(chalk.dim(` • ${issue}`));
|
|
78
|
+
}
|
|
569
79
|
}
|
|
570
|
-
if (report.
|
|
571
|
-
lines.push('', chalk.
|
|
572
|
-
for (const warning of report.warnings)
|
|
573
|
-
lines.push(chalk.dim(` • ${warning}`));
|
|
80
|
+
else if (report.daemonRunning && report.extensionConnected) {
|
|
81
|
+
lines.push('', chalk.green('Everything looks good!'));
|
|
574
82
|
}
|
|
575
83
|
return lines.join('\n');
|
|
576
84
|
}
|
|
577
|
-
async function confirmPrompt(question) {
|
|
578
|
-
const rl = createInterface({ input, output });
|
|
579
|
-
try {
|
|
580
|
-
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
581
|
-
return answer === 'y' || answer === 'yes';
|
|
582
|
-
}
|
|
583
|
-
finally {
|
|
584
|
-
rl.close();
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
export function writeFileWithMkdir(filePath, content) {
|
|
588
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
589
|
-
fs.writeFileSync(filePath, content, 'utf-8');
|
|
590
|
-
}
|
|
591
|
-
export async function applyBrowserDoctorFix(report, opts = {}) {
|
|
592
|
-
const token = opts.token ?? report.recommendedToken;
|
|
593
|
-
if (!token)
|
|
594
|
-
throw new Error('No Playwright MCP token is available to write. Provide --token first.');
|
|
595
|
-
const fp = getTokenFingerprint(token);
|
|
596
|
-
const plannedWrites = [];
|
|
597
|
-
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
|
598
|
-
const shellStatus = report.shellFiles.find(s => s.path === shellPath);
|
|
599
|
-
if (shellStatus?.fingerprint !== fp)
|
|
600
|
-
plannedWrites.push(shellPath);
|
|
601
|
-
for (const config of report.configs) {
|
|
602
|
-
if (!config.writable)
|
|
603
|
-
continue;
|
|
604
|
-
if (config.fingerprint === fp)
|
|
605
|
-
continue; // already correct
|
|
606
|
-
plannedWrites.push(config.path);
|
|
607
|
-
}
|
|
608
|
-
if (plannedWrites.length === 0) {
|
|
609
|
-
console.log(chalk.green('All config files are already up to date.'));
|
|
610
|
-
return [];
|
|
611
|
-
}
|
|
612
|
-
if (!opts.yes) {
|
|
613
|
-
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
|
|
614
|
-
if (!ok)
|
|
615
|
-
return [];
|
|
616
|
-
}
|
|
617
|
-
const written = [];
|
|
618
|
-
if (plannedWrites.includes(shellPath)) {
|
|
619
|
-
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
|
620
|
-
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
|
|
621
|
-
written.push(shellPath);
|
|
622
|
-
}
|
|
623
|
-
for (const config of report.configs) {
|
|
624
|
-
if (!plannedWrites.includes(config.path))
|
|
625
|
-
continue;
|
|
626
|
-
if (config.parseError)
|
|
627
|
-
continue;
|
|
628
|
-
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
|
629
|
-
const next = config.format === 'toml'
|
|
630
|
-
? upsertTomlConfigToken(before, token)
|
|
631
|
-
: upsertJsonConfigToken(before, token, config.path);
|
|
632
|
-
writeFileWithMkdir(config.path, next);
|
|
633
|
-
written.push(config.path);
|
|
634
|
-
}
|
|
635
|
-
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
636
|
-
return written;
|
|
637
|
-
}
|