rechrome 1.6.0 → 1.8.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 +452 -111
  3. package/rech.ts +452 -111
  4. package/serve.js +11 -6
  5. package/serve.ts +11 -6
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 };
18
+
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
+ }
15
30
 
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) {
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,20 +332,44 @@ 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}` },
291
339
  body: JSON.stringify({ args, identity, env }),
292
340
  signal: AbortSignal.timeout(70_000),
293
- }).catch((e) => { console.error(`[rech] ${e.message}`); process.exit(1); });
294
- if (res.status === 401) { console.error("Unauthorized: bad key"); process.exit(1); }
341
+ }).catch(async (e) => {
342
+ console.error(`[rech] ${e.message}`);
343
+ const dnsResult = await import("dns/promises").then(m => m.lookup(host)).catch(() => null);
344
+ if (!dnsResult) {
345
+ console.error(`[rech] rech-client\n -x: DNS failed -> ${host}[unknown] -> rech-server[unknown]`);
346
+ } else {
347
+ const tcpOk = await new Promise<boolean>(resolve => {
348
+ import("net").then(({ createConnection }) => {
349
+ const s = createConnection({ host, port: Number(port), timeout: 3000 });
350
+ s.on("connect", () => { s.destroy(); resolve(true); });
351
+ s.on("error", () => resolve(false));
352
+ s.on("timeout", () => { s.destroy(); resolve(false); });
353
+ });
354
+ });
355
+ if (tcpOk) {
356
+ console.error(`[rech] rech-client -> ${host}:${port}\n -x: connection refused -> rech-server[unknown]`);
357
+ } else {
358
+ console.error(`[rech] rech-client -> ${host}(${dnsResult.address})\n -x: port ${port} unreachable -> rech-server[unknown]`);
359
+ }
360
+ }
361
+ process.exit(1);
362
+ });
363
+ if (res.status === 401) {
364
+ console.error(`[rech] rech-client -> rech-server[ok]\n -x: token rejected -> playwright[unknown]`);
365
+ process.exit(1);
366
+ }
295
367
  return res.json();
296
368
  }
297
369
 
298
370
  async function run(url: string, args: string[]) {
299
- const { host, port, protocol } = parseUrl(url);
300
- const effectiveProfile = parseUrl(url).profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
371
+ const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
372
+ const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
301
373
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
302
374
  const identity = await getClientIdentity();
303
375
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
@@ -305,11 +377,21 @@ async function run(url: string, args: string[]) {
305
377
  `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
306
378
  );
307
379
 
380
+ const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir });
308
381
  const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
309
382
 
310
383
  if (existingSession)
311
384
  console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
312
- if (stderr) process.stderr.write(stderr);
385
+ if (stderr) {
386
+ if (stderr.includes('Extension connection timeout')) {
387
+ const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
388
+ const last = hasToken
389
+ ? ` -x: extension token rejected -> extension[unknown]`
390
+ : ` -> extension[not installed] (run: rech setup)`;
391
+ console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
392
+ }
393
+ process.stderr.write(stderr);
394
+ }
313
395
  if (stdout) process.stdout.write(stdout);
314
396
 
315
397
  if (files?.length) {
@@ -331,111 +413,370 @@ async function run(url: string, args: string[]) {
331
413
  process.exit(status);
332
414
  }
333
415
 
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);
416
+ function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
417
+ return `<!DOCTYPE html>
418
+ <html lang="en">
419
+ <head>
420
+ <meta charset="UTF-8">
421
+ <title>rechrome — Extension Setup</title>
422
+ <style>
423
+ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; }
424
+ h1 { color: #1a73e8; }
425
+ .step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding: 12px 16px; margin: 16px 0; border-radius: 0 8px 8px 0; }
426
+ .step h3 { margin: 0 0 8px; }
427
+ code { background: #e8eaed; padding: 2px 6px; border-radius: 4px; font-size: 0.95em; word-break: break-all; }
428
+ .path { display: flex; align-items: center; gap: 8px; }
429
+ button { background: #1a73e8; color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.9em; }
430
+ button:active { background: #1558b0; }
431
+ .note { color: #666; font-size: 0.9em; }
432
+ </style>
433
+ </head>
434
+ <body>
435
+ <h1>rechrome — Extension Setup</h1>
436
+ <p>Install the multi-tab extension in Chrome profile: <strong>${profileDisplay}</strong></p>
437
+
438
+ <div class="step">
439
+ <h3>Step 1 — Open Chrome Extensions</h3>
440
+ <p>In the Chrome profile <strong>${profileDisplay}</strong>, navigate to:</p>
441
+ <code>chrome://extensions/</code>
442
+ <p class="note">Make sure you are in the correct profile (check the avatar in the top-right corner).</p>
443
+ </div>
444
+
445
+ <div class="step">
446
+ <h3>Step 2 — Enable Developer Mode</h3>
447
+ <p>Toggle <strong>Developer mode</strong> on (top-right of the extensions page).</p>
448
+ </div>
449
+
450
+ <div class="step">
451
+ <h3>Step 3 — Load the extension</h3>
452
+ <p>Click <strong>Load unpacked</strong> and select this directory:</p>
453
+ <div class="path">
454
+ <code id="extPath">${extDistDir}</code>
455
+ <button onclick="navigator.clipboard.writeText(document.getElementById('extPath').textContent).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy path',1500)})">Copy path</button>
456
+ </div>
457
+ </div>
458
+
459
+ <div class="step">
460
+ <h3>Step 4 — Return to terminal</h3>
461
+ <p>Press <strong>Enter</strong> in the terminal to continue setup.</p>
462
+ </div>
463
+
464
+ <div class="step">
465
+ <h3>Step 5 — Copy auth token</h3>
466
+ <p>Click the extension icon in the Chrome toolbar (or open the URL below):</p>
467
+ <code id="statusUrl">chrome-extension://(detected after install)/status.html</code>
468
+ <p>The page shows <strong>PLAYWRIGHT_MCP_EXTENSION_TOKEN=...</strong> — paste that into the terminal when prompted.</p>
469
+ </div>
470
+ </body>
471
+ </html>`;
472
+ }
473
+
474
+ const OXMGR_PROCESS_NAME = "rechrome-serve";
475
+
476
+ async function runOxmgr(args: string[]): Promise<number> {
477
+ const proc = Bun.spawn(["bunx", "oxmgr", ...args], { stdout: "inherit", stderr: "inherit" });
478
+ await proc.exited;
479
+ return proc.exitCode ?? 1;
480
+ }
481
+
482
+ async function daemonInstall(serveUrl: string): Promise<void> {
483
+ const home = process.env.HOME!;
484
+ const bunBin = Bun.which("bun") ?? process.execPath;
485
+ const rechScript = import.meta.filename;
486
+
487
+ const envArgs: string[] = [
488
+ "--env", `HOME=${home}`,
489
+ "--env", `PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}`,
490
+ "--env", `${ENV_KEY}=${serveUrl}`,
491
+ "--env", `PWMCP_TEST_CONNECTION_TIMEOUT=${process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000"}`,
492
+ ];
493
+ if (process.env.PLAYWRIGHT_CLI) envArgs.push("--env", `PLAYWRIGHT_CLI=${process.env.PLAYWRIGHT_CLI}`);
494
+ if (process.env.RECH_HOST) envArgs.push("--env", `RECH_HOST=${process.env.RECH_HOST}`);
495
+ if (isReadable(process.env.RECH_TLS_CERT)) envArgs.push("--env", `RECH_TLS_CERT=${process.env.RECH_TLS_CERT}`);
496
+ if (isReadable(process.env.RECH_TLS_KEY)) envArgs.push("--env", `RECH_TLS_KEY=${process.env.RECH_TLS_KEY}`);
497
+
498
+ await runOxmgr(["delete", OXMGR_PROCESS_NAME]).catch(() => {});
499
+ await runOxmgr([
500
+ "start",
501
+ "--name", OXMGR_PROCESS_NAME,
502
+ "--restart", "always",
503
+ "--cwd", home,
504
+ ...envArgs,
505
+ `${bunBin} ${rechScript} serve`,
506
+ ]);
507
+ await runOxmgr(["service", "install"]);
508
+ }
509
+
510
+ async function daemonUninstall(): Promise<void> {
511
+ await runOxmgr(["delete", OXMGR_PROCESS_NAME]);
512
+ await runOxmgr(["service", "uninstall"]);
513
+ console.log(`Removed oxmgr process: ${OXMGR_PROCESS_NAME}`);
514
+ }
515
+
516
+ async function setup(opts: { profile?: string } = {}): Promise<void> {
517
+ const { createInterface } = await import("readline");
518
+ const isTTY = process.stdin.isTTY ?? false;
519
+ const rl = isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
520
+ const ask = (q: string, def = "") => {
521
+ if (!rl) { process.stdout.write(`${q}${def}\n`); return Promise.resolve(def); }
522
+ return new Promise<string>(r => rl.question(q, r));
523
+ };
524
+
525
+ // [1/4] Daemon
526
+ console.log("\n[1/4] Setting up serve daemon...");
527
+ // Clear stale hostname-based URL so we always use 127.0.0.1 locally
528
+ if (process.env[ENV_KEY]) {
529
+ try {
530
+ const u = new URL(process.env[ENV_KEY]);
531
+ if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
532
+ } catch {}
340
533
  }
534
+ const url = await getOrCreateUrl();
341
535
  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);
536
+
537
+ let ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
538
+ if (ping) {
539
+ console.log(` Already running at ${protocol}://${host}:${port}`);
540
+ await daemonInstall(url);
541
+ console.log(` Updated daemon: ${OXMGR_PROCESS_NAME}`);
542
+ } else {
543
+ await daemonInstall(url);
544
+ console.log(` Registered daemon: ${OXMGR_PROCESS_NAME}`);
545
+ process.stdout.write(" Starting");
546
+ for (let i = 0; i < 15; i++) {
547
+ await Bun.sleep(1000);
548
+ ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
549
+ if (ping) break;
550
+ process.stdout.write(".");
551
+ }
552
+ process.stdout.write("\n");
553
+ if (!ping) {
554
+ console.error(` Failed to start serve at ${host}:${port}`);
555
+ rl?.close();
556
+ process.exit(1);
557
+ }
558
+ console.log(` Serve running at ${protocol}://${host}:${port}`);
346
559
  }
347
560
 
348
- // 2. Interactive profile selection
349
561
  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;
562
+ if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
563
+ const userDataDir = await findChromeUserDataDir();
365
564
 
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;
565
+ async function pickProfile(exclude: Set<string>): Promise<[string, { user_name?: string; name?: string }] | null> {
566
+ const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
567
+ if (!available.length) return null;
568
+ available.forEach(([dir, info], i) =>
569
+ console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
570
+ );
571
+ if (opts.profile !== undefined) {
572
+ const num = parseInt(opts.profile);
573
+ if (!isNaN(num)) return available[num - 1] ?? null;
574
+ return available.find(([, info]) =>
575
+ (info.user_name ?? "").toLowerCase().includes(opts.profile!.toLowerCase())
576
+ ) ?? null;
577
+ }
578
+ if (!isTTY) {
579
+ console.log(" Non-TTY: auto-selecting first profile");
580
+ return available[0] ?? null;
581
+ }
582
+ const answer = await ask("\n Profile number: ");
583
+ const idx = parseInt(answer.trim()) - 1;
584
+ if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
585
+ return available[idx];
373
586
  }
374
587
 
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);
588
+ async function getExtAndToken(profileDir: string, profileDisplay: string): Promise<{ extId: string; token: string } | null> {
589
+ // Extension check
590
+ let extId: string | undefined;
591
+ while (true) {
592
+ const found = await findInstalledExtension(profileDir);
593
+ if (found) { extId = found.id; break; }
594
+ const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
595
+ mkdirSync(RECH_HOME_DIR, { recursive: true });
596
+ await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
597
+ console.log(`\n Extension not found in profile: ${profileDisplay}`);
598
+ console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
599
+ console.log(`\n Opening install guide in your browser...`);
600
+ Bun.spawn(["open", setupHtmlPath], { stdout: "ignore", stderr: "ignore" });
601
+ await ask("\n Press Enter after loading the extension to retry...");
602
+ }
603
+ console.log(` Extension found: ${extId}`);
604
+
605
+ // Token
606
+ const statusUrl = `chrome-extension://${extId}/status.html`;
607
+ console.log(`\n Get auth token from the extension:`);
608
+ console.log(` ${statusUrl}`);
609
+ Bun.spawn(
610
+ ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
611
+ `--profile-directory=${profileDir}`, statusUrl],
612
+ { stdout: "ignore", stderr: "ignore", detached: true },
613
+ );
614
+ console.log(`\n Or click the extension icon in the Chrome toolbar.`);
615
+ console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
616
+ const tokenInput = (await ask(" Paste token: ")).trim();
617
+ const token = tokenInput.replace(/^.*?=/, "").trim();
618
+ if (!token || token.length < 20) { console.error(" Invalid token (too short)"); return null; }
619
+ console.log(" Token accepted");
620
+ return { extId, token };
380
621
  }
381
622
 
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
- }
623
+ // [2/4] Primary profile
624
+ console.log("\n[2/4] Select Chrome profile:");
625
+ const picked = await pickProfile(new Set());
626
+ if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
627
+ const [profileDir, profileInfoSel] = picked;
628
+ const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
397
629
 
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");
630
+ // [3+4/4] Extension + token for primary profile
631
+ console.log("\n[3/4] Checking extension...");
632
+ const primary = await getExtAndToken(profileDir, profileDisplay);
633
+ if (!primary) { rl?.close(); process.exit(1); }
634
+ const { extId, token } = primary;
635
+ const profileEmail = profileInfoSel.user_name || profileDir;
636
+
637
+ // Save RECHROME_URL
638
+ const pwdEnvPath = join(process.cwd(), ".env.local");
639
+ const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
640
+ const homeEnvPath = join(process.env.HOME!, ".env.local");
641
+ const saveChoice = (await ask(
642
+ `\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]: `
643
+ )).trim();
644
+ const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
645
+ if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
401
646
  const existing = await file(globalEnvPath).text().catch(() => "");
402
647
  const rechUrl = new URL(url);
403
648
  rechUrl.searchParams.set("extension_id", extId);
404
649
  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();
650
+ rechUrl.searchParams.set("profile", profileEmail);
408
651
  if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
409
652
  const newLine = `RECHROME_URL=${rechUrl.toString()}`;
410
- // Remove old separate vars and update RECHROME_URL
411
653
  const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
412
654
  let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
413
655
  const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
414
656
  if (rechIdx >= 0) lines[rechIdx] = newLine;
415
657
  else lines.push(newLine);
416
658
  await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
417
- console.log(`\nSaved to ${globalEnvPath}:\n ${newLine}`);
418
- console.log("\nDone!");
659
+ console.log(`\nSaved to ${globalEnvPath}`);
660
+ console.log(`\n ${newLine}`);
661
+
662
+ // Save primary to token registry
663
+ await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
664
+
665
+ // Additional profiles
666
+ const configured = new Set([profileDir]);
667
+ while (true) {
668
+ const more = (await ask("\nAdd another profile? [y/N]: ")).trim().toLowerCase();
669
+ if (more !== "y" && more !== "yes") break;
670
+ const remaining = Object.entries(cache!).filter(([dir]) => !configured.has(dir));
671
+ if (!remaining.length) { console.log(" No more profiles available."); break; }
672
+ console.log("\n Select additional profile:");
673
+ const extra = await pickProfile(configured);
674
+ if (!extra) { console.log(" Skipped."); continue; }
675
+ const [extraDir, extraInfo] = extra;
676
+ const extraDisplay = extraInfo.user_name || extraInfo.name || extraDir;
677
+ console.log(`\n Setting up: ${extraDisplay}`);
678
+ const result = await getExtAndToken(extraDir, extraDisplay);
679
+ if (!result) { console.log(" Skipped."); continue; }
680
+ const extraEmail = extraInfo.user_name || extraDir;
681
+ await saveTokenEntry(extraEmail, { extensionId: result.extId, token: result.token, profileDir: extraDir, userDataDir: userDataDir ?? undefined });
682
+ configured.add(extraDir);
683
+ console.log(` Saved token for ${extraDisplay}`);
684
+ }
685
+ rl?.close();
686
+ envWatcher?.close();
687
+ console.log(`\nDone! Test with:\n rech eval "() => document.title"`);
688
+ }
689
+
690
+ async function status(): Promise<void> {
691
+ const url = process.env[ENV_KEY];
692
+ if (!url) {
693
+ console.log(`serve: not configured (run \`rech setup\`)`);
694
+ return;
695
+ }
696
+ const { host, port, protocol } = parseUrl(url);
697
+ const parsed = parseUrl(url);
698
+ const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
699
+ // Check actual socket binding via lsof (shows * for 0.0.0.0, or exact IP for loopback-only)
700
+ const lsofProc = Bun.spawn(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], { stdout: "pipe", stderr: "ignore" });
701
+ const lsofOut = await new Response(lsofProc.stdout).text();
702
+ const listenLine = lsofOut.split("\n").find(l => l.includes(`:${port}`));
703
+ const listenAddr = listenLine?.match(/TCP\s+(\S+:\d+)/)?.[1] ?? (ping ? `${host}:${port}` : null);
704
+ console.log(`serve: ${ping ? `running ${protocol}://${listenAddr ?? `${host}:${port}`}` : "not running"}`);
705
+ const oxmgrProc = Bun.spawn(["bunx", "oxmgr", "list"], { stdout: "pipe", stderr: "ignore" });
706
+ const oxmgrOut = await new Response(oxmgrProc.stdout).text();
707
+ const daemonRegistered = oxmgrOut.includes(OXMGR_PROCESS_NAME);
708
+ console.log(`daemon: ${daemonRegistered ? `oxmgr (${OXMGR_PROCESS_NAME})` : "not installed"}`);
709
+ const registry = await readTokenRegistry();
710
+ const entries = Object.entries(registry);
711
+ if (entries.length) {
712
+ console.log(`\nprofiles:`);
713
+ const primaryProfile = parsed.profileDirectory;
714
+ for (const [email, entry] of entries) {
715
+ const isPrimary = email === primaryProfile || entry.profileDir === primaryProfile;
716
+ const marker = isPrimary ? " (primary)" : "";
717
+ console.log(` ${email.padEnd(36)} [${entry.profileDir}] ext: ${entry.extensionId.slice(0, 8)}… token: ${entry.token.slice(0, 8)}…${marker}`);
718
+ }
719
+ } else if (parsed.profileDirectory) {
720
+ // Legacy: no registry yet, show from RECHROME_URL
721
+ const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
722
+ console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
723
+ }
724
+ }
725
+
726
+ function printHelp(): void {
727
+ console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
728
+
729
+ Usage:
730
+ rech setup First-time setup: daemon + Chrome extension + config
731
+ rech status Show current configuration and serve health
732
+ rech uninstall Remove the serve daemon and clear config
733
+ rech serve Start the serve server manually (foreground)
734
+ rech profiles List Chrome profiles
735
+ rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
736
+
737
+ Environment:
738
+ ${ENV_KEY} Server URL set by \`rech setup\`
739
+
740
+ Examples:
741
+ rech setup
742
+ rech eval "() => document.title"
743
+ rech open https://example.com
744
+ rech screenshot`);
419
745
  }
420
746
 
421
747
  if (import.meta.main) {
422
748
  const args = process.argv.slice(2);
749
+ const cmd = args[0]?.toLowerCase();
423
750
 
424
- if (args[0] === "serve") {
751
+ if (cmd === "serve") {
425
752
  const { serve } = await import("./serve.js");
426
- serve();
427
- } else if (args[0] === "profiles") {
753
+ serve(); // long-lived; watcher intentionally kept alive
754
+ } else if (cmd === "status") {
755
+ await status();
756
+ envWatcher?.close();
757
+ } else if (cmd === "profiles") {
428
758
  await listProfiles();
429
- } else if (args[0] === "setup") {
430
- await setup();
759
+ envWatcher?.close();
760
+ } else if (cmd === "setup") {
761
+ const profileIdx = args.indexOf("--profile");
762
+ const profile = profileIdx !== -1
763
+ ? args[profileIdx + 1]
764
+ : args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
765
+ await setup({ profile }); // setup closes envWatcher itself before printing Done
766
+ } else if (cmd === "uninstall") {
767
+ await daemonUninstall();
768
+ envWatcher?.close();
769
+ } else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
770
+ printHelp();
771
+ envWatcher?.close();
431
772
  } else {
432
773
  const url = process.env[ENV_KEY];
433
774
  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
- );
775
+ console.error(`${ENV_KEY} is not set. Run \`rech setup\` to configure.\n`);
776
+ printHelp();
437
777
  process.exit(1);
438
778
  }
439
- run(url, args);
779
+ await run(url, args);
780
+ envWatcher?.close();
440
781
  }
441
782
  }