rechrome 1.6.0 → 1.7.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 (5) hide show
  1. package/package.json +1 -1
  2. package/rech.js +413 -106
  3. package/rech.ts +413 -106
  4. package/serve.js +11 -6
  5. package/serve.ts +11 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rech.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { file } from "bun";
4
4
  import { randomBytes } from "crypto";
5
- import { mkdirSync, appendFileSync, existsSync } from "fs";
5
+ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, constants as fsConstants } from "fs";
6
6
  import { hostname } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
 
@@ -11,33 +11,58 @@ export const DEFAULT_PORT = 13775;
11
11
  export const RECH_DIR = join(import.meta.dir, ".rech");
12
12
  export const LOG_DIR = join(RECH_DIR, "logs");
13
13
 
14
- const envFile = join(import.meta.dir, ".env.local");
14
+ const RECH_HOME_DIR = join(process.env.HOME!, ".rech");
15
+ const TOKENS_FILE = join(RECH_HOME_DIR, "tokens.json");
16
+
17
+ type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string };
15
18
 
16
- async function loadEnvFile(path: string): Promise<boolean> {
17
- const envRaw = await file(path).text().catch(() => "");
18
- if (!envRaw) return false;
19
- let hasKey = false;
20
- for (const line of envRaw.split("\n")) {
21
- const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
22
- if (m) {
19
+ async function readTokenRegistry(): Promise<Record<string, TokenEntry>> {
20
+ const raw = await file(TOKENS_FILE).text().catch(() => "{}");
21
+ try { return JSON.parse(raw); } catch { return {}; }
22
+ }
23
+
24
+ async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
25
+ mkdirSync(RECH_HOME_DIR, { recursive: true });
26
+ const registry = await readTokenRegistry();
27
+ registry[profileEmail] = entry;
28
+ await Bun.write(TOKENS_FILE, JSON.stringify(registry, null, 2) + "\n");
29
+ }
30
+
31
+ const envFile = join(import.meta.dir, ".env.local");
32
+ const globalEnvFile = join(process.env.HOME || "~", ".env.local");
33
+
34
+ // Walk CWD→root loading env files nearest-first; per-key: closest file wins, farther files skip.
35
+ // At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
36
+ export async function loadNearestEnv(extraFallbacks: string[] = []) {
37
+ const seen = new Set<string>();
38
+ const applyFile = async (path: string) => {
39
+ const raw = await file(path).text().catch(() => "");
40
+ for (const line of raw.split("\n")) {
41
+ const m = line.match(/^\s*([^#=\s][^#=]*?)\s*=\s*(.*?)\s*$/);
42
+ if (!m || m[1].startsWith("#")) continue;
43
+ if (seen.has(m[1])) continue;
44
+ seen.add(m[1]);
23
45
  process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
24
- if (m[1] === ENV_KEY) hasKey = true;
25
46
  }
26
- }
27
- return hasKey;
28
- }
47
+ };
29
48
 
30
- async function loadEnv() {
31
- // Walk up from cwd first — project-local .env.local takes priority
32
49
  let dir = process.cwd();
50
+ const dirs: string[] = [];
33
51
  while (true) {
34
- if (await loadEnvFile(join(dir, ".env.local"))) break;
52
+ dirs.push(dir);
35
53
  const parent = join(dir, "..");
36
54
  if (parent === dir) break;
37
55
  dir = parent;
38
56
  }
39
- // Fall back to script dir's .env.local
40
- if (!process.env[ENV_KEY]) await loadEnvFile(envFile);
57
+ for (const d of dirs) {
58
+ await applyFile(join(d, ".rechrome", ".env.local"));
59
+ await applyFile(join(d, ".env.local"));
60
+ }
61
+ for (const f of extraFallbacks) await applyFile(f);
62
+ }
63
+
64
+ async function loadEnv() {
65
+ await loadNearestEnv();
41
66
  }
42
67
  // Shell-set passthrough vars survive .env.local loading
43
68
  const _shellPassthrough: Record<string, string> = {};
@@ -48,12 +73,9 @@ await loadEnv();
48
73
  Object.assign(process.env, _shellPassthrough);
49
74
 
50
75
  import { watch } from "node:fs";
51
- if (existsSync(envFile)) {
52
- watch(envFile, async () => {
53
- log(".env.local changed, reloading");
54
- await loadEnv();
55
- });
56
- }
76
+ const envWatcher = existsSync(envFile)
77
+ ? watch(envFile, async () => { log(".env.local changed, reloading"); await loadEnv(); })
78
+ : null;
57
79
 
58
80
 
59
81
  export const PASSTHROUGH_ENV_KEYS = [
@@ -61,8 +83,14 @@ export const PASSTHROUGH_ENV_KEYS = [
61
83
  "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
62
84
  "PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
63
85
  "PLAYWRIGHT_MCP_USER_DATA_DIR",
86
+ "PWMCP_TEST_CONNECTION_TIMEOUT",
64
87
  ] as const;
65
88
 
89
+ function isReadable(p?: string): boolean {
90
+ if (!p) return false;
91
+ try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
92
+ }
93
+
66
94
  export function log(msg: string) {
67
95
  mkdirSync(LOG_DIR, { recursive: true });
68
96
  const ts = new Date().toISOString();
@@ -92,13 +120,13 @@ export function parseUrl(raw: string) {
92
120
  export async function getOrCreateUrl(): Promise<string> {
93
121
  if (process.env[ENV_KEY]) return process.env[ENV_KEY];
94
122
  const key = randomBytes(9).toString("base64url"); // 12 chars
95
- const url = `http://${key}@${hostname()}:${DEFAULT_PORT}`;
123
+ const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
96
124
  const newLine = `${ENV_KEY}=${url}`;
97
- const envRaw = await file(envFile)
98
- .text()
99
- .catch(() => "");
100
- const content = envRaw.trimEnd() ? envRaw.trimEnd() + "\n" + newLine + "\n" : newLine + "\n";
101
- Bun.write(envFile, content);
125
+ // Write to ~/.env.local so it's not shadowed by project .env.local
126
+ const envRaw = await file(globalEnvFile).text().catch(() => "");
127
+ const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
128
+ const content = [...lines, newLine, ""].join("\n");
129
+ Bun.write(globalEnvFile, content);
102
130
  process.env[ENV_KEY] = url;
103
131
  return url;
104
132
  }
@@ -153,19 +181,34 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
153
181
  return { hostname: hostname(), cwd };
154
182
  }
155
183
 
156
- function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Record<string, string> {
184
+ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Promise<Record<string, string>> {
157
185
  const env: Record<string, string> = {};
158
186
  for (const key of PASSTHROUGH_ENV_KEYS) {
159
187
  if (process.env[key]) env[key] = process.env[key];
160
188
  }
161
189
  if (urlExtras?.extensionId)
162
190
  env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
163
- if (urlExtras?.extensionToken)
164
- env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
165
191
  if (urlExtras?.profileDirectory)
166
192
  env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
167
193
  if (urlExtras?.userDataDir)
168
194
  env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
195
+ // Token: shell env wins (explicit override), registry is fallback, URL param is last resort
196
+ const profileKey = urlExtras?.profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
197
+ if (profileKey) {
198
+ const registry = await readTokenRegistry();
199
+ const entry = registry[profileKey];
200
+ if (entry) {
201
+ if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
202
+ if (!env["PLAYWRIGHT_MCP_USER_DATA_DIR"] && entry.userDataDir) env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = entry.userDataDir;
203
+ if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
204
+ env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
205
+ } else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
206
+ console.error(`[rech] warning: shell PLAYWRIGHT_MCP_EXTENSION_TOKEN differs from registry token for "${profileKey}" — using shell value. Run \`unset PLAYWRIGHT_MCP_EXTENSION_TOKEN\` to use the registry.`);
207
+ }
208
+ }
209
+ }
210
+ if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] && urlExtras?.extensionToken)
211
+ env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
169
212
  return env;
170
213
  }
171
214
 
@@ -222,7 +265,12 @@ async function findInstalledExtension(
222
265
  const data = JSON.parse(await f.text());
223
266
  const settings = data?.extensions?.settings ?? {};
224
267
  for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
225
- if (info?.path === EXTENSION_DIST_DIR) return { id: extId, profile: prof };
268
+ if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
269
+ let storedPath = info.path as string;
270
+ try { storedPath = realpathSync(storedPath); } catch {}
271
+ let distPath = EXTENSION_DIST_DIR;
272
+ try { distPath = realpathSync(distPath); } catch {}
273
+ if (storedPath === distPath) return { id: extId, profile: prof };
226
274
  }
227
275
  } catch {}
228
276
  }
@@ -284,7 +332,7 @@ async function callServe(
284
332
  const identity = await getClientIdentity();
285
333
  const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
286
334
  if (effectiveProfile) (identity as any).profile = effectiveProfile;
287
- const env = { ...getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir }), ...overrideEnv };
335
+ const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
288
336
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
289
337
  method: "POST",
290
338
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -331,111 +379,370 @@ async function run(url: string, args: string[]) {
331
379
  process.exit(status);
332
380
  }
333
381
 
334
- async function setup(): Promise<void> {
335
- // 1. Require serve to be running
336
- const url = process.env[ENV_KEY];
337
- if (!url) {
338
- console.error(`${ENV_KEY} not set — start the server first:\n rech serve`);
339
- process.exit(1);
382
+ function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
383
+ return `<!DOCTYPE html>
384
+ <html lang="en">
385
+ <head>
386
+ <meta charset="UTF-8">
387
+ <title>rechrome — Extension Setup</title>
388
+ <style>
389
+ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; }
390
+ h1 { color: #1a73e8; }
391
+ .step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding: 12px 16px; margin: 16px 0; border-radius: 0 8px 8px 0; }
392
+ .step h3 { margin: 0 0 8px; }
393
+ code { background: #e8eaed; padding: 2px 6px; border-radius: 4px; font-size: 0.95em; word-break: break-all; }
394
+ .path { display: flex; align-items: center; gap: 8px; }
395
+ button { background: #1a73e8; color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.9em; }
396
+ button:active { background: #1558b0; }
397
+ .note { color: #666; font-size: 0.9em; }
398
+ </style>
399
+ </head>
400
+ <body>
401
+ <h1>rechrome — Extension Setup</h1>
402
+ <p>Install the multi-tab extension in Chrome profile: <strong>${profileDisplay}</strong></p>
403
+
404
+ <div class="step">
405
+ <h3>Step 1 — Open Chrome Extensions</h3>
406
+ <p>In the Chrome profile <strong>${profileDisplay}</strong>, navigate to:</p>
407
+ <code>chrome://extensions/</code>
408
+ <p class="note">Make sure you are in the correct profile (check the avatar in the top-right corner).</p>
409
+ </div>
410
+
411
+ <div class="step">
412
+ <h3>Step 2 — Enable Developer Mode</h3>
413
+ <p>Toggle <strong>Developer mode</strong> on (top-right of the extensions page).</p>
414
+ </div>
415
+
416
+ <div class="step">
417
+ <h3>Step 3 — Load the extension</h3>
418
+ <p>Click <strong>Load unpacked</strong> and select this directory:</p>
419
+ <div class="path">
420
+ <code id="extPath">${extDistDir}</code>
421
+ <button onclick="navigator.clipboard.writeText(document.getElementById('extPath').textContent).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy path',1500)})">Copy path</button>
422
+ </div>
423
+ </div>
424
+
425
+ <div class="step">
426
+ <h3>Step 4 — Return to terminal</h3>
427
+ <p>Press <strong>Enter</strong> in the terminal to continue setup.</p>
428
+ </div>
429
+
430
+ <div class="step">
431
+ <h3>Step 5 — Copy auth token</h3>
432
+ <p>Click the extension icon in the Chrome toolbar (or open the URL below):</p>
433
+ <code id="statusUrl">chrome-extension://(detected after install)/status.html</code>
434
+ <p>The page shows <strong>PLAYWRIGHT_MCP_EXTENSION_TOKEN=...</strong> — paste that into the terminal when prompted.</p>
435
+ </div>
436
+ </body>
437
+ </html>`;
438
+ }
439
+
440
+ const OXMGR_PROCESS_NAME = "rechrome-serve";
441
+
442
+ async function runOxmgr(args: string[]): Promise<number> {
443
+ const proc = Bun.spawn(["bunx", "oxmgr", ...args], { stdout: "inherit", stderr: "inherit" });
444
+ await proc.exited;
445
+ return proc.exitCode ?? 1;
446
+ }
447
+
448
+ async function daemonInstall(serveUrl: string): Promise<void> {
449
+ const home = process.env.HOME!;
450
+ const bunBin = Bun.which("bun") ?? process.execPath;
451
+ const rechScript = import.meta.filename;
452
+
453
+ const envArgs: string[] = [
454
+ "--env", `HOME=${home}`,
455
+ "--env", `PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}`,
456
+ "--env", `${ENV_KEY}=${serveUrl}`,
457
+ "--env", `PWMCP_TEST_CONNECTION_TIMEOUT=${process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000"}`,
458
+ ];
459
+ if (process.env.PLAYWRIGHT_CLI) envArgs.push("--env", `PLAYWRIGHT_CLI=${process.env.PLAYWRIGHT_CLI}`);
460
+ if (process.env.RECH_HOST) envArgs.push("--env", `RECH_HOST=${process.env.RECH_HOST}`);
461
+ if (isReadable(process.env.RECH_TLS_CERT)) envArgs.push("--env", `RECH_TLS_CERT=${process.env.RECH_TLS_CERT}`);
462
+ if (isReadable(process.env.RECH_TLS_KEY)) envArgs.push("--env", `RECH_TLS_KEY=${process.env.RECH_TLS_KEY}`);
463
+
464
+ await runOxmgr(["delete", OXMGR_PROCESS_NAME]).catch(() => {});
465
+ await runOxmgr([
466
+ "start",
467
+ "--name", OXMGR_PROCESS_NAME,
468
+ "--restart", "always",
469
+ "--cwd", home,
470
+ ...envArgs,
471
+ `${bunBin} ${rechScript} serve`,
472
+ ]);
473
+ await runOxmgr(["service", "install"]);
474
+ }
475
+
476
+ async function daemonUninstall(): Promise<void> {
477
+ await runOxmgr(["delete", OXMGR_PROCESS_NAME]);
478
+ await runOxmgr(["service", "uninstall"]);
479
+ console.log(`Removed oxmgr process: ${OXMGR_PROCESS_NAME}`);
480
+ }
481
+
482
+ async function setup(opts: { profile?: string } = {}): Promise<void> {
483
+ const { createInterface } = await import("readline");
484
+ const isTTY = process.stdin.isTTY ?? false;
485
+ const rl = isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
486
+ const ask = (q: string, def = "") => {
487
+ if (!rl) { process.stdout.write(`${q}${def}\n`); return Promise.resolve(def); }
488
+ return new Promise<string>(r => rl.question(q, r));
489
+ };
490
+
491
+ // [1/4] Daemon
492
+ console.log("\n[1/4] Setting up serve daemon...");
493
+ // Clear stale hostname-based URL so we always use 127.0.0.1 locally
494
+ if (process.env[ENV_KEY]) {
495
+ try {
496
+ const u = new URL(process.env[ENV_KEY]);
497
+ if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
498
+ } catch {}
340
499
  }
500
+ const url = await getOrCreateUrl();
341
501
  const { host, port, protocol } = parseUrl(url);
342
- const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(3000) }).catch(() => null);
343
- if (!ping) {
344
- console.error(`rech serve is not running at ${host}:${port}\nStart it with:\n rech serve`);
345
- process.exit(1);
502
+
503
+ let ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
504
+ if (ping) {
505
+ console.log(` Already running at ${protocol}://${host}:${port}`);
506
+ await daemonInstall(url);
507
+ console.log(` Updated daemon: ${OXMGR_PROCESS_NAME}`);
508
+ } else {
509
+ await daemonInstall(url);
510
+ console.log(` Registered daemon: ${OXMGR_PROCESS_NAME}`);
511
+ process.stdout.write(" Starting");
512
+ for (let i = 0; i < 15; i++) {
513
+ await Bun.sleep(1000);
514
+ ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
515
+ if (ping) break;
516
+ process.stdout.write(".");
517
+ }
518
+ process.stdout.write("\n");
519
+ if (!ping) {
520
+ console.error(` Failed to start serve at ${host}:${port}`);
521
+ rl?.close();
522
+ process.exit(1);
523
+ }
524
+ console.log(` Serve running at ${protocol}://${host}:${port}`);
346
525
  }
347
526
 
348
- // 2. Interactive profile selection
349
527
  const cache = await readChromeProfileCache();
350
- if (!cache) { console.error("Chrome profiles not found"); process.exit(1); }
351
- const profiles = Object.entries(cache);
352
- console.log("\nAvailable Chrome profiles:");
353
- profiles.forEach(([dir, info], i) =>
354
- console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
355
- );
356
- const { createInterface } = await import("readline");
357
- const rl = createInterface({ input: process.stdin, output: process.stdout });
358
- const answer = await new Promise<string>(r => rl.question("\nProfile number: ", r));
359
- rl.close();
360
- const idx = parseInt(answer.trim()) - 1;
361
- if (isNaN(idx) || idx < 0 || idx >= profiles.length) { console.error("Invalid selection"); process.exit(1); }
362
- const [profileDir, profileInfoSel] = profiles[idx];
363
- const profileEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileDir };
364
- const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
528
+ if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
529
+ const userDataDir = await findChromeUserDataDir();
365
530
 
366
- // 3. Discover the extension ID. Unpacked extension IDs are derived from the
367
- // load path, so look up the actual ID from Chrome's Secure Preferences.
368
- // Env override wins if set explicitly.
369
- let extId = process.env.PLAYWRIGHT_MCP_EXTENSION_ID;
370
- if (!extId) {
371
- const found = await findInstalledExtension(profileDir);
372
- if (found) extId = found.id;
531
+ async function pickProfile(exclude: Set<string>): Promise<[string, { user_name?: string; name?: string }] | null> {
532
+ const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
533
+ if (!available.length) return null;
534
+ available.forEach(([dir, info], i) =>
535
+ console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
536
+ );
537
+ if (opts.profile !== undefined) {
538
+ const num = parseInt(opts.profile);
539
+ if (!isNaN(num)) return available[num - 1] ?? null;
540
+ return available.find(([, info]) =>
541
+ (info.user_name ?? "").toLowerCase().includes(opts.profile!.toLowerCase())
542
+ ) ?? null;
543
+ }
544
+ if (!isTTY) {
545
+ console.log(" Non-TTY: auto-selecting first profile");
546
+ return available[0] ?? null;
547
+ }
548
+ const answer = await ask("\n Profile number: ");
549
+ const idx = parseInt(answer.trim()) - 1;
550
+ if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
551
+ return available[idx];
373
552
  }
374
553
 
375
- if (!extId) {
376
- printInstallInstructions(profileDisplay);
377
- console.error("Opening chrome://extensions/ in the selected profile...");
378
- await callServe(url, ["open", "chrome://extensions/"], profileEnv);
379
- process.exit(1);
554
+ async function getExtAndToken(profileDir: string, profileDisplay: string): Promise<{ extId: string; token: string } | null> {
555
+ // Extension check
556
+ let extId: string | undefined;
557
+ while (true) {
558
+ const found = await findInstalledExtension(profileDir);
559
+ if (found) { extId = found.id; break; }
560
+ const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
561
+ mkdirSync(RECH_HOME_DIR, { recursive: true });
562
+ await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
563
+ console.log(`\n Extension not found in profile: ${profileDisplay}`);
564
+ console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
565
+ console.log(`\n Opening install guide in your browser...`);
566
+ Bun.spawn(["open", setupHtmlPath], { stdout: "ignore", stderr: "ignore" });
567
+ await ask("\n Press Enter after loading the extension to retry...");
568
+ }
569
+ console.log(` Extension found: ${extId}`);
570
+
571
+ // Token
572
+ const statusUrl = `chrome-extension://${extId}/status.html`;
573
+ console.log(`\n Get auth token from the extension:`);
574
+ console.log(` ${statusUrl}`);
575
+ Bun.spawn(
576
+ ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
577
+ `--profile-directory=${profileDir}`, statusUrl],
578
+ { stdout: "ignore", stderr: "ignore", detached: true },
579
+ );
580
+ console.log(`\n Or click the extension icon in the Chrome toolbar.`);
581
+ console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
582
+ const tokenInput = (await ask(" Paste token: ")).trim();
583
+ const token = tokenInput.replace(/^.*?=/, "").trim();
584
+ if (!token || token.length < 20) { console.error(" Invalid token (too short)"); return null; }
585
+ console.log(" Token accepted");
586
+ return { extId, token };
380
587
  }
381
588
 
382
- const statusUrl = `chrome-extension://${extId}/status.html`;
383
- console.log(`\nOpening ${statusUrl}...`);
384
- const openResult = await callServe(url, ["open", statusUrl], profileEnv);
385
- if (openResult.status !== 0) { process.stderr.write(openResult.stderr); process.exit(openResult.status); }
386
-
387
- // 4. Read token from extension page localStorage
388
- const evalResult = await callServe(url, ["eval", `() => localStorage.getItem('auth-token')`], profileEnv);
389
- const tokenMatch = evalResult.stdout.match(/"([A-Za-z0-9_-]{20,})"/);
390
- const token = tokenMatch?.[1];
391
- if (!token) {
392
- printInstallInstructions(profileDisplay);
393
- console.error("Tried to read the auth token from the extension's status page but failed.");
394
- console.error("This usually means the extension is not loaded in this profile.");
395
- process.exit(1);
396
- }
589
+ // [2/4] Primary profile
590
+ console.log("\n[2/4] Select Chrome profile:");
591
+ const picked = await pickProfile(new Set());
592
+ if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
593
+ const [profileDir, profileInfoSel] = picked;
594
+ const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
397
595
 
398
- // 5. Write single RECHROME_URL with all params to ~/.env.local
399
- const home = process.env.HOME!;
400
- const globalEnvPath = join(home, ".env.local");
596
+ // [3+4/4] Extension + token for primary profile
597
+ console.log("\n[3/4] Checking extension...");
598
+ const primary = await getExtAndToken(profileDir, profileDisplay);
599
+ if (!primary) { rl?.close(); process.exit(1); }
600
+ const { extId, token } = primary;
601
+ const profileEmail = profileInfoSel.user_name || profileDir;
602
+
603
+ // Save RECHROME_URL
604
+ const pwdEnvPath = join(process.cwd(), ".env.local");
605
+ const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
606
+ const homeEnvPath = join(process.env.HOME!, ".env.local");
607
+ const saveChoice = (await ask(
608
+ `\n[4/4] Save RECHROME_URL to:\n 1. ${pwdEnvPath} (current dir) [default]\n 2. ${pwdRechPath} (current dir, rechrome-only)\n 3. ${homeEnvPath} (user home)\n\n Choice [1]: `
609
+ )).trim();
610
+ const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
611
+ if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
401
612
  const existing = await file(globalEnvPath).text().catch(() => "");
402
613
  const rechUrl = new URL(url);
403
614
  rechUrl.searchParams.set("extension_id", extId);
404
615
  rechUrl.searchParams.set("token", token);
405
- // Prefer email for readability, fall back to directory name
406
- rechUrl.searchParams.set("profile", profileInfoSel.user_name || profileDir);
407
- const userDataDir = await findChromeUserDataDir();
616
+ rechUrl.searchParams.set("profile", profileEmail);
408
617
  if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
409
618
  const newLine = `RECHROME_URL=${rechUrl.toString()}`;
410
- // Remove old separate vars and update RECHROME_URL
411
619
  const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
412
620
  let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
413
621
  const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
414
622
  if (rechIdx >= 0) lines[rechIdx] = newLine;
415
623
  else lines.push(newLine);
416
624
  await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
417
- console.log(`\nSaved to ${globalEnvPath}:\n ${newLine}`);
418
- console.log("\nDone!");
625
+ console.log(`\nSaved to ${globalEnvPath}`);
626
+ console.log(`\n ${newLine}`);
627
+
628
+ // Save primary to token registry
629
+ await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
630
+
631
+ // Additional profiles
632
+ const configured = new Set([profileDir]);
633
+ while (true) {
634
+ const more = (await ask("\nAdd another profile? [y/N]: ")).trim().toLowerCase();
635
+ if (more !== "y" && more !== "yes") break;
636
+ const remaining = Object.entries(cache!).filter(([dir]) => !configured.has(dir));
637
+ if (!remaining.length) { console.log(" No more profiles available."); break; }
638
+ console.log("\n Select additional profile:");
639
+ const extra = await pickProfile(configured);
640
+ if (!extra) { console.log(" Skipped."); continue; }
641
+ const [extraDir, extraInfo] = extra;
642
+ const extraDisplay = extraInfo.user_name || extraInfo.name || extraDir;
643
+ console.log(`\n Setting up: ${extraDisplay}`);
644
+ const result = await getExtAndToken(extraDir, extraDisplay);
645
+ if (!result) { console.log(" Skipped."); continue; }
646
+ const extraEmail = extraInfo.user_name || extraDir;
647
+ await saveTokenEntry(extraEmail, { extensionId: result.extId, token: result.token, profileDir: extraDir, userDataDir: userDataDir ?? undefined });
648
+ configured.add(extraDir);
649
+ console.log(` Saved token for ${extraDisplay}`);
650
+ }
651
+ rl?.close();
652
+ envWatcher?.close();
653
+ console.log(`\nDone! Test with:\n rech eval "() => document.title"`);
654
+ }
655
+
656
+ async function status(): Promise<void> {
657
+ const url = process.env[ENV_KEY];
658
+ if (!url) {
659
+ console.log(`serve: not configured (run \`rech setup\`)`);
660
+ return;
661
+ }
662
+ const { host, port, protocol } = parseUrl(url);
663
+ const parsed = parseUrl(url);
664
+ const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
665
+ // Check actual socket binding via lsof (shows * for 0.0.0.0, or exact IP for loopback-only)
666
+ const lsofProc = Bun.spawn(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], { stdout: "pipe", stderr: "ignore" });
667
+ const lsofOut = await new Response(lsofProc.stdout).text();
668
+ const listenLine = lsofOut.split("\n").find(l => l.includes(`:${port}`));
669
+ const listenAddr = listenLine?.match(/TCP\s+(\S+:\d+)/)?.[1] ?? (ping ? `${host}:${port}` : null);
670
+ console.log(`serve: ${ping ? `running ${protocol}://${listenAddr ?? `${host}:${port}`}` : "not running"}`);
671
+ const oxmgrProc = Bun.spawn(["bunx", "oxmgr", "list"], { stdout: "pipe", stderr: "ignore" });
672
+ const oxmgrOut = await new Response(oxmgrProc.stdout).text();
673
+ const daemonRegistered = oxmgrOut.includes(OXMGR_PROCESS_NAME);
674
+ console.log(`daemon: ${daemonRegistered ? `oxmgr (${OXMGR_PROCESS_NAME})` : "not installed"}`);
675
+ const registry = await readTokenRegistry();
676
+ const entries = Object.entries(registry);
677
+ if (entries.length) {
678
+ console.log(`\nprofiles:`);
679
+ const primaryProfile = parsed.profileDirectory;
680
+ for (const [email, entry] of entries) {
681
+ const isPrimary = email === primaryProfile || entry.profileDir === primaryProfile;
682
+ const marker = isPrimary ? " (primary)" : "";
683
+ console.log(` ${email.padEnd(36)} [${entry.profileDir}] ext: ${entry.extensionId.slice(0, 8)}… token: ${entry.token.slice(0, 8)}…${marker}`);
684
+ }
685
+ } else if (parsed.profileDirectory) {
686
+ // Legacy: no registry yet, show from RECHROME_URL
687
+ const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
688
+ console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
689
+ }
690
+ }
691
+
692
+ function printHelp(): void {
693
+ console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
694
+
695
+ Usage:
696
+ rech setup First-time setup: daemon + Chrome extension + config
697
+ rech status Show current configuration and serve health
698
+ rech uninstall Remove the serve daemon and clear config
699
+ rech serve Start the serve server manually (foreground)
700
+ rech profiles List Chrome profiles
701
+ rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
702
+
703
+ Environment:
704
+ ${ENV_KEY} Server URL set by \`rech setup\`
705
+
706
+ Examples:
707
+ rech setup
708
+ rech eval "() => document.title"
709
+ rech open https://example.com
710
+ rech screenshot`);
419
711
  }
420
712
 
421
713
  if (import.meta.main) {
422
714
  const args = process.argv.slice(2);
715
+ const cmd = args[0]?.toLowerCase();
423
716
 
424
- if (args[0] === "serve") {
717
+ if (cmd === "serve") {
425
718
  const { serve } = await import("./serve.js");
426
- serve();
427
- } else if (args[0] === "profiles") {
719
+ serve(); // long-lived; watcher intentionally kept alive
720
+ } else if (cmd === "status") {
721
+ await status();
722
+ envWatcher?.close();
723
+ } else if (cmd === "profiles") {
428
724
  await listProfiles();
429
- } else if (args[0] === "setup") {
430
- await setup();
725
+ envWatcher?.close();
726
+ } else if (cmd === "setup") {
727
+ const profileIdx = args.indexOf("--profile");
728
+ const profile = profileIdx !== -1
729
+ ? args[profileIdx + 1]
730
+ : args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
731
+ await setup({ profile }); // setup closes envWatcher itself before printing Done
732
+ } else if (cmd === "uninstall") {
733
+ await daemonUninstall();
734
+ envWatcher?.close();
735
+ } else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
736
+ printHelp();
737
+ envWatcher?.close();
431
738
  } else {
432
739
  const url = process.env[ENV_KEY];
433
740
  if (!url) {
434
- console.error(
435
- `Usage:\n rech serve\n ${ENV_KEY}=http://key@host:${DEFAULT_PORT}?extension_id=ID&token=TOKEN rech <playwright-args...>\n ${ENV_KEY}=https://key@host/path?extension_id=ID&token=TOKEN rech <playwright-args...>`,
436
- );
741
+ console.error(`${ENV_KEY} is not set. Run \`rech setup\` to configure.\n`);
742
+ printHelp();
437
743
  process.exit(1);
438
744
  }
439
- run(url, args);
745
+ await run(url, args);
746
+ envWatcher?.close();
440
747
  }
441
748
  }