@romanmatena/browsermonitor 2.0.0

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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +558 -0
  3. package/package.json +53 -0
  4. package/src/agents.llm/browser-monitor-section.md +18 -0
  5. package/src/cli.mjs +202 -0
  6. package/src/http-server.mjs +536 -0
  7. package/src/init.mjs +162 -0
  8. package/src/intro.mjs +36 -0
  9. package/src/logging/LogBuffer.mjs +178 -0
  10. package/src/logging/constants.mjs +19 -0
  11. package/src/logging/dump.mjs +207 -0
  12. package/src/logging/index.mjs +13 -0
  13. package/src/logging/timestamps.mjs +13 -0
  14. package/src/monitor/README.md +10 -0
  15. package/src/monitor/index.mjs +18 -0
  16. package/src/monitor/interactive-mode.mjs +275 -0
  17. package/src/monitor/join-mode.mjs +654 -0
  18. package/src/monitor/open-mode.mjs +889 -0
  19. package/src/monitor/page-monitoring.mjs +199 -0
  20. package/src/monitor/tab-selection.mjs +53 -0
  21. package/src/monitor.mjs +39 -0
  22. package/src/os/README.md +4 -0
  23. package/src/os/wsl/chrome.mjs +503 -0
  24. package/src/os/wsl/detect.mjs +68 -0
  25. package/src/os/wsl/diagnostics.mjs +729 -0
  26. package/src/os/wsl/index.mjs +45 -0
  27. package/src/os/wsl/port-proxy.mjs +190 -0
  28. package/src/settings.mjs +101 -0
  29. package/src/templates/api-help.mjs +212 -0
  30. package/src/templates/cli-commands.mjs +51 -0
  31. package/src/templates/interactive-keys.mjs +33 -0
  32. package/src/templates/ready-help.mjs +33 -0
  33. package/src/templates/section-heading.mjs +141 -0
  34. package/src/templates/table-helper.mjs +73 -0
  35. package/src/templates/wait-for-chrome.mjs +19 -0
  36. package/src/utils/ask.mjs +49 -0
  37. package/src/utils/chrome-profile-path.mjs +37 -0
  38. package/src/utils/colors.mjs +49 -0
  39. package/src/utils/env.mjs +30 -0
  40. package/src/utils/profile-id.mjs +23 -0
  41. package/src/utils/status-line.mjs +47 -0
@@ -0,0 +1,889 @@
1
+ /**
2
+ * Open Mode - launch new Chrome and monitor.
3
+ */
4
+
5
+ import puppeteer from 'puppeteer';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import readline from 'readline';
9
+ import { execSync } from 'child_process';
10
+ import { C, log } from '../utils/colors.mjs';
11
+ import {
12
+ getWindowsHostForWSL,
13
+ getLastCmdStderrAndClear,
14
+ getWindowsLocalAppData,
15
+ detectWindowsChromeCanaryPath,
16
+ printCanaryInstallInstructions,
17
+ killPuppeteerMonitorChromes,
18
+ checkChromeRunning,
19
+ runWslDiagnostics,
20
+ } from '../os/wsl/index.mjs';
21
+ import { LogBuffer, getTimestamp, getFullTimestamp } from '../logging/index.mjs';
22
+ import { createHttpServer } from '../http-server.mjs';
23
+ import { setupPageMonitoring as setupPageMonitoringShared } from './page-monitoring.mjs';
24
+ import { askUserToSelectPage } from './tab-selection.mjs';
25
+ import { askYesNo } from '../utils/ask.mjs';
26
+ import { printReadyHelp, printStatusBlock, KEYS_OPEN } from '../templates/ready-help.mjs';
27
+ import { printApiHelpTable } from '../templates/api-help.mjs';
28
+ import { printModeHeading, printBulletBox } from '../templates/section-heading.mjs';
29
+ import { createTable, printTable } from '../templates/table-helper.mjs';
30
+ import { writeStatusLine, clearStatusLine } from '../utils/status-line.mjs';
31
+ import { getProfileIdFromProjectDir } from '../utils/profile-id.mjs';
32
+
33
+ // Browser and page in module scope for cleanup
34
+ let browser = null;
35
+ let page = null;
36
+ let cleanupDone = false;
37
+ let launchedOnWindows = false;
38
+ let windowsDebugPort = 0;
39
+
40
+
41
+ /**
42
+ * Run in Open Mode - launch new Chrome and monitor
43
+ * @param {string} url - URL to monitor
44
+ * @param {Object} options - Monitor options
45
+ * @param {boolean} options.realtime - Enable realtime mode (default: false = lazy mode)
46
+ * @param {boolean} options.headless - Run in headless mode (default: false = GUI mode)
47
+ * @param {string} options.outputDir - Output directory (default: process.cwd())
48
+ * @param {string[]} options.ignorePatterns - Additional patterns to ignore in console
49
+ * @param {number} options.hardTimeout - Hard timeout in ms (default: 0 = disabled)
50
+ * @param {number} options.defaultTimeout - Default page timeout in ms (default: 30000)
51
+ * @param {number} options.navigationTimeout - Navigation timeout in ms (default: 60000)
52
+ * @param {number} options.httpPort - HTTP server port for dump endpoint (default: 60001, 0 = disabled)
53
+ */
54
+ export async function runOpenMode(url, options = {}) {
55
+ const {
56
+ realtime = false,
57
+ headless = false,
58
+ outputDir = process.cwd(),
59
+ paths = null,
60
+ ignorePatterns = [],
61
+ hardTimeout = 0,
62
+ defaultTimeout = 30_000,
63
+ navigationTimeout = 60_000,
64
+ httpPort = 60001,
65
+ sharedHttpState = null,
66
+ sharedHttpServer = null,
67
+ skipProfileBlock = false,
68
+ } = options;
69
+
70
+ const lazyMode = !realtime;
71
+
72
+ // Create LogBuffer instance for centralized buffer management
73
+ const logBuffer = new LogBuffer({
74
+ outputDir,
75
+ paths,
76
+ lazyMode,
77
+ ignorePatterns,
78
+ });
79
+
80
+ // Chrome profile: stays in project dir or Windows LOCALAPPDATA (not inside .browsermonitor)
81
+ const USER_DATA_DIR = path.join(outputDir, '.browsermonitor-profile');
82
+ // PID file goes into .browsermonitor/ when paths available
83
+ const PID_FILE = paths ? paths.pidFile : path.join(outputDir, '.browsermonitor', 'browsermonitor.pid');
84
+
85
+ // HTTP server for LLM dump endpoint
86
+ let httpServer = null;
87
+
88
+ // ===== CLEANUP FUNCTION =====
89
+ // closeBrowser: true = k (close Chrome and exit), false = q / Ctrl+C (exit only, Chrome keeps running)
90
+ async function cleanup(code = 0, closeBrowser = false) {
91
+ if (cleanupDone) return;
92
+ cleanupDone = true;
93
+
94
+ console.log('');
95
+ log.info(closeBrowser ? 'Cleaning up and closing Chrome...' : 'Cleaning up (Chrome will stay open)...');
96
+
97
+ try {
98
+ if (httpServer) {
99
+ await new Promise((resolve) => {
100
+ httpServer.close(resolve);
101
+ });
102
+ log.dim('HTTP server closed');
103
+ }
104
+ } catch (e) {
105
+ log.error(`Error closing HTTP server: ${e.message}`);
106
+ }
107
+
108
+ try {
109
+ if (browser) {
110
+ if (closeBrowser) {
111
+ if (launchedOnWindows) {
112
+ browser.disconnect();
113
+ try {
114
+ const killed = killPuppeteerMonitorChromes(true);
115
+ if (killed > 0) {
116
+ log.success('Chrome closed');
117
+ } else {
118
+ log.dim('Chrome may have already closed');
119
+ }
120
+ } catch {
121
+ log.dim('Chrome may have already closed');
122
+ }
123
+ } else {
124
+ await browser.close();
125
+ log.success('Browser closed');
126
+ }
127
+ } else {
128
+ browser.disconnect();
129
+ log.success('Disconnected (Chrome still running)');
130
+ }
131
+ }
132
+ } catch (e) {
133
+ log.error(`Error closing browser: ${e.message}`);
134
+ }
135
+
136
+ try {
137
+ fs.unlinkSync(PID_FILE);
138
+ } catch {}
139
+
140
+ process.exit(code);
141
+ }
142
+
143
+ // ===== SIGNAL HANDLERS =====
144
+ process.on('SIGINT', () => {
145
+ console.log('');
146
+ log.dim('Received SIGINT (Ctrl+C)');
147
+ cleanup(0, false);
148
+ });
149
+
150
+ process.on('SIGTERM', () => {
151
+ console.log('');
152
+ log.dim('Received SIGTERM');
153
+ cleanup(0, false);
154
+ });
155
+
156
+ process.on('uncaughtException', (e) => {
157
+ const msg = (e && e.message) || String(e);
158
+ if (/Execution context was destroyed|Target closed|Protocol error/.test(msg)) {
159
+ log.dim(`Navigation/context closed: ${msg.slice(0, 60)}… (continuing)`);
160
+ return;
161
+ }
162
+ log.error(`Uncaught exception: ${e.message}`);
163
+ console.error(e.stack);
164
+ cleanup(1);
165
+ });
166
+
167
+ process.on('unhandledRejection', (e) => {
168
+ const msg = (e && e.message) || String(e);
169
+ if (/Execution context was destroyed|Target closed|Protocol error/.test(msg)) {
170
+ log.dim(`Navigation/context closed: ${msg.slice(0, 60)}… (continuing)`);
171
+ return;
172
+ }
173
+ log.error(`Unhandled rejection: ${e}`);
174
+ cleanup(1);
175
+ });
176
+
177
+ // ===== HARD TIMEOUT (safety net) =====
178
+ if (hardTimeout > 0) {
179
+ setTimeout(() => {
180
+ log.error(`HARD TIMEOUT (${hardTimeout}ms) - forcing exit`);
181
+ cleanup(1);
182
+ }, hardTimeout);
183
+ }
184
+
185
+ // Track monitored pages for tab switching
186
+ let monitoredPages = [];
187
+ let isSelectingTab = false;
188
+ let currentProfilePath = null;
189
+ let activePageCleanup = null;
190
+ let collectingPaused = false;
191
+
192
+ // Output counter for periodic help reminder (same block as initial Ready, every HELP_INTERVAL)
193
+ let outputCounter = 0;
194
+ const HELP_INTERVAL = 5;
195
+ function maybeShowHelp() {
196
+ outputCounter++;
197
+ if (outputCounter % HELP_INTERVAL === 0) {
198
+ printReadyHelp(httpPort, KEYS_OPEN);
199
+ }
200
+ }
201
+
202
+ // In-session help (h key) – full table with session context for Claude Code
203
+ function printHelp() {
204
+ const currentUrl = page?.url?.() || url;
205
+ console.log(`${C.cyan}Launch mode${C.reset} URL: ${C.brightGreen}${currentUrl}${C.reset} │ Dir: ${outputDir}`);
206
+ printApiHelpTable({
207
+ port: httpPort,
208
+ showApi: true,
209
+ showInteractive: true,
210
+ showOutputFiles: true,
211
+ context: {
212
+ consoleLog: logBuffer.CONSOLE_LOG,
213
+ networkLog: logBuffer.NETWORK_LOG,
214
+ networkDir: logBuffer.NETWORK_DIR,
215
+ cookiesDir: logBuffer.COOKIES_DIR,
216
+ domHtml: logBuffer.DOM_HTML,
217
+ screenshot: logBuffer.SCREENSHOT,
218
+ },
219
+ sessionContext: {
220
+ currentUrl,
221
+ profilePath: currentProfilePath,
222
+ },
223
+ });
224
+ }
225
+
226
+ // Use shared HTTP server (started in CLI) or create our own
227
+ if (sharedHttpState && sharedHttpServer) {
228
+ httpServer = sharedHttpServer;
229
+ sharedHttpState.mode = 'launch';
230
+ sharedHttpState.logBuffer = logBuffer;
231
+ sharedHttpState.getPages = () => monitoredPages;
232
+ sharedHttpState.getCollectingPaused = () => collectingPaused;
233
+ sharedHttpState.setCollectingPaused = (v) => { collectingPaused = !!v; };
234
+ sharedHttpState.getAllTabs = async () => {
235
+ if (!browser) return [];
236
+ const allPages = await browser.pages();
237
+ const isUserPage = (p) => {
238
+ const u = p.url();
239
+ return !u.startsWith('chrome://') && !u.startsWith('devtools://') && !u.startsWith('chrome-extension://');
240
+ };
241
+ let pages = allPages.filter(isUserPage);
242
+ const nonBlank = pages.filter(p => p.url() !== 'about:blank');
243
+ if (nonBlank.length > 0) pages = nonBlank;
244
+ return pages.map((p, i) => ({ index: i + 1, url: p.url() }));
245
+ };
246
+ sharedHttpState.switchToTab = async (index) => {
247
+ if (!browser) return { success: false, error: 'Browser not ready' };
248
+ try {
249
+ const allPages = await browser.pages();
250
+ const isUserPage = (p) => {
251
+ const u = p.url();
252
+ return !u.startsWith('chrome://') && !u.startsWith('devtools://') && !u.startsWith('chrome-extension://');
253
+ };
254
+ let pages = allPages.filter(isUserPage);
255
+ const nonBlank = pages.filter(p => p.url() !== 'about:blank');
256
+ if (nonBlank.length > 0) pages = nonBlank;
257
+ if (index < 1 || index > pages.length) return { success: false, error: `Invalid index. Use 1-${pages.length}.` };
258
+ const selectedPage = pages[index - 1];
259
+ page = selectedPage;
260
+ monitoredPages = [selectedPage];
261
+ setupPageMonitoring(selectedPage);
262
+ logBuffer.printConsoleSeparator('TAB SWITCHED');
263
+ logBuffer.printNetworkSeparator('TAB SWITCHED');
264
+ return { success: true, url: selectedPage.url() };
265
+ } catch (e) {
266
+ return { success: false, error: e.message };
267
+ }
268
+ };
269
+ } else {
270
+ httpServer = createHttpServer({
271
+ port: httpPort,
272
+ mode: 'launch',
273
+ logBuffer,
274
+ getPages: () => monitoredPages,
275
+ getCollectingPaused: () => collectingPaused,
276
+ setCollectingPaused: (v) => { collectingPaused = !!v; },
277
+ });
278
+ }
279
+
280
+ // Setup page monitoring (console, request, response events) – shared implementation
281
+ function setupPageMonitoring(targetPage) {
282
+ setupPageMonitoringShared(targetPage, {
283
+ logBuffer,
284
+ getCollectingPaused: () => collectingPaused,
285
+ setActivePageCleanup: (fn) => { activePageCleanup = fn; },
286
+ pageLabel: '',
287
+ });
288
+ }
289
+
290
+ // Switch tabs in Launch mode
291
+ async function switchTabs() {
292
+ if (!browser) {
293
+ log.error('Browser not ready');
294
+ return;
295
+ }
296
+
297
+ try {
298
+ const allPages = await browser.pages();
299
+
300
+ // Filter out internal Chrome pages (but keep about:blank if it's the only one with content)
301
+ const isUserPage = (p) => {
302
+ const pageUrl = p.url();
303
+ return !pageUrl.startsWith('chrome://') &&
304
+ !pageUrl.startsWith('devtools://') &&
305
+ !pageUrl.startsWith('chrome-extension://');
306
+ };
307
+ let pages = allPages.filter(isUserPage);
308
+
309
+ // If we have pages other than about:blank, filter out about:blank
310
+ const nonBlankPages = pages.filter(p => p.url() !== 'about:blank');
311
+ if (nonBlankPages.length > 0) {
312
+ pages = nonBlankPages;
313
+ }
314
+
315
+ if (pages.length === 0) {
316
+ log.warn('No user tabs found');
317
+ return;
318
+ }
319
+
320
+ if (pages.length === 1) {
321
+ log.info('Only one user tab available');
322
+ return;
323
+ }
324
+
325
+ isSelectingTab = true;
326
+ const selectedPage = await askUserToSelectPage(pages);
327
+ isSelectingTab = false;
328
+
329
+ if (selectedPage === null) {
330
+ log.dim('Tab switch cancelled');
331
+ return;
332
+ }
333
+
334
+ // Switch to new page and setup monitoring
335
+ page = selectedPage;
336
+ monitoredPages = [selectedPage];
337
+ setupPageMonitoring(selectedPage);
338
+ log.success(`Now monitoring: ${C.brightCyan}${selectedPage.url()}${C.reset}`);
339
+
340
+ // Add separators to indicate tab switch in logs
341
+ logBuffer.printConsoleSeparator('TAB SWITCHED');
342
+ logBuffer.printNetworkSeparator('TAB SWITCHED');
343
+
344
+ } catch (e) {
345
+ log.error(`Error switching tabs: ${e.message}`);
346
+ isSelectingTab = false;
347
+ }
348
+ }
349
+
350
+ // Setup keyboard input for lazy mode
351
+ function setupKeyboardInput() {
352
+ readline.emitKeypressEvents(process.stdin);
353
+ if (process.stdin.isTTY) {
354
+ process.stdin.setRawMode(true);
355
+ }
356
+
357
+ process.stdin.on('keypress', async (str, key) => {
358
+ if (key.ctrl && key.name === 'c') {
359
+ cleanup(0);
360
+ return;
361
+ }
362
+
363
+ // Ignore keys during tab selection
364
+ if (isSelectingTab) {
365
+ return;
366
+ }
367
+
368
+ // Shortcuts only without modifiers (no Ctrl, Shift, Win)
369
+ if (key.ctrl || key.shift || key.meta) {
370
+ return;
371
+ }
372
+
373
+ if (key.name === 'd') {
374
+ await logBuffer.dumpBuffersToFiles({
375
+ dumpCookies: page ? () => logBuffer.dumpCookiesFromPage(page) : null,
376
+ dumpDom: page ? () => logBuffer.dumpDomFromPage(page) : null,
377
+ dumpScreenshot: page ? () => logBuffer.dumpScreenshotFromPage(page) : null,
378
+ });
379
+ maybeShowHelp();
380
+ } else if (key.name === 'c') {
381
+ logBuffer.clearAllBuffers();
382
+ maybeShowHelp();
383
+ } else if (key.name === 'q') {
384
+ cleanup(0, false);
385
+ } else if (key.name === 's') {
386
+ const stats = logBuffer.getStats();
387
+ const currentUrl = page ? page.url() : 'N/A';
388
+ const tabCount = browser ? (await browser.pages()).length : 0;
389
+ printStatusBlock(stats, currentUrl, tabCount, collectingPaused);
390
+ maybeShowHelp();
391
+ } else if (key.name === 'p') {
392
+ collectingPaused = !collectingPaused;
393
+ log.info(collectingPaused ? 'Collecting stopped (paused). Press p or curl .../start to resume.' : 'Collecting started (resumed).');
394
+ maybeShowHelp();
395
+ } else if (key.name === 'k') {
396
+ log.warn('Closing Chrome and exiting...');
397
+ cleanup(0, true);
398
+ } else if (key.name === 't') {
399
+ await switchTabs();
400
+ maybeShowHelp();
401
+ } else if (key.name === 'h') {
402
+ // Show full help
403
+ printHelp();
404
+ }
405
+ });
406
+ }
407
+
408
+ // Initialize
409
+ printModeHeading('Open mode');
410
+ if (realtime) {
411
+ fs.writeFileSync(logBuffer.CONSOLE_LOG, '');
412
+ logBuffer.clearNetworkDir();
413
+ }
414
+
415
+ writeStatusLine(`${C.dim}Launching browser for ${url}...${C.reset}`);
416
+ const configLines = [
417
+ `${C.cyan}Configuration${C.reset}`,
418
+ ` Mode: ${lazyMode ? `${C.green}LAZY${C.reset} (buffered)` : `${C.yellow}REALTIME${C.reset} (immediate write)`}`,
419
+ ` Browser: ${headless ? `${C.dim}HEADLESS${C.reset}` : `${C.green}GUI${C.reset}`}`,
420
+ ].join('\n');
421
+ clearStatusLine(true);
422
+ const configTable = createTable({ colWidths: [95], tableOpts: { wordWrap: true } });
423
+ configTable.push([configLines]);
424
+ printTable(configTable);
425
+
426
+ if (realtime) {
427
+ logBuffer.printNetworkSeparator('BROWSERMONITOR STARTED');
428
+ logBuffer.logNetwork(`URL: ${url}`);
429
+ logBuffer.logNetwork('');
430
+ }
431
+
432
+ // HTTP server is already started via createHttpServer above
433
+
434
+ // ===== DETECT WSL =====
435
+ const isWSL = (() => {
436
+ try {
437
+ const release = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
438
+ return release.includes('microsoft') || release.includes('wsl');
439
+ } catch { return false; }
440
+ })();
441
+
442
+ // ===== CONFIGURE CHROME PROFILE (only when Chrome will use USER_DATA_DIR) =====
443
+ // On WSL with project on WSL fs (/srv/...), Chrome uses Windows LOCALAPPDATA - skip creating Linux path
444
+ const chromeUsesUserDataDir = !isWSL || outputDir.startsWith('/mnt/');
445
+ if (chromeUsesUserDataDir) {
446
+ const prefsDir = path.join(USER_DATA_DIR, 'Default');
447
+ const prefsFile = path.join(prefsDir, 'Preferences');
448
+ try {
449
+ fs.mkdirSync(prefsDir, { recursive: true });
450
+ let prefs = {};
451
+ if (fs.existsSync(prefsFile)) {
452
+ try {
453
+ prefs = JSON.parse(fs.readFileSync(prefsFile, 'utf8'));
454
+ } catch { /* ignore parse errors, start fresh */ }
455
+ }
456
+ prefs.session = prefs.session || {};
457
+ prefs.session.restore_on_startup = 5;
458
+ prefs.profile = prefs.profile || {};
459
+ prefs.profile.exit_type = 'Normal';
460
+ fs.writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
461
+ } catch (e) {
462
+ log.dim(`Could not configure profile preferences: ${e.message}`);
463
+ }
464
+ }
465
+
466
+ // ===== MAIN TRY/FINALLY BLOCK =====
467
+ try {
468
+ if (isWSL) {
469
+ // === WSL MODE: Launch Chrome on Windows via PowerShell ===
470
+ writeStatusLine(`${C.dim}Detecting Chrome...${C.reset}`);
471
+
472
+ // For launch mode we use only Chrome Canary (isolated from user's regular Chrome)
473
+ const chromePath = detectWindowsChromeCanaryPath();
474
+ if (!chromePath) {
475
+ clearStatusLine();
476
+ printCanaryInstallInstructions();
477
+ log.info('Install Chrome Canary and try again.');
478
+ process.exit(1);
479
+ }
480
+
481
+ // In launch mode, kill any existing browsermonitor Chrome processes
482
+ clearStatusLine(true);
483
+ const killed = killPuppeteerMonitorChromes();
484
+ if (killed > 0) {
485
+ await new Promise(r => setTimeout(r, 1000));
486
+ }
487
+
488
+ // Check if Chrome is running - with or without debug port
489
+ const chromeStatus = checkChromeRunning();
490
+ let chromeAlreadyRunning = false;
491
+ let useExistingChrome = false;
492
+ let existingDebugPort = null;
493
+
494
+ if (chromeStatus.running) {
495
+ if (chromeStatus.withDebugPort && chromeStatus.debugPort) {
496
+ // Chrome is running WITH debug port - we can try to connect to it!
497
+ existingDebugPort = chromeStatus.debugPort;
498
+
499
+ // Check if it's accessible from WSL or needs port proxy
500
+ const connectHost = getWindowsHostForWSL();
501
+ try {
502
+ const testResponse = await fetch(`http://${connectHost}:${existingDebugPort}/json/version`, {
503
+ signal: AbortSignal.timeout(2000)
504
+ });
505
+ if (testResponse.ok) {
506
+ useExistingChrome = true;
507
+ }
508
+ } catch {
509
+ // Not accessible - check if port proxy would help
510
+
511
+ // Check if port proxy is already set up
512
+ try {
513
+ const portProxyList = execSync('netsh.exe interface portproxy show v4tov4 2>nul', { encoding: 'utf8', timeout: 5000 });
514
+ if (portProxyList.includes(String(existingDebugPort))) {
515
+ } else {
516
+ // Offer to set up port proxy
517
+ console.log('');
518
+ console.log(` ${C.yellow}Port proxy needed:${C.reset} Chrome is not accessible from WSL.`);
519
+ console.log(` ${C.dim}Run this in PowerShell (Admin) to fix:${C.reset}`);
520
+ console.log(` ${C.cyan}netsh interface portproxy add v4tov4 listenport=${existingDebugPort} listenaddress=0.0.0.0 connectport=${existingDebugPort} connectaddress=127.0.0.1${C.reset}`);
521
+ console.log('');
522
+
523
+ const shouldSetup = await askYesNo(` ${C.bold}Try to set up port proxy now? (requires admin)${C.reset}`);
524
+ if (shouldSetup) {
525
+ try {
526
+ // Try to run netsh (might need elevation)
527
+ execSync(`netsh.exe interface portproxy add v4tov4 listenport=${existingDebugPort} listenaddress=0.0.0.0 connectport=${existingDebugPort} connectaddress=127.0.0.1`, { encoding: 'utf8', timeout: 5000 });
528
+ // Test again
529
+ await new Promise(r => setTimeout(r, 500));
530
+ const retryResponse = await fetch(`http://${connectHost}:${existingDebugPort}/json/version`, {
531
+ signal: AbortSignal.timeout(2000)
532
+ });
533
+ if (retryResponse.ok) {
534
+ useExistingChrome = true;
535
+ }
536
+ } catch (e) {
537
+ log.warn('Could not set up port proxy (run PowerShell as Admin)');
538
+ }
539
+ }
540
+ }
541
+ } catch {
542
+ // netsh failed
543
+ }
544
+ }
545
+
546
+ if (useExistingChrome) {
547
+ windowsDebugPort = existingDebugPort;
548
+ }
549
+ } else {
550
+ // Chrome running WITHOUT debug port - singleton problem
551
+ chromeAlreadyRunning = true;
552
+ }
553
+ }
554
+
555
+ // Skip launch if using existing Chrome
556
+ let windowsUserDataDir = 'existing';
557
+
558
+ if (!useExistingChrome) {
559
+ // Find available port starting from 9222
560
+ // Check if port is already in use on Windows
561
+ const findAvailablePort = () => {
562
+ const START_PORT = 9222;
563
+ const MAX_PORT = 9299;
564
+ for (let port = START_PORT; port <= MAX_PORT; port++) {
565
+ try {
566
+ // Check if port is listening on Windows
567
+ const checkCmd = `powershell.exe -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue).Count"`;
568
+ const result = execSync(checkCmd, { encoding: 'utf8', timeout: 3000 }).trim();
569
+ const count = parseInt(result, 10) || 0;
570
+ if (count === 0) {
571
+ return port; // Port is free
572
+ }
573
+ } catch {
574
+ // Get-NetTCPConnection failed = port is likely free
575
+ return port;
576
+ }
577
+ }
578
+ // All ports in use, fallback to random
579
+ return START_PORT + Math.floor(Math.random() * (MAX_PORT - START_PORT));
580
+ };
581
+ windowsDebugPort = findAvailablePort();
582
+
583
+ // Get Windows user data dir
584
+ // Chrome has issues with \\wsl$\ paths for singleton lock detection
585
+ // So we store profiles on Windows filesystem with project-specific hash
586
+ if (USER_DATA_DIR.startsWith('/mnt/')) {
587
+ // Path is on Windows drive, convert it directly
588
+ windowsUserDataDir = USER_DATA_DIR.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, '\\');
589
+ } else {
590
+ // Path is on WSL filesystem - use Windows LOCALAPPDATA with project-specific profile ID
591
+ const { profileId } = getProfileIdFromProjectDir(outputDir);
592
+ const localAppData = getWindowsLocalAppData();
593
+ windowsUserDataDir = `${localAppData}\\browsermonitor\\${profileId}`;
594
+ }
595
+
596
+ // CMD.EXE stderr + Profile/Project in bullet box
597
+ const cmdStderrLines = getLastCmdStderrAndClear();
598
+ const mergedStderr = [];
599
+ for (let i = 0; i < cmdStderrLines.length; i++) {
600
+ const curr = cmdStderrLines[i];
601
+ const next = cmdStderrLines[i + 1];
602
+ if (next && /^'\\\\/.test(curr) && /CMD\.EXE was started/i.test(next)) {
603
+ mergedStderr.push(`${next} ${curr}`);
604
+ i++;
605
+ } else {
606
+ mergedStderr.push(curr);
607
+ }
608
+ }
609
+ if (!skipProfileBlock) {
610
+ const infoLines = [
611
+ ...mergedStderr,
612
+ `${C.cyan}Profile:${C.reset} ${windowsUserDataDir}`,
613
+ `${C.cyan}Project:${C.reset} ${outputDir}`,
614
+ ];
615
+ if (infoLines.length > 0) {
616
+ clearStatusLine();
617
+ console.log('');
618
+ printBulletBox(infoLines);
619
+ }
620
+ }
621
+
622
+ writeStatusLine(`${C.dim}Launching Chrome on Windows (port ${windowsDebugPort})...${C.reset}`);
623
+
624
+ // Launch Chrome on Windows with user data dir
625
+ // Note: Chrome M113+ always binds to 127.0.0.1 only for security
626
+ // See: https://issues.chromium.org/issues/40261787
627
+ // Port proxy is required for WSL access (set up after Chrome starts)
628
+ const psCommand = `Start-Process -FilePath '${chromePath}' -ArgumentList '--remote-debugging-port=${windowsDebugPort}','--user-data-dir=${windowsUserDataDir}','--disable-session-crashed-bubble','--start-maximized','${url}'`;
629
+ try {
630
+ execSync(`powershell.exe -NoProfile -Command "${psCommand}"`, { encoding: 'utf8', timeout: 10000 });
631
+ launchedOnWindows = true;
632
+
633
+ // Set up port proxy AFTER Chrome starts
634
+ clearStatusLine();
635
+ writeStatusLine(`${C.dim}Setting up port proxy for WSL access...${C.reset}`);
636
+
637
+ // Wait for Chrome to bind and detect address
638
+ let chromeBindAddr = null;
639
+ for (let i = 0; i < 10; i++) {
640
+ try { execSync('powershell.exe -NoProfile -Command "Start-Sleep -Milliseconds 500"', { timeout: 2000 }); } catch {}
641
+ try {
642
+ const netstat = execSync(`netstat.exe -ano`, { encoding: 'utf8', timeout: 5000 });
643
+ const lines = netstat.split('\n').filter(l => l.includes(':' + windowsDebugPort) && l.includes('LISTEN'));
644
+ for (const line of lines) {
645
+ if (line.includes('127.0.0.1:' + windowsDebugPort)) { chromeBindAddr = '127.0.0.1'; break; }
646
+ else if (line.includes('[::1]:' + windowsDebugPort)) { chromeBindAddr = '::1'; break; }
647
+ }
648
+ if (chromeBindAddr) break;
649
+ } catch {}
650
+ }
651
+ if (!chromeBindAddr) chromeBindAddr = '127.0.0.1';
652
+
653
+ const proxyType = chromeBindAddr === '::1' ? 'v4tov6' : 'v4tov4';
654
+
655
+ // Remove old proxies first
656
+ try {
657
+ execSync(`powershell.exe -NoProfile -Command "netsh interface portproxy delete v4tov4 listenport=${windowsDebugPort} listenaddress=0.0.0.0 2>\\$null; netsh interface portproxy delete v4tov6 listenport=${windowsDebugPort} listenaddress=0.0.0.0 2>\\$null"`, { encoding: 'utf8', timeout: 5000 });
658
+ } catch {}
659
+
660
+ try {
661
+ execSync(
662
+ `powershell.exe -NoProfile -Command "netsh interface portproxy add ${proxyType} listenport=${windowsDebugPort} listenaddress=0.0.0.0 connectport=${windowsDebugPort} connectaddress=${chromeBindAddr}"`,
663
+ { encoding: 'utf8', timeout: 5000 }
664
+ );
665
+ // Port proxy configured
666
+ } catch (proxyErr) {
667
+ log.warn(`Could not set up port proxy (may need admin): ${proxyErr.message}`);
668
+ }
669
+ } catch (e) {
670
+ log.error(`Failed to launch Chrome on Windows: ${e.message}`);
671
+ process.exit(1);
672
+ }
673
+ }
674
+
675
+ // Wait for Chrome to start and connect with retry
676
+ clearStatusLine(true);
677
+ const connectHost = getWindowsHostForWSL({ quiet: true });
678
+ const browserURL = `http://${connectHost}:${windowsDebugPort}`;
679
+ const wslSetupLines = [
680
+ `${C.cyan}Open (WSL)${C.reset}`,
681
+ ` ${C.yellow}WSL detected${C.reset} – Chrome on Windows (GPU/WebGL)`,
682
+ ` Chrome Canary (isolated from regular Chrome)`,
683
+ chromeAlreadyRunning ? ` ${C.yellow}Chrome already running${C.reset} – new window joins existing process` : '',
684
+ ` Port: ${windowsDebugPort} │ Connection: ${browserURL}`,
685
+ ].filter(Boolean).join('\n');
686
+ const wslTable = createTable({ colWidths: [95], tableOpts: { wordWrap: true } });
687
+ wslTable.push([wslSetupLines]);
688
+ printTable(wslTable);
689
+
690
+ writeStatusLine(`${C.dim}Connecting to Chrome...${C.reset}`);
691
+
692
+ // Retry connection up to 5 times (total ~7.5 seconds)
693
+ // If it fails, diagnostics will show the exact problem
694
+ const MAX_RETRIES = 5;
695
+ const RETRY_DELAY = 1500;
696
+ let lastError = null;
697
+
698
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
699
+ try {
700
+ await new Promise(r => setTimeout(r, RETRY_DELAY));
701
+ writeStatusLine(`${C.dim}Connecting to Chrome (attempt ${attempt}/${MAX_RETRIES})...${C.reset}`);
702
+
703
+ // First check if Chrome debug endpoint is reachable
704
+ try {
705
+ const versionUrl = `${browserURL}/json/version`;
706
+ const response = await fetch(versionUrl, { signal: AbortSignal.timeout(3000) });
707
+ if (response.ok) {
708
+ const info = await response.json();
709
+ writeStatusLine(`${C.dim}Chrome responding: ${info.Browser || 'unknown'}${C.reset}`);
710
+ }
711
+ } catch (fetchErr) {
712
+ throw new Error(`Cannot reach Chrome debug endpoint: ${fetchErr.message}`);
713
+ }
714
+
715
+ browser = await puppeteer.connect({ browserURL, defaultViewport: null });
716
+ clearStatusLine();
717
+ lastError = null;
718
+ break;
719
+ } catch (e) {
720
+ lastError = e;
721
+ }
722
+ }
723
+
724
+ if (lastError) {
725
+ log.error(`Failed to connect to Chrome after ${MAX_RETRIES} attempts: ${lastError.message}`);
726
+
727
+ // Run comprehensive WSL diagnostics
728
+ const diagResult = await runWslDiagnostics(windowsDebugPort, connectHost);
729
+
730
+ // Handle port proxy conflict automatically
731
+ if (diagResult.hasPortProxyConflict) {
732
+ console.log('');
733
+ console.log(`${C.bold}${C.green}═══════════════════════════════════════════════════════════════════════════════${C.reset}`);
734
+ console.log(`${C.bold}${C.green} AUTOMATIC FIX${C.reset}`);
735
+ console.log(`${C.bold}${C.green}═══════════════════════════════════════════════════════════════════════════════${C.reset}`);
736
+ console.log('');
737
+
738
+ const shouldFix = await askYesNo('Do you want me to fix this automatically? (remove port proxy, restart Chrome)');
739
+
740
+ if (shouldFix) {
741
+ const fixPort = diagResult.actualPort || windowsDebugPort;
742
+
743
+ console.log(`${C.cyan}[1/2]${C.reset} Removing port proxy for port ${fixPort}...`);
744
+ try {
745
+ execSync(`netsh.exe interface portproxy delete v4tov4 listenport=${fixPort} listenaddress=0.0.0.0`, { encoding: 'utf8', timeout: 5000 });
746
+ console.log(` ${C.green}✓${C.reset} Port proxy removed`);
747
+ } catch (e) {
748
+ console.log(` ${C.yellow}!${C.reset} Could not remove port proxy (may need admin): ${e.message}`);
749
+ }
750
+
751
+ console.log(`${C.cyan}[2/2]${C.reset} Stopping Chrome...`);
752
+ try {
753
+ killPuppeteerMonitorChromes(true); // Only kill browsermonitor Chrome, not user's browser!
754
+ console.log(` ${C.green}✓${C.reset} Chrome stopped`);
755
+ } catch (e) {
756
+ console.log(` ${C.yellow}!${C.reset} Could not stop Chrome: ${e.message}`);
757
+ }
758
+
759
+ console.log('');
760
+ console.log(`${C.green}Fix applied!${C.reset} Please run browsermonitor again.`);
761
+ console.log(`${C.dim}Chrome will now bind to 0.0.0.0 correctly (no port proxy needed).${C.reset}`);
762
+ console.log('');
763
+ process.exit(0);
764
+ }
765
+ }
766
+
767
+ // Additional context for Chrome singleton issue
768
+ if (chromeAlreadyRunning && !diagResult.hasPortProxyConflict) {
769
+ console.log(`${C.yellow}Note:${C.reset} Chrome was already running when we tried to launch.`);
770
+ console.log(' The new window joined the existing process without debug port.');
771
+ console.log('');
772
+ console.log(`${C.bold}Solution:${C.reset} Close ALL Chrome windows and try again.`);
773
+ console.log('');
774
+ }
775
+
776
+ process.exit(1);
777
+ }
778
+
779
+ const connectedLines = [
780
+ `${C.green}Connected to Chrome on Windows${C.reset}`,
781
+ ` Chrome: Windows native (port ${windowsDebugPort})`,
782
+ `${C.dim}Note: Separate Chrome profile – you may need to log in to websites.${C.reset}`,
783
+ ].join('\n');
784
+ const connectedTable = createTable({ colWidths: [95], tableOpts: { wordWrap: true } });
785
+ connectedTable.push([connectedLines]);
786
+ printTable(connectedTable);
787
+ currentProfilePath = windowsUserDataDir === 'existing' ? null : windowsUserDataDir;
788
+
789
+ } else {
790
+ // === NATIVE MODE: Standard Puppeteer launch ===
791
+ browser = await puppeteer.launch({
792
+ headless: headless ? 'new' : false,
793
+ userDataDir: USER_DATA_DIR,
794
+ args: [
795
+ '--no-sandbox',
796
+ '--disable-setuid-sandbox',
797
+ '--ignore-certificate-errors',
798
+ '--disable-gpu',
799
+ '--disable-software-rasterizer',
800
+ '--remote-debugging-port=0',
801
+ '--disable-session-crashed-bubble',
802
+ ...(headless ? [] : ['--start-maximized']),
803
+ ],
804
+ defaultViewport: headless ? { width: 1920, height: 1080 } : null,
805
+ });
806
+
807
+ // Save Chrome PID to file for recovery
808
+ const browserProcess = browser.process();
809
+ const chromePid = browserProcess ? browserProcess.pid : null;
810
+
811
+ if (chromePid) {
812
+ fs.writeFileSync(PID_FILE, String(chromePid));
813
+ }
814
+ const nativeLines = [
815
+ `${C.green}Connected to Chrome${C.reset}`,
816
+ ` Browser profile: ${USER_DATA_DIR}`,
817
+ chromePid ? ` PID: ${chromePid} │ PID file: ${PID_FILE}` : '',
818
+ chromePid ? `${C.dim}If stuck: kill -9 $(cat ${PID_FILE})${C.reset}` : '',
819
+ ].filter(Boolean).join('\n');
820
+ const nativeTable = createTable({ colWidths: [95], tableOpts: { wordWrap: true } });
821
+ nativeTable.push([nativeLines]);
822
+ printTable(nativeTable);
823
+ currentProfilePath = USER_DATA_DIR;
824
+ }
825
+
826
+ // Use one tab for our URL: open it first, then close any others (avoids closing the only window)
827
+ const initialPages = await browser.pages();
828
+ page = initialPages.find((p) => p.url() === 'about:blank') || initialPages[0] || await browser.newPage();
829
+ monitoredPages = [page];
830
+
831
+ // ===== SET TIMEOUTS =====
832
+ page.setDefaultTimeout(defaultTimeout);
833
+ page.setDefaultNavigationTimeout(navigationTimeout);
834
+
835
+ // ===== SETUP PAGE MONITORING (console, network events) =====
836
+ setupPageMonitoring(page);
837
+
838
+ writeStatusLine(`${C.dim}Navigating to ${url}...${C.reset}`);
839
+ if (realtime) {
840
+ logBuffer.logConsole(`[Monitor] Navigating to ${url}...`);
841
+ }
842
+ logBuffer.printNetworkSeparator('NAVIGATION STARTED');
843
+
844
+ await page.goto(url, {
845
+ waitUntil: 'domcontentloaded',
846
+ timeout: navigationTimeout,
847
+ });
848
+
849
+ clearStatusLine();
850
+ logBuffer.printConsoleSeparator('PAGE LOADED - Listening for console output');
851
+ logBuffer.printNetworkSeparator('PAGE LOADED - Listening for network requests');
852
+
853
+ // Close other tabs only after our page is open (so we never close the tab we need)
854
+ const allPages = await browser.pages();
855
+ for (const p of allPages) {
856
+ if (p !== page && !p.isClosed()) {
857
+ await p.close().catch(() => {});
858
+ }
859
+ }
860
+
861
+ if (realtime) {
862
+ logBuffer.logConsole(`[Monitor] URL: ${url}`);
863
+ logBuffer.logConsole(`[Monitor] Press Ctrl+C to stop.`);
864
+ logBuffer.logConsole(`[Monitor] Type console.clear() in browser to reset console log.`);
865
+ logBuffer.logConsole('');
866
+ } else {
867
+ // Lazy mode: Ready block from template (same as periodic reminder)
868
+ printReadyHelp(httpPort, KEYS_OPEN);
869
+ setupKeyboardInput();
870
+ }
871
+
872
+ // Keep process running until signal
873
+ await new Promise(() => {});
874
+ } finally {
875
+ // Ensure browser is closed even if something goes wrong
876
+ if (browser && !cleanupDone) {
877
+ try {
878
+ await browser.close();
879
+ log.dim('Browser closed in finally block');
880
+ } catch (e) {
881
+ log.error(`Error closing browser in finally: ${e.message}`);
882
+ }
883
+ }
884
+
885
+ try {
886
+ fs.unlinkSync(PID_FILE);
887
+ } catch {}
888
+ }
889
+ }