@jackwener/opencli 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/{CLI-CREATOR.md → CLI-EXPLORER.md} +15 -11
  2. package/CLI-ONESHOT.md +216 -0
  3. package/LICENSE +28 -0
  4. package/README.md +114 -63
  5. package/README.zh-CN.md +115 -63
  6. package/SKILL.md +25 -6
  7. package/dist/browser.d.ts +53 -10
  8. package/dist/browser.js +491 -111
  9. package/dist/browser.test.d.ts +1 -0
  10. package/dist/browser.test.js +56 -0
  11. package/dist/build-manifest.js +4 -0
  12. package/dist/cli-manifest.json +279 -3
  13. package/dist/clis/boss/search.js +186 -30
  14. package/dist/clis/twitter/delete.d.ts +1 -0
  15. package/dist/clis/twitter/delete.js +73 -0
  16. package/dist/clis/twitter/followers.d.ts +1 -0
  17. package/dist/clis/twitter/followers.js +104 -0
  18. package/dist/clis/twitter/following.d.ts +1 -0
  19. package/dist/clis/twitter/following.js +90 -0
  20. package/dist/clis/twitter/like.d.ts +1 -0
  21. package/dist/clis/twitter/like.js +69 -0
  22. package/dist/clis/twitter/notifications.d.ts +1 -0
  23. package/dist/clis/twitter/notifications.js +109 -0
  24. package/dist/clis/twitter/post.d.ts +1 -0
  25. package/dist/clis/twitter/post.js +63 -0
  26. package/dist/clis/twitter/reply.d.ts +1 -0
  27. package/dist/clis/twitter/reply.js +57 -0
  28. package/dist/clis/v2ex/daily.d.ts +1 -0
  29. package/dist/clis/v2ex/daily.js +98 -0
  30. package/dist/clis/v2ex/me.d.ts +1 -0
  31. package/dist/clis/v2ex/me.js +99 -0
  32. package/dist/clis/v2ex/notifications.d.ts +1 -0
  33. package/dist/clis/v2ex/notifications.js +72 -0
  34. package/dist/doctor.d.ts +50 -0
  35. package/dist/doctor.js +372 -0
  36. package/dist/doctor.test.d.ts +1 -0
  37. package/dist/doctor.test.js +114 -0
  38. package/dist/main.js +47 -5
  39. package/dist/output.test.d.ts +1 -0
  40. package/dist/output.test.js +20 -0
  41. package/dist/registry.d.ts +4 -0
  42. package/dist/registry.js +1 -0
  43. package/dist/runtime.d.ts +3 -1
  44. package/dist/runtime.js +2 -2
  45. package/package.json +2 -2
  46. package/src/browser.test.ts +77 -0
  47. package/src/browser.ts +541 -99
  48. package/src/build-manifest.ts +4 -0
  49. package/src/clis/boss/search.ts +196 -29
  50. package/src/clis/twitter/delete.ts +78 -0
  51. package/src/clis/twitter/followers.ts +119 -0
  52. package/src/clis/twitter/following.ts +105 -0
  53. package/src/clis/twitter/like.ts +74 -0
  54. package/src/clis/twitter/notifications.ts +119 -0
  55. package/src/clis/twitter/post.ts +68 -0
  56. package/src/clis/twitter/reply.ts +62 -0
  57. package/src/clis/v2ex/daily.ts +105 -0
  58. package/src/clis/v2ex/me.ts +103 -0
  59. package/src/clis/v2ex/notifications.ts +77 -0
  60. package/src/doctor.test.ts +133 -0
  61. package/src/doctor.ts +424 -0
  62. package/src/main.ts +47 -4
  63. package/src/output.test.ts +27 -0
  64. package/src/registry.ts +5 -0
  65. package/src/runtime.ts +2 -1
package/src/browser.ts CHANGED
@@ -1,28 +1,199 @@
1
1
  /**
2
- * Browser interaction via Playwright MCP Bridge extension.
3
- * Connects to an existing Chrome browser through the extension's stdio JSON-RPC.
2
+ * Browser interaction via Chrome DevTools Protocol.
3
+ * Connects to an existing Chrome browser through CDP auto-discovery or extension bridge.
4
4
  */
5
5
 
6
6
  import { spawn, execSync, type ChildProcess } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
8
+ import * as net from 'node:net';
7
9
  import { fileURLToPath } from 'node:url';
8
10
  import * as fs from 'node:fs';
9
11
  import * as os from 'node:os';
10
12
  import * as path from 'node:path';
11
13
  import { formatSnapshot } from './snapshotFormatter.js';
12
14
 
15
+ /**
16
+ * Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
17
+ *
18
+ * Starting with Chrome 144, users can enable remote debugging from
19
+ * chrome://inspect#remote-debugging without any command-line flags.
20
+ * Chrome writes the active port and browser GUID to a DevToolsActivePort file
21
+ * in the user data directory, which we read to construct the WebSocket endpoint.
22
+ *
23
+ * Priority: OPENCLI_CDP_ENDPOINT env > DevToolsActivePort auto-discovery > --extension fallback
24
+ */
25
+
26
+ /** Quick TCP port probe to verify Chrome is actually listening */
27
+ function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Promise<boolean> {
28
+ return new Promise(resolve => {
29
+ const sock = net.createConnection({ port, host });
30
+ sock.setTimeout(timeoutMs);
31
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
32
+ sock.on('error', () => resolve(false));
33
+ sock.on('timeout', () => { sock.destroy(); resolve(false); });
34
+ });
35
+ }
36
+
37
+ export async function discoverChromeEndpoint(): Promise<string | null> {
38
+ const candidates: string[] = [];
39
+
40
+ // User-specified Chrome data dir takes highest priority
41
+ if (process.env.CHROME_USER_DATA_DIR) {
42
+ candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
43
+ }
44
+
45
+ // Standard Chrome/Edge user data dirs per platform
46
+ if (process.platform === 'win32') {
47
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
48
+ candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
49
+ candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
50
+ } else if (process.platform === 'darwin') {
51
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
52
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
53
+ } else {
54
+ candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
55
+ candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
56
+ candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
57
+ }
58
+
59
+ for (const filePath of candidates) {
60
+ try {
61
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
62
+ const lines = content.split('\n');
63
+ if (lines.length >= 2) {
64
+ const port = parseInt(lines[0], 10);
65
+ const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
66
+ if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
67
+ const endpoint = `ws://127.0.0.1:${port}${browserPath}`;
68
+ // Verify the port is actually reachable (Chrome may have closed, leaving a stale file)
69
+ if (await isPortReachable(port)) {
70
+ return endpoint;
71
+ }
72
+ }
73
+ }
74
+ } catch {}
75
+ }
76
+ return null;
77
+ }
78
+
13
79
  // Read version from package.json (single source of truth)
14
80
  const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
15
81
  const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
16
82
 
17
- const EXTENSION_LOCK_TIMEOUT = parseInt(process.env.OPENCLI_EXTENSION_LOCK_TIMEOUT ?? '120', 10);
18
- const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INTERVAL ?? '1', 10);
19
83
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
20
- const LOCK_DIR = path.join(os.tmpdir(), 'opencli-mcp-lock');
84
+ const STDERR_BUFFER_LIMIT = 16 * 1024;
85
+ const TAB_CLEANUP_TIMEOUT_MS = 2000;
86
+ let _cachedMcpServerPath: string | null | undefined;
87
+
88
+ type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
89
+ type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
90
+
91
+ type ConnectFailureInput = {
92
+ kind: ConnectFailureKind;
93
+ mode: 'extension' | 'cdp';
94
+ timeout: number;
95
+ hasExtensionToken: boolean;
96
+ tokenFingerprint?: string | null;
97
+ stderr?: string;
98
+ exitCode?: number | null;
99
+ rawMessage?: string;
100
+ };
101
+
102
+ export function getTokenFingerprint(token: string | undefined): string | null {
103
+ if (!token) return null;
104
+ return createHash('sha256').update(token).digest('hex').slice(0, 8);
105
+ }
106
+
107
+ export function formatBrowserConnectError(input: ConnectFailureInput): Error {
108
+ const stderr = input.stderr?.trim();
109
+ const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
110
+ const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
111
+
112
+ if (input.mode === 'extension') {
113
+ if (input.kind === 'missing-token') {
114
+ return new Error(
115
+ 'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
116
+ 'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
117
+ 'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
118
+ suffix,
119
+ );
120
+ }
121
+
122
+ if (input.kind === 'extension-not-installed') {
123
+ return new Error(
124
+ 'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
125
+ 'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
126
+ 'If Chrome shows an approval dialog, click Allow.' +
127
+ suffix,
128
+ );
129
+ }
130
+
131
+ if (input.kind === 'extension-timeout') {
132
+ const likelyCause = input.hasExtensionToken
133
+ ? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
134
+ : 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
135
+ return new Error(
136
+ `Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
137
+ `${likelyCause} If a browser prompt is visible, click Allow. You can also switch to Chrome remote debugging mode with OPENCLI_USE_CDP=1 as a fallback.` +
138
+ suffix,
139
+ );
140
+ }
141
+ }
142
+
143
+ if (input.mode === 'cdp' && input.kind === 'cdp-timeout') {
144
+ return new Error(
145
+ `Timed out connecting to browser via CDP (${input.timeout}s).\n\n` +
146
+ 'Make sure Chrome is running and remote debugging is enabled at chrome://inspect#remote-debugging, or set OPENCLI_CDP_ENDPOINT explicitly.' +
147
+ suffix,
148
+ );
149
+ }
150
+
151
+ if (input.kind === 'mcp-init') {
152
+ return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
153
+ }
154
+
155
+ if (input.kind === 'process-exit') {
156
+ return new Error(
157
+ `Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
158
+ suffix,
159
+ );
160
+ }
161
+
162
+ return new Error(input.rawMessage ?? 'Failed to connect to browser');
163
+ }
164
+
165
+ function inferConnectFailureKind(args: {
166
+ mode: 'extension' | 'cdp';
167
+ hasExtensionToken: boolean;
168
+ stderr: string;
169
+ rawMessage?: string;
170
+ exited?: boolean;
171
+ }): ConnectFailureKind {
172
+ const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
173
+
174
+ if (args.mode === 'extension' && !args.hasExtensionToken)
175
+ return 'missing-token';
176
+ if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
177
+ return 'extension-not-installed';
178
+ if (args.rawMessage?.startsWith('MCP init failed:'))
179
+ return 'mcp-init';
180
+ if (args.exited)
181
+ return 'process-exit';
182
+ if (args.mode === 'extension')
183
+ return 'extension-timeout';
184
+ if (args.mode === 'cdp')
185
+ return 'cdp-timeout';
186
+ return 'unknown';
187
+ }
21
188
 
22
189
  // JSON-RPC helpers
23
190
  let _nextId = 1;
24
- function jsonRpcRequest(method: string, params: Record<string, any> = {}): string {
25
- return JSON.stringify({ jsonrpc: '2.0', id: _nextId++, method, params }) + '\n';
191
+ function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
192
+ const id = _nextId++;
193
+ return {
194
+ id,
195
+ message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
196
+ };
26
197
  }
27
198
 
28
199
  import type { IPage } from './types.js';
@@ -31,11 +202,10 @@ import type { IPage } from './types.js';
31
202
  * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
32
203
  */
33
204
  export class Page implements IPage {
34
- constructor(private _send: (msg: string) => void, private _recv: () => Promise<any>) {}
205
+ constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
35
206
 
36
207
  async call(method: string, params: Record<string, any> = {}): Promise<any> {
37
- this._send(jsonRpcRequest(method, params));
38
- const resp = await this._recv();
208
+ const resp = await this._request(method, params);
39
209
  if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
40
210
  // Extract text content from MCP result
41
211
  const result = resp.result;
@@ -231,41 +401,172 @@ export class Page implements IPage {
231
401
  * Playwright MCP process manager.
232
402
  */
233
403
  export class PlaywrightMCP {
404
+ private static _activeInsts: Set<PlaywrightMCP> = new Set();
405
+ private static _cleanupRegistered = false;
406
+
407
+ private static _registerGlobalCleanup() {
408
+ if (this._cleanupRegistered) return;
409
+ this._cleanupRegistered = true;
410
+ const cleanup = () => {
411
+ for (const inst of this._activeInsts) {
412
+ if (inst._proc && !inst._proc.killed) {
413
+ try { inst._proc.kill('SIGKILL'); } catch {}
414
+ }
415
+ }
416
+ };
417
+ process.on('exit', cleanup);
418
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
419
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
420
+ }
421
+
234
422
  private _proc: ChildProcess | null = null;
235
423
  private _buffer = '';
236
- private _waiters: Array<(data: any) => void> = [];
237
- private _lockAcquired = false;
238
- private _initialTabCount = 0;
424
+ private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
425
+ private _initialTabIdentities: string[] = [];
426
+ private _closingPromise: Promise<void> | null = null;
427
+ private _state: PlaywrightMCPState = 'idle';
239
428
 
240
429
  private _page: Page | null = null;
241
430
 
242
- async connect(opts: { timeout?: number } = {}): Promise<Page> {
243
- await this._acquireLock();
244
- const timeout = opts.timeout ?? CONNECT_TIMEOUT;
431
+ get state(): PlaywrightMCPState {
432
+ return this._state;
433
+ }
434
+
435
+ private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
436
+ return new Promise<any>((resolve, reject) => {
437
+ if (!this._proc?.stdin?.writable) {
438
+ reject(new Error('Playwright MCP process is not writable'));
439
+ return;
440
+ }
441
+ const { id, message } = createJsonRpcRequest(method, params);
442
+ this._pending.set(id, { resolve, reject });
443
+ this._proc.stdin.write(message, (err) => {
444
+ if (!err) return;
445
+ this._pending.delete(id);
446
+ reject(err);
447
+ });
448
+ });
449
+ }
450
+
451
+ private _rejectPendingRequests(error: Error): void {
452
+ const pending = [...this._pending.values()];
453
+ this._pending.clear();
454
+ for (const waiter of pending) waiter.reject(error);
455
+ }
456
+
457
+ private _resetAfterFailedConnect(): void {
458
+ const proc = this._proc;
459
+ this._page = null;
460
+ this._proc = null;
461
+ this._buffer = '';
462
+ this._initialTabIdentities = [];
463
+ this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
464
+ PlaywrightMCP._activeInsts.delete(this);
465
+ if (proc && !proc.killed) {
466
+ try { proc.kill('SIGKILL'); } catch {}
467
+ }
468
+ }
469
+
470
+ async connect(opts: { timeout?: number; forceExtension?: boolean } = {}): Promise<Page> {
471
+ if (this._state === 'connected' && this._page) return this._page;
472
+ if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
473
+ if (this._state === 'closing') throw new Error('Playwright MCP is closing');
474
+ if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
475
+
245
476
  const mcpPath = findMcpServerPath();
246
477
  if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
247
478
 
248
- return new Promise<Page>((resolve, reject) => {
249
- const timer = setTimeout(() => reject(new Error(`Timed out connecting to browser (${timeout}s)`)), timeout * 1000);
479
+ PlaywrightMCP._registerGlobalCleanup();
480
+ PlaywrightMCP._activeInsts.add(this);
481
+ this._state = 'connecting';
482
+ const timeout = opts.timeout ?? CONNECT_TIMEOUT;
250
483
 
251
- const mcpArgs = [mcpPath, '--extension'];
252
- if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
253
- mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
484
+ // Connection priority:
485
+ // 1. OPENCLI_CDP_ENDPOINT env var → explicit CDP endpoint
486
+ // 2. OPENCLI_USE_CDP=1 → auto-discover via DevToolsActivePort
487
+ // 3. Default → --extension mode (Playwright MCP Bridge)
488
+ // Some anti-bot sites (e.g. BOSS Zhipin) detect CDP — use forceExtension to bypass.
489
+ const forceExt = opts.forceExtension || process.env.OPENCLI_FORCE_EXTENSION === '1';
490
+ let cdpEndpoint: string | null = null;
491
+ if (!forceExt) {
492
+ if (process.env.OPENCLI_CDP_ENDPOINT) {
493
+ cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
494
+ } else if (process.env.OPENCLI_USE_CDP === '1') {
495
+ cdpEndpoint = await discoverChromeEndpoint();
496
+ }
254
497
  }
255
498
 
256
- this._proc = spawn('node', mcpArgs, {
257
- stdio: ['pipe', 'pipe', 'pipe'],
258
- env: { ...process.env, ...(process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN ? { PLAYWRIGHT_MCP_EXTENSION_TOKEN: process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN } : {}) },
259
- });
499
+ return new Promise<Page>((resolve, reject) => {
500
+ const isDebug = process.env.DEBUG?.includes('opencli:mcp');
501
+ const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
502
+ const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension';
503
+ const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
504
+ const tokenFingerprint = getTokenFingerprint(extensionToken);
505
+ let stderrBuffer = '';
506
+ let settled = false;
507
+
508
+ const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
509
+ if (settled) return;
510
+ settled = true;
511
+ this._state = 'idle';
512
+ clearTimeout(timer);
513
+ this._resetAfterFailedConnect();
514
+ reject(formatBrowserConnectError({
515
+ kind,
516
+ mode,
517
+ timeout,
518
+ hasExtensionToken: !!extensionToken,
519
+ tokenFingerprint,
520
+ stderr: stderrBuffer,
521
+ exitCode: extra.exitCode,
522
+ rawMessage: extra.rawMessage,
523
+ }));
524
+ };
525
+
526
+ const settleSuccess = (pageToResolve: Page) => {
527
+ if (settled) return;
528
+ settled = true;
529
+ this._state = 'connected';
530
+ clearTimeout(timer);
531
+ resolve(pageToResolve);
532
+ };
533
+
534
+ const timer = setTimeout(() => {
535
+ debugLog('Connection timed out');
536
+ settleError(inferConnectFailureKind({
537
+ mode,
538
+ hasExtensionToken: !!extensionToken,
539
+ stderr: stderrBuffer,
540
+ }));
541
+ }, timeout * 1000);
542
+
543
+ const mcpArgs: string[] = [mcpPath];
544
+ if (cdpEndpoint) {
545
+ mcpArgs.push('--cdp-endpoint', cdpEndpoint);
546
+ } else {
547
+ mcpArgs.push('--extension');
548
+ }
549
+ if (process.env.OPENCLI_VERBOSE) {
550
+ console.error(`[opencli] CDP mode: ${cdpEndpoint ? `auto-discovered ${cdpEndpoint}` : 'fallback to --extension'}`);
551
+ if (mode === 'extension') {
552
+ console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
553
+ }
554
+ }
555
+ if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
556
+ mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
557
+ }
558
+ debugLog(`Spawning node ${mcpArgs.join(' ')}`);
559
+
560
+ this._proc = spawn('node', mcpArgs, {
561
+ stdio: ['pipe', 'pipe', 'pipe'],
562
+ env: { ...process.env },
563
+ });
260
564
 
261
565
  // Increase max listeners to avoid warnings
262
566
  this._proc.setMaxListeners(20);
263
567
  if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
264
568
 
265
- const page = new Page(
266
- (msg) => { if (this._proc?.stdin?.writable) this._proc.stdin.write(msg); },
267
- () => new Promise<any>((res) => { this._waiters.push(res); }),
268
- );
569
+ const page = new Page((method, params = {}) => this._sendRequest(method, params));
269
570
  this._page = page;
270
571
 
271
572
  this._proc.stdout?.on('data', (chunk: Buffer) => {
@@ -274,107 +575,238 @@ export class PlaywrightMCP {
274
575
  this._buffer = lines.pop() ?? '';
275
576
  for (const line of lines) {
276
577
  if (!line.trim()) continue;
578
+ debugLog(`RECV: ${line}`);
277
579
  try {
278
580
  const parsed = JSON.parse(line);
279
- const waiter = this._waiters.shift();
280
- if (waiter) waiter(parsed);
281
- } catch {}
581
+ if (typeof parsed?.id === 'number') {
582
+ const waiter = this._pending.get(parsed.id);
583
+ if (waiter) {
584
+ this._pending.delete(parsed.id);
585
+ waiter.resolve(parsed);
586
+ }
587
+ }
588
+ } catch (e) {
589
+ debugLog(`Parse error: ${e}`);
590
+ }
282
591
  }
283
592
  });
284
593
 
285
- this._proc.stderr?.on('data', () => {});
286
- this._proc.on('error', (err) => { clearTimeout(timer); reject(err); });
594
+ this._proc.stderr?.on('data', (chunk: Buffer) => {
595
+ const text = chunk.toString();
596
+ stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
597
+ debugLog(`STDERR: ${text}`);
598
+ });
599
+ this._proc.on('error', (err) => {
600
+ debugLog(`Subprocess error: ${err.message}`);
601
+ this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
602
+ settleError('process-exit', { rawMessage: err.message });
603
+ });
604
+ this._proc.on('close', (code) => {
605
+ debugLog(`Subprocess closed with code ${code}`);
606
+ this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
607
+ if (!settled) {
608
+ settleError(inferConnectFailureKind({
609
+ mode,
610
+ hasExtensionToken: !!extensionToken,
611
+ stderr: stderrBuffer,
612
+ exited: true,
613
+ }), { exitCode: code });
614
+ }
615
+ });
287
616
 
288
617
  // Initialize: send initialize request
289
- const initMsg = jsonRpcRequest('initialize', {
618
+ debugLog('Waiting for initialize response...');
619
+ this._sendRequest('initialize', {
290
620
  protocolVersion: '2024-11-05',
291
621
  capabilities: {},
292
622
  clientInfo: { name: 'opencli', version: PKG_VERSION },
293
- });
294
- this._proc.stdin?.write(initMsg);
295
-
296
- // Wait for initialize response, then send initialized notification
297
- const origRecv = () => new Promise<any>((res) => { this._waiters.push(res); });
298
- origRecv().then((resp) => {
299
- if (resp.error) { clearTimeout(timer); reject(new Error(`MCP init failed: ${resp.error.message}`)); return; }
300
- this._proc?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
623
+ }).then((resp) => {
624
+ debugLog('Got initialize response');
625
+ if (resp.error) {
626
+ settleError(inferConnectFailureKind({
627
+ mode,
628
+ hasExtensionToken: !!extensionToken,
629
+ stderr: stderrBuffer,
630
+ rawMessage: `MCP init failed: ${resp.error.message}`,
631
+ }), { rawMessage: resp.error.message });
632
+ return;
633
+ }
634
+
635
+ const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
636
+ debugLog(`SEND: ${initializedMsg.trim()}`);
637
+ this._proc?.stdin?.write(initializedMsg);
301
638
 
302
639
  // Get initial tab count for cleanup
640
+ debugLog('Fetching initial tabs count...');
303
641
  page.tabs().then((tabs: any) => {
304
- if (typeof tabs === 'string') {
305
- this._initialTabCount = (tabs.match(/Tab \d+/g) || []).length;
306
- } else if (Array.isArray(tabs)) {
307
- this._initialTabCount = tabs.length;
308
- }
309
- clearTimeout(timer);
310
- resolve(page);
311
- }).catch(() => { clearTimeout(timer); resolve(page); });
312
- }).catch((err) => { clearTimeout(timer); reject(err); });
642
+ debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
643
+ this._initialTabIdentities = extractTabIdentities(tabs);
644
+ settleSuccess(page);
645
+ }).catch((err) => {
646
+ debugLog(`Tabs fetch error: ${err.message}`);
647
+ settleSuccess(page);
648
+ });
649
+ }).catch((err) => {
650
+ debugLog(`Init promise rejected: ${err.message}`);
651
+ settleError('mcp-init', { rawMessage: err.message });
652
+ });
313
653
  });
314
654
  }
315
655
 
316
656
  async close(): Promise<void> {
317
- try {
318
- // Close tabs opened during this session (site tabs + extension tabs)
319
- if (this._page && this._proc && !this._proc.killed) {
320
- try {
321
- const tabs = await this._page.tabs();
322
- const tabStr = typeof tabs === 'string' ? tabs : JSON.stringify(tabs);
323
- const allTabs = tabStr.match(/Tab (\d+)/g) || [];
324
- const currentTabCount = allTabs.length;
325
-
326
- // Close tabs in reverse order to avoid index shifting issues
327
- // Keep the original tabs that existed before the command started
328
- if (currentTabCount > this._initialTabCount && this._initialTabCount > 0) {
329
- for (let i = currentTabCount - 1; i >= this._initialTabCount; i--) {
330
- try { await this._page.closeTab(i); } catch {}
657
+ if (this._closingPromise) return this._closingPromise;
658
+ if (this._state === 'closed') return;
659
+ this._state = 'closing';
660
+ this._closingPromise = (async () => {
661
+ try {
662
+ // Close tabs opened during this session (site tabs + extension tabs)
663
+ if (this._page && this._proc && !this._proc.killed) {
664
+ try {
665
+ const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
666
+ const tabEntries = extractTabEntries(tabs);
667
+ const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
668
+ for (const index of tabsToClose) {
669
+ try { await this._page.closeTab(index); } catch {}
331
670
  }
671
+ } catch {}
672
+ }
673
+ if (this._proc && !this._proc.killed) {
674
+ this._proc.kill('SIGTERM');
675
+ const exited = await new Promise<boolean>((res) => {
676
+ let done = false;
677
+ const finish = (value: boolean) => {
678
+ if (done) return;
679
+ done = true;
680
+ res(value);
681
+ };
682
+ this._proc?.once('exit', () => finish(true));
683
+ setTimeout(() => finish(false), 3000);
684
+ });
685
+ if (!exited && this._proc && !this._proc.killed) {
686
+ try { this._proc.kill('SIGKILL'); } catch {}
332
687
  }
333
- } catch {}
334
- }
335
- if (this._proc && !this._proc.killed) {
336
- this._proc.kill('SIGTERM');
337
- await new Promise<void>((res) => { this._proc?.on('exit', () => res()); setTimeout(res, 3000); });
688
+ }
689
+ } finally {
690
+ this._rejectPendingRequests(new Error('Playwright MCP session closed'));
691
+ this._page = null;
692
+ this._proc = null;
693
+ this._state = 'closed';
694
+ PlaywrightMCP._activeInsts.delete(this);
338
695
  }
339
- } finally {
340
- this._page = null;
341
- this._releaseLock();
342
- }
696
+ })();
697
+ return this._closingPromise;
343
698
  }
699
+ }
344
700
 
345
- private async _acquireLock(): Promise<void> {
346
- const start = Date.now();
347
- while (true) {
348
- try { fs.mkdirSync(LOCK_DIR, { recursive: false }); this._lockAcquired = true; return; }
349
- catch (e: any) {
350
- if (e.code !== 'EEXIST') throw e;
351
- if ((Date.now() - start) / 1000 > EXTENSION_LOCK_TIMEOUT) {
352
- // Force remove stale lock
353
- try { fs.rmdirSync(LOCK_DIR); } catch {}
354
- continue;
355
- }
356
- await new Promise(r => setTimeout(r, EXTENSION_LOCK_POLL * 1000));
357
- }
358
- }
701
+ function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
702
+ if (Array.isArray(raw)) {
703
+ return raw.map((tab: any, index: number) => ({
704
+ index,
705
+ identity: [
706
+ tab?.id ?? '',
707
+ tab?.url ?? '',
708
+ tab?.title ?? '',
709
+ tab?.name ?? '',
710
+ ].join('|'),
711
+ }));
712
+ }
713
+
714
+ if (typeof raw === 'string') {
715
+ return raw
716
+ .split('\n')
717
+ .map(line => line.trim())
718
+ .filter(Boolean)
719
+ .map(line => {
720
+ const match = line.match(/Tab\s+(\d+)\s*(.*)$/);
721
+ if (!match) return null;
722
+ return {
723
+ index: parseInt(match[1], 10),
724
+ identity: match[2].trim() || `tab-${match[1]}`,
725
+ };
726
+ })
727
+ .filter((entry): entry is { index: number; identity: string } => entry !== null);
359
728
  }
360
729
 
361
- private _releaseLock(): void {
362
- if (this._lockAcquired) {
363
- try { fs.rmdirSync(LOCK_DIR); } catch {}
364
- this._lockAcquired = false;
730
+ return [];
731
+ }
732
+
733
+ function extractTabIdentities(raw: any): string[] {
734
+ return extractTabEntries(raw).map(tab => tab.identity);
735
+ }
736
+
737
+ function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
738
+ if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
739
+ const remaining = new Map<string, number>();
740
+ for (const identity of initialIdentities) {
741
+ remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
742
+ }
743
+
744
+ const tabsToClose: number[] = [];
745
+ for (const tab of currentTabs) {
746
+ const count = remaining.get(tab.identity) ?? 0;
747
+ if (count > 0) {
748
+ remaining.set(tab.identity, count - 1);
749
+ continue;
365
750
  }
751
+ tabsToClose.push(tab.index);
366
752
  }
753
+
754
+ return tabsToClose.sort((a, b) => b - a);
367
755
  }
368
756
 
757
+ function appendLimited(current: string, chunk: string, limit: number): string {
758
+ const next = current + chunk;
759
+ if (next.length <= limit) return next;
760
+ return next.slice(-limit);
761
+ }
762
+
763
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
764
+ return new Promise<T>((resolve, reject) => {
765
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
766
+ promise.then(
767
+ (value) => {
768
+ clearTimeout(timer);
769
+ resolve(value);
770
+ },
771
+ (error) => {
772
+ clearTimeout(timer);
773
+ reject(error);
774
+ },
775
+ );
776
+ });
777
+ }
778
+
779
+ export const __test__ = {
780
+ createJsonRpcRequest,
781
+ extractTabEntries,
782
+ diffTabIndexes,
783
+ appendLimited,
784
+ withTimeout,
785
+ };
786
+
369
787
  function findMcpServerPath(): string | null {
788
+ if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
789
+
790
+ const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
791
+ if (envMcp && fs.existsSync(envMcp)) {
792
+ _cachedMcpServerPath = envMcp;
793
+ return _cachedMcpServerPath;
794
+ }
795
+
370
796
  // Check local node_modules first (@playwright/mcp is the modern package)
371
797
  const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
372
- if (fs.existsSync(localMcp)) return localMcp;
798
+ if (fs.existsSync(localMcp)) {
799
+ _cachedMcpServerPath = localMcp;
800
+ return _cachedMcpServerPath;
801
+ }
373
802
 
374
803
  // Check project-relative path
375
804
  const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
376
805
  const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
377
- if (fs.existsSync(projectMcp)) return projectMcp;
806
+ if (fs.existsSync(projectMcp)) {
807
+ _cachedMcpServerPath = projectMcp;
808
+ return _cachedMcpServerPath;
809
+ }
378
810
 
379
811
  // Check common locations
380
812
  const candidates = [
@@ -386,13 +818,19 @@ function findMcpServerPath(): string | null {
386
818
  // Try npx resolution (legacy package name)
387
819
  try {
388
820
  const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
389
- if (result && fs.existsSync(result)) return result;
821
+ if (result && fs.existsSync(result)) {
822
+ _cachedMcpServerPath = result;
823
+ return _cachedMcpServerPath;
824
+ }
390
825
  } catch {}
391
826
 
392
827
  // Try which
393
828
  try {
394
829
  const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
395
- if (result && fs.existsSync(result)) return result;
830
+ if (result && fs.existsSync(result)) {
831
+ _cachedMcpServerPath = result;
832
+ return _cachedMcpServerPath;
833
+ }
396
834
  } catch {}
397
835
 
398
836
  // Search in common npx cache
@@ -400,9 +838,13 @@ function findMcpServerPath(): string | null {
400
838
  if (!fs.existsSync(base)) continue;
401
839
  try {
402
840
  const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
403
- if (found) return found;
841
+ if (found) {
842
+ _cachedMcpServerPath = found;
843
+ return _cachedMcpServerPath;
844
+ }
404
845
  } catch {}
405
846
  }
406
847
 
407
- return null;
848
+ _cachedMcpServerPath = null;
849
+ return _cachedMcpServerPath;
408
850
  }