rechrome 1.5.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 (6) hide show
  1. package/README.md +40 -20
  2. package/package.json +3 -3
  3. package/rech.js +608 -65
  4. package/rech.ts +608 -65
  5. package/serve.js +112 -13
  6. package/serve.ts +112 -13
package/rech.js CHANGED
@@ -2,47 +2,95 @@
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
- import { join, basename } from "path";
7
+ import { join, basename, dirname } from "path";
8
8
 
9
- export const ENV_KEY = "REMOTE_CHROME_URL";
9
+ export const ENV_KEY = "RECHROME_URL";
10
10
  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
- // Load .env.local from script's directory (works even when invoked from elsewhere)
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
+ }
30
+
15
31
  const envFile = join(import.meta.dir, ".env.local");
32
+ const globalEnvFile = join(process.env.HOME || "~", ".env.local");
16
33
 
17
- /** Load .env.local into process.env. */
18
- async function loadEnv() {
19
- const envRaw = await file(envFile)
20
- .text()
21
- .catch(() => "");
22
- for (const line of envRaw.split("\n")) {
23
- const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
24
- if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
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]);
45
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
46
+ }
47
+ };
48
+
49
+ let dir = process.cwd();
50
+ const dirs: string[] = [];
51
+ while (true) {
52
+ dirs.push(dir);
53
+ const parent = join(dir, "..");
54
+ if (parent === dir) break;
55
+ dir = parent;
25
56
  }
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();
66
+ }
67
+ // Shell-set passthrough vars survive .env.local loading
68
+ const _shellPassthrough: Record<string, string> = {};
69
+ for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
70
+ if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
26
71
  }
27
72
  await loadEnv();
73
+ Object.assign(process.env, _shellPassthrough);
28
74
 
29
- // Watch .env.local for changes and hot-reload
30
75
  import { watch } from "node:fs";
31
- if (existsSync(envFile)) {
32
- watch(envFile, async () => {
33
- log(".env.local changed, reloading");
34
- await loadEnv();
35
- });
36
- }
76
+ const envWatcher = existsSync(envFile)
77
+ ? watch(envFile, async () => { log(".env.local changed, reloading"); await loadEnv(); })
78
+ : null;
37
79
 
38
80
 
39
81
  export const PASSTHROUGH_ENV_KEYS = [
40
82
  "PLAYWRIGHT_MCP_EXTENSION_ID",
41
83
  "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
42
- "PLAYWRIGHT_MCP_USER_DATA_DIR",
43
84
  "PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
85
+ "PLAYWRIGHT_MCP_USER_DATA_DIR",
86
+ "PWMCP_TEST_CONNECTION_TIMEOUT",
44
87
  ] as const;
45
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
+
46
94
  export function log(msg: string) {
47
95
  mkdirSync(LOG_DIR, { recursive: true });
48
96
  const ts = new Date().toISOString();
@@ -54,19 +102,31 @@ export function log(msg: string) {
54
102
 
55
103
  export function parseUrl(raw: string) {
56
104
  const u = new URL(raw);
57
- return { key: u.username, host: u.hostname, port: parseInt(u.port) || DEFAULT_PORT };
105
+ const scheme = u.protocol.replace(":", "");
106
+ const protocol = scheme === "https" ? "https" : "http";
107
+ const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
108
+ return {
109
+ key: u.username,
110
+ host: u.hostname,
111
+ port: parseInt(u.port) || defaultPort,
112
+ protocol,
113
+ extensionId: u.searchParams.get("extension_id") ?? undefined,
114
+ extensionToken: u.searchParams.get("token") ?? undefined,
115
+ profileDirectory: u.searchParams.get("profile") ?? undefined,
116
+ userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
117
+ };
58
118
  }
59
119
 
60
120
  export async function getOrCreateUrl(): Promise<string> {
61
121
  if (process.env[ENV_KEY]) return process.env[ENV_KEY];
62
122
  const key = randomBytes(9).toString("base64url"); // 12 chars
63
- const url = `remote-chrome://${key}@${hostname()}:${DEFAULT_PORT}`;
123
+ const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
64
124
  const newLine = `${ENV_KEY}=${url}`;
65
- const envRaw = await file(envFile)
66
- .text()
67
- .catch(() => "");
68
- const content = envRaw.trimEnd() ? envRaw.trimEnd() + "\n" + newLine + "\n" : newLine + "\n";
69
- 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);
70
130
  process.env[ENV_KEY] = url;
71
131
  return url;
72
132
  }
@@ -121,49 +181,182 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
121
181
  return { hostname: hostname(), cwd };
122
182
  }
123
183
 
124
- function getClientEnv(): Record<string, string> {
184
+ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Promise<Record<string, string>> {
125
185
  const env: Record<string, string> = {};
126
186
  for (const key of PASSTHROUGH_ENV_KEYS) {
127
187
  if (process.env[key]) env[key] = process.env[key];
128
188
  }
189
+ if (urlExtras?.extensionId)
190
+ env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
191
+ if (urlExtras?.profileDirectory)
192
+ env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
193
+ if (urlExtras?.userDataDir)
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;
129
212
  return env;
130
213
  }
131
214
 
132
- async function run(url: string, args: string[]) {
133
- const { key, host, port } = parseUrl(url);
215
+ const CHROME_LOCAL_STATE_PATHS = () => {
216
+ const home = process.env.HOME || "~";
217
+ return [
218
+ join(home, "Library/Application Support/Google/Chrome/Local State"),
219
+ join(home, ".config/google-chrome/Local State"),
220
+ join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
221
+ ];
222
+ };
223
+
224
+ async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
225
+ for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
226
+ const f = file(statePath);
227
+ if (!(await f.exists())) continue;
228
+ try {
229
+ const data = JSON.parse(await f.text());
230
+ return data?.profile?.info_cache ?? null;
231
+ } catch {}
232
+ }
233
+ return null;
234
+ }
134
235
 
236
+ async function findChromeUserDataDir(): Promise<string | null> {
237
+ for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
238
+ if (!(await file(statePath).exists())) continue;
239
+ return dirname(statePath);
240
+ }
241
+ return null;
242
+ }
243
+
244
+ export const EXTENSION_DIST_DIR = join(
245
+ import.meta.dir,
246
+ "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
247
+ );
248
+
249
+ // Walk all Chrome profiles' Secure Preferences and find an extension
250
+ // whose loaded `path` matches our dist directory. The extension ID Chrome
251
+ // generates for an unpacked extension is path-dependent, so we cannot rely
252
+ // on a hardcoded ID across machines.
253
+ async function findInstalledExtension(
254
+ profileDir?: string,
255
+ ): Promise<{ id: string; profile: string } | null> {
256
+ const userDataDir = await findChromeUserDataDir();
257
+ if (!userDataDir) return null;
258
+ const cache = await readChromeProfileCache();
259
+ const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
260
+ for (const prof of profiles) {
261
+ const prefsPath = join(userDataDir, prof, "Secure Preferences");
262
+ const f = file(prefsPath);
263
+ if (!(await f.exists())) continue;
264
+ try {
265
+ const data = JSON.parse(await f.text());
266
+ const settings = data?.extensions?.settings ?? {};
267
+ for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
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 };
274
+ }
275
+ } catch {}
276
+ }
277
+ return null;
278
+ }
279
+
280
+ function printInstallInstructions(profileDisplay: string): void {
281
+ console.error("");
282
+ console.error("Multi-tab extension is not installed in this Chrome profile.");
283
+ console.error("");
284
+ console.error("To install:");
285
+ console.error(" 1. Open chrome://extensions/ in the selected profile");
286
+ console.error(` (profile: ${profileDisplay})`);
287
+ console.error(" 2. Enable \"Developer mode\" (top-right toggle)");
288
+ console.error(" 3. Click \"Load unpacked\"");
289
+ console.error(" 4. Select this directory:");
290
+ console.error(` ${EXTENSION_DIST_DIR}`);
291
+ console.error(" 5. Re-run `rech setup`");
292
+ console.error("");
293
+ }
294
+
295
+ async function resolveProfileEmail(dir: string): Promise<string> {
296
+ const cache = await readChromeProfileCache();
297
+ if (cache?.[dir]?.user_name) return cache[dir].user_name;
298
+ return dir;
299
+ }
300
+
301
+ async function listProfiles(): Promise<void> {
302
+ const cache = await readChromeProfileCache();
303
+ if (!cache) { console.error("Chrome Local State not found"); process.exit(1); }
304
+
305
+ const current = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
306
+ // Resolve email/name → dir for current marker
307
+ let currentDir = current;
308
+ if (current && !/^(Default|Profile \d+)$/i.test(current)) {
309
+ for (const [dir, info] of Object.entries(cache)) {
310
+ if (info.user_name === current || info.name === current) { currentDir = dir; break; }
311
+ }
312
+ }
313
+
314
+ const rows = Object.entries(cache).map(([dir, info]) => [
315
+ dir,
316
+ info.user_name || "",
317
+ info.name || "",
318
+ dir === currentDir ? "← current" : "",
319
+ ]);
320
+ const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
321
+ for (const row of rows) {
322
+ console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
323
+ }
324
+ }
325
+
326
+ async function callServe(
327
+ url: string,
328
+ args: string[],
329
+ overrideEnv?: Record<string, string>,
330
+ ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
331
+ const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
135
332
  const identity = await getClientIdentity();
136
- console.error(
137
- `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`})`,
138
- );
139
- const res = await fetch(`http://${host}:${port}/run`, {
333
+ const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
334
+ if (effectiveProfile) (identity as any).profile = effectiveProfile;
335
+ const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
336
+ const res = await fetch(`${protocol}://${host}:${port}/run`, {
140
337
  method: "POST",
141
338
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
142
- body: JSON.stringify({ args, identity, env: getClientEnv() }),
339
+ body: JSON.stringify({ args, identity, env }),
143
340
  signal: AbortSignal.timeout(70_000),
144
- }).catch((e) => {
145
- console.error(`[rech] ${e.message}`);
146
- process.exit(1);
147
- });
148
-
149
- if (res.status === 401) {
150
- console.error("Unauthorized: bad key");
151
- process.exit(1);
152
- }
153
-
154
- const { status, stdout, stderr, files, existingSession } = (await res.json()) as {
155
- status: number;
156
- stdout: string;
157
- stderr: string;
158
- files?: string[];
159
- existingSession?: boolean;
160
- };
341
+ }).catch((e) => { console.error(`[rech] ${e.message}`); process.exit(1); });
342
+ if (res.status === 401) { console.error("Unauthorized: bad key"); process.exit(1); }
343
+ return res.json();
344
+ }
161
345
 
162
- if (existingSession) {
163
- console.error(
164
- `[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
165
- );
166
- }
346
+ async function run(url: string, args: string[]) {
347
+ const { host, port, protocol } = parseUrl(url);
348
+ const effectiveProfile = parseUrl(url).profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
349
+ const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
350
+ const identity = await getClientIdentity();
351
+ const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
352
+ console.error(
353
+ `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
354
+ );
355
+
356
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
357
+
358
+ if (existingSession)
359
+ console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
167
360
  if (stderr) process.stderr.write(stderr);
168
361
  if (stdout) process.stdout.write(stdout);
169
362
 
@@ -173,8 +366,8 @@ async function run(url: string, args: string[]) {
173
366
  const gitignorePath = join(dlDir, ".gitignore");
174
367
  if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
175
368
  for (const name of files) {
176
- const fileRes = await fetch(`http://${host}:${port}/files/${name}`, {
177
- headers: { Authorization: `Bearer ${key}` },
369
+ const fileRes = await fetch(`${protocol}://${host}:${port}/files/${name}`, {
370
+ headers: { Authorization: `Bearer ${parseUrl(url).key}` },
178
371
  });
179
372
  if (!fileRes.ok) continue;
180
373
  const dest = join(dlDir, basename(name));
@@ -186,20 +379,370 @@ async function run(url: string, args: string[]) {
186
379
  process.exit(status);
187
380
  }
188
381
 
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 {}
499
+ }
500
+ const url = await getOrCreateUrl();
501
+ const { host, port, protocol } = parseUrl(url);
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}`);
525
+ }
526
+
527
+ const cache = await readChromeProfileCache();
528
+ if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
529
+ const userDataDir = await findChromeUserDataDir();
530
+
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];
552
+ }
553
+
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 };
587
+ }
588
+
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;
595
+
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 });
612
+ const existing = await file(globalEnvPath).text().catch(() => "");
613
+ const rechUrl = new URL(url);
614
+ rechUrl.searchParams.set("extension_id", extId);
615
+ rechUrl.searchParams.set("token", token);
616
+ rechUrl.searchParams.set("profile", profileEmail);
617
+ if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
618
+ const newLine = `RECHROME_URL=${rechUrl.toString()}`;
619
+ const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
620
+ let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
621
+ const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
622
+ if (rechIdx >= 0) lines[rechIdx] = newLine;
623
+ else lines.push(newLine);
624
+ await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
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`);
711
+ }
712
+
189
713
  if (import.meta.main) {
190
714
  const args = process.argv.slice(2);
715
+ const cmd = args[0]?.toLowerCase();
191
716
 
192
- if (args[0] === "serve") {
717
+ if (cmd === "serve") {
193
718
  const { serve } = await import("./serve.js");
194
- serve();
719
+ serve(); // long-lived; watcher intentionally kept alive
720
+ } else if (cmd === "status") {
721
+ await status();
722
+ envWatcher?.close();
723
+ } else if (cmd === "profiles") {
724
+ await listProfiles();
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();
195
738
  } else {
196
739
  const url = process.env[ENV_KEY];
197
740
  if (!url) {
198
- console.error(
199
- `Usage:\n rech serve\n ${ENV_KEY}=remote-chrome://key@host:${DEFAULT_PORT} rech <playwright-args...>`,
200
- );
741
+ console.error(`${ENV_KEY} is not set. Run \`rech setup\` to configure.\n`);
742
+ printHelp();
201
743
  process.exit(1);
202
744
  }
203
- run(url, args);
745
+ await run(url, args);
746
+ envWatcher?.close();
204
747
  }
205
748
  }