rechrome 1.4.0 → 1.6.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 +280 -44
  4. package/rech.ts +280 -44
  5. package/serve.js +104 -10
  6. package/serve.ts +104 -10
package/README.md CHANGED
@@ -49,24 +49,27 @@ This auto-generates a connection URL in `.env.local` (with an auth key).
49
49
 
50
50
  ### 2. Run commands from a client
51
51
 
52
- Copy the `REMOTE_CHROME_URL` from the server's `.env.local` to the client:
52
+ Copy the `RECHROME_URL` from the server's `.env.local` to the client's project `.env.local`:
53
53
 
54
54
  ```bash
55
- export REMOTE_CHROME_URL=remote-chrome://YOUR_KEY@server-host:13775
55
+ # .env.local in your project directory
56
+ RECHROME_URL=http://YOUR_KEY@server-host:13775
56
57
 
57
58
  # Open a URL
58
- rechrome open https://example.com
59
+ rech open https://example.com
59
60
 
60
61
  # Take a screenshot
61
- rechrome screenshot
62
+ rech screenshot
62
63
 
63
64
  # List open tabs
64
- rechrome tab-list
65
+ rech tab-list
65
66
 
66
67
  # Any playwright-cli command works
67
- rechrome --help
68
+ rech --help
68
69
  ```
69
70
 
71
+ rechrome walks up from the current working directory to find `.env.local`, so each project can have its own connection URL, Chrome profile, and extension token.
72
+
70
73
  ## Configuration
71
74
 
72
75
  Copy `.env.example` to `.env.local` and edit:
@@ -75,22 +78,24 @@ Copy `.env.example` to `.env.local` and edit:
75
78
  cp .env.example .env.local
76
79
  ```
77
80
 
78
- | Variable | Description | Default |
79
- | -------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------- |
80
- | `REMOTE_CHROME_URL` | Connection URL (auto-generated by `rechrome serve`) | — |
81
- | `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
82
- | `RECH_HOST` | Server bind address | `127.0.0.1` |
83
- | `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
84
- | `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | Chrome extension token (client overrides server) | — |
85
- | `PLAYWRIGHT_MCP_USER_DATA_DIR` | Chrome user data directory — use to pin connections to a specific Chrome install (client overrides server) | — |
86
- | `PLAYWRIGHT_MCP_PROFILE_DIRECTORY` | Chrome profile sub-directory (e.g. `Profile 2`) use when multiple profiles share the same user data dir (client overrides server) | — |
87
-
88
- > **Multi-profile tip:** If you have multiple Chrome profiles open and only one has the Playwright MCP Bridge extension, set both vars so the extension connection always targets the correct profile:
81
+ | Variable | Description | Default |
82
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
83
+ | `RECHROME_URL` | Connection URL (auto-generated by `rech serve`). Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
84
+ | `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
85
+ | `RECH_HOST` | Server bind address | `127.0.0.1` |
86
+ | `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
87
+ | `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | Chrome extension token — profile-specific, get it from the extension's status page (client overrides server) | — |
88
+ | `PLAYWRIGHT_MCP_USER_DATA_DIR` | Chrome user data directory — use to pin connections to a specific Chrome install (client overrides server) | — |
89
+ | `PLAYWRIGHT_MCP_PROFILE_DIRECTORY` | Chrome profile — accepts directory name (`Profile 2`), display name (`Snowstar`), or **email** (`you@example.com`) (client overrides server) | — |
90
+
91
+ > **Multi-profile tip:** Each project's `.env.local` can specify a different Chrome profile via the `?profile=` query param in `RECHROME_URL`. The server resolves display names and email addresses to the actual Chrome profile directory automatically (reads `~/Library/Application Support/Google/Chrome/Local State`).
92
+ >
89
93
  > ```
90
- > PLAYWRIGHT_MCP_USER_DATA_DIR=/Users/yourname/Library/Application Support/Google/Chrome
91
- > PLAYWRIGHT_MCP_PROFILE_DIRECTORY=Profile 2
94
+ > # .env.local for a work project
95
+ > RECHROME_URL="http://KEY@server:13775?token=TOKEN&extension_id=EXT_ID&profile=taku%40company.com"
92
96
  > ```
93
- > To find your profile directory name, open `chrome://version` in the target Chrome profile and look for **Profile Path**.
97
+ >
98
+ > Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
94
99
 
95
100
  ### Remote access
96
101
 
@@ -115,9 +120,24 @@ bun install
115
120
  bun test
116
121
  ```
117
122
 
123
+ ## Why we fork playwright
124
+
125
+ rechrome depends on [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab), which is a fork of [microsoft/playwright](https://github.com/microsoft/playwright). We maintain it because the upstream does not yet support several features required for rechrome's use case:
126
+
127
+ | Feature | Our change | Status |
128
+ |---------|-----------|--------|
129
+ | Multi-tab control | `playwright-multi-tab` fork adds `tab-new`, `tab-list`, `tab-select`, `tab-close` commands and a persistent session daemon | Not in upstream |
130
+ | `PLAYWRIGHT_MCP_EXTENSION_ID` | Lets you specify a custom extension ID instead of the hardcoded default | Not in upstream |
131
+ | `PLAYWRIGHT_MCP_PROFILE_DIRECTORY` | Passes `--profile-directory` to Chrome so the correct system profile is used; auto-detects the Chrome user data dir by OS | Not in upstream |
132
+
133
+ We also fork [playwright-mcp](https://github.com/snomiao/playwright-mcp) (inside `playwright-multi-tab/lib/playwright-mcp`) to support the custom extension ID and multi-tab session routing.
134
+
135
+ PRs upstream are welcome. Once these features land in the official packages we will drop the forks.
136
+
118
137
  ## Related
119
138
 
120
139
  - [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering rechrome's multi-tab and multi-session browser control
140
+ - [microsoft/playwright](https://github.com/microsoft/playwright) — upstream
121
141
 
122
142
  ## License
123
143
 
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
7
7
  },
8
8
  "bin": {
9
- "rech": "./rech.js",
10
- "rechrome": "./rech.js"
9
+ "rech": "./rech.ts",
10
+ "rechrome": "./rech.ts"
11
11
  },
12
12
  "files": [
13
13
  ".env.example",
package/rech.js CHANGED
@@ -4,29 +4,49 @@ import { file } from "bun";
4
4
  import { randomBytes } from "crypto";
5
5
  import { mkdirSync, appendFileSync, existsSync } 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)
15
14
  const envFile = join(import.meta.dir, ".env.local");
16
15
 
17
- /** Load .env.local into process.env. */
18
- async function loadEnv() {
19
- const envRaw = await file(envFile)
20
- .text()
21
- .catch(() => "");
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;
22
20
  for (const line of envRaw.split("\n")) {
23
21
  const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
24
- if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
22
+ if (m) {
23
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
24
+ if (m[1] === ENV_KEY) hasKey = true;
25
+ }
26
+ }
27
+ return hasKey;
28
+ }
29
+
30
+ async function loadEnv() {
31
+ // Walk up from cwd first — project-local .env.local takes priority
32
+ let dir = process.cwd();
33
+ while (true) {
34
+ if (await loadEnvFile(join(dir, ".env.local"))) break;
35
+ const parent = join(dir, "..");
36
+ if (parent === dir) break;
37
+ dir = parent;
25
38
  }
39
+ // Fall back to script dir's .env.local
40
+ if (!process.env[ENV_KEY]) await loadEnvFile(envFile);
41
+ }
42
+ // Shell-set passthrough vars survive .env.local loading
43
+ const _shellPassthrough: Record<string, string> = {};
44
+ for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
45
+ if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
26
46
  }
27
47
  await loadEnv();
48
+ Object.assign(process.env, _shellPassthrough);
28
49
 
29
- // Watch .env.local for changes and hot-reload
30
50
  import { watch } from "node:fs";
31
51
  if (existsSync(envFile)) {
32
52
  watch(envFile, async () => {
@@ -39,8 +59,8 @@ if (existsSync(envFile)) {
39
59
  export const PASSTHROUGH_ENV_KEYS = [
40
60
  "PLAYWRIGHT_MCP_EXTENSION_ID",
41
61
  "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
42
- "PLAYWRIGHT_MCP_USER_DATA_DIR",
43
62
  "PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
63
+ "PLAYWRIGHT_MCP_USER_DATA_DIR",
44
64
  ] as const;
45
65
 
46
66
  export function log(msg: string) {
@@ -54,13 +74,25 @@ export function log(msg: string) {
54
74
 
55
75
  export function parseUrl(raw: string) {
56
76
  const u = new URL(raw);
57
- return { key: u.username, host: u.hostname, port: parseInt(u.port) || DEFAULT_PORT };
77
+ const scheme = u.protocol.replace(":", "");
78
+ const protocol = scheme === "https" ? "https" : "http";
79
+ const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
80
+ return {
81
+ key: u.username,
82
+ host: u.hostname,
83
+ port: parseInt(u.port) || defaultPort,
84
+ protocol,
85
+ extensionId: u.searchParams.get("extension_id") ?? undefined,
86
+ extensionToken: u.searchParams.get("token") ?? undefined,
87
+ profileDirectory: u.searchParams.get("profile") ?? undefined,
88
+ userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
89
+ };
58
90
  }
59
91
 
60
92
  export async function getOrCreateUrl(): Promise<string> {
61
93
  if (process.env[ENV_KEY]) return process.env[ENV_KEY];
62
94
  const key = randomBytes(9).toString("base64url"); // 12 chars
63
- const url = `remote-chrome://${key}@${hostname()}:${DEFAULT_PORT}`;
95
+ const url = `http://${key}@${hostname()}:${DEFAULT_PORT}`;
64
96
  const newLine = `${ENV_KEY}=${url}`;
65
97
  const envRaw = await file(envFile)
66
98
  .text()
@@ -121,49 +153,162 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
121
153
  return { hostname: hostname(), cwd };
122
154
  }
123
155
 
124
- function getClientEnv(): Record<string, string> {
156
+ function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Record<string, string> {
125
157
  const env: Record<string, string> = {};
126
158
  for (const key of PASSTHROUGH_ENV_KEYS) {
127
159
  if (process.env[key]) env[key] = process.env[key];
128
160
  }
161
+ if (urlExtras?.extensionId)
162
+ env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
163
+ if (urlExtras?.extensionToken)
164
+ env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
165
+ if (urlExtras?.profileDirectory)
166
+ env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
167
+ if (urlExtras?.userDataDir)
168
+ env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
129
169
  return env;
130
170
  }
131
171
 
132
- async function run(url: string, args: string[]) {
133
- const { key, host, port } = parseUrl(url);
172
+ const CHROME_LOCAL_STATE_PATHS = () => {
173
+ const home = process.env.HOME || "~";
174
+ return [
175
+ join(home, "Library/Application Support/Google/Chrome/Local State"),
176
+ join(home, ".config/google-chrome/Local State"),
177
+ join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
178
+ ];
179
+ };
180
+
181
+ async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
182
+ for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
183
+ const f = file(statePath);
184
+ if (!(await f.exists())) continue;
185
+ try {
186
+ const data = JSON.parse(await f.text());
187
+ return data?.profile?.info_cache ?? null;
188
+ } catch {}
189
+ }
190
+ return null;
191
+ }
192
+
193
+ async function findChromeUserDataDir(): Promise<string | null> {
194
+ for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
195
+ if (!(await file(statePath).exists())) continue;
196
+ return dirname(statePath);
197
+ }
198
+ return null;
199
+ }
200
+
201
+ export const EXTENSION_DIST_DIR = join(
202
+ import.meta.dir,
203
+ "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
204
+ );
205
+
206
+ // Walk all Chrome profiles' Secure Preferences and find an extension
207
+ // whose loaded `path` matches our dist directory. The extension ID Chrome
208
+ // generates for an unpacked extension is path-dependent, so we cannot rely
209
+ // on a hardcoded ID across machines.
210
+ async function findInstalledExtension(
211
+ profileDir?: string,
212
+ ): Promise<{ id: string; profile: string } | null> {
213
+ const userDataDir = await findChromeUserDataDir();
214
+ if (!userDataDir) return null;
215
+ const cache = await readChromeProfileCache();
216
+ const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
217
+ for (const prof of profiles) {
218
+ const prefsPath = join(userDataDir, prof, "Secure Preferences");
219
+ const f = file(prefsPath);
220
+ if (!(await f.exists())) continue;
221
+ try {
222
+ const data = JSON.parse(await f.text());
223
+ const settings = data?.extensions?.settings ?? {};
224
+ for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
225
+ if (info?.path === EXTENSION_DIST_DIR) return { id: extId, profile: prof };
226
+ }
227
+ } catch {}
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function printInstallInstructions(profileDisplay: string): void {
233
+ console.error("");
234
+ console.error("Multi-tab extension is not installed in this Chrome profile.");
235
+ console.error("");
236
+ console.error("To install:");
237
+ console.error(" 1. Open chrome://extensions/ in the selected profile");
238
+ console.error(` (profile: ${profileDisplay})`);
239
+ console.error(" 2. Enable \"Developer mode\" (top-right toggle)");
240
+ console.error(" 3. Click \"Load unpacked\"");
241
+ console.error(" 4. Select this directory:");
242
+ console.error(` ${EXTENSION_DIST_DIR}`);
243
+ console.error(" 5. Re-run `rech setup`");
244
+ console.error("");
245
+ }
246
+
247
+ async function resolveProfileEmail(dir: string): Promise<string> {
248
+ const cache = await readChromeProfileCache();
249
+ if (cache?.[dir]?.user_name) return cache[dir].user_name;
250
+ return dir;
251
+ }
252
+
253
+ async function listProfiles(): Promise<void> {
254
+ const cache = await readChromeProfileCache();
255
+ if (!cache) { console.error("Chrome Local State not found"); process.exit(1); }
256
+
257
+ const current = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
258
+ // Resolve email/name → dir for current marker
259
+ let currentDir = current;
260
+ if (current && !/^(Default|Profile \d+)$/i.test(current)) {
261
+ for (const [dir, info] of Object.entries(cache)) {
262
+ if (info.user_name === current || info.name === current) { currentDir = dir; break; }
263
+ }
264
+ }
134
265
 
266
+ const rows = Object.entries(cache).map(([dir, info]) => [
267
+ dir,
268
+ info.user_name || "",
269
+ info.name || "",
270
+ dir === currentDir ? "← current" : "",
271
+ ]);
272
+ const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
273
+ for (const row of rows) {
274
+ console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
275
+ }
276
+ }
277
+
278
+ async function callServe(
279
+ url: string,
280
+ args: string[],
281
+ overrideEnv?: Record<string, string>,
282
+ ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
283
+ const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
135
284
  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`, {
285
+ const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
286
+ if (effectiveProfile) (identity as any).profile = effectiveProfile;
287
+ const env = { ...getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir }), ...overrideEnv };
288
+ const res = await fetch(`${protocol}://${host}:${port}/run`, {
140
289
  method: "POST",
141
290
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
142
- body: JSON.stringify({ args, identity, env: getClientEnv() }),
291
+ body: JSON.stringify({ args, identity, env }),
143
292
  signal: AbortSignal.timeout(70_000),
144
- }).catch((e) => {
145
- console.error(`[rech] ${e.message}`);
146
- process.exit(1);
147
- });
293
+ }).catch((e) => { console.error(`[rech] ${e.message}`); process.exit(1); });
294
+ if (res.status === 401) { console.error("Unauthorized: bad key"); process.exit(1); }
295
+ return res.json();
296
+ }
148
297
 
149
- if (res.status === 401) {
150
- console.error("Unauthorized: bad key");
151
- process.exit(1);
152
- }
298
+ 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;
301
+ const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
302
+ const identity = await getClientIdentity();
303
+ const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
304
+ console.error(
305
+ `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
306
+ );
153
307
 
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
- };
308
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
161
309
 
162
- if (existingSession) {
163
- console.error(
164
- `[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
165
- );
166
- }
310
+ if (existingSession)
311
+ console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
167
312
  if (stderr) process.stderr.write(stderr);
168
313
  if (stdout) process.stdout.write(stdout);
169
314
 
@@ -173,8 +318,8 @@ async function run(url: string, args: string[]) {
173
318
  const gitignorePath = join(dlDir, ".gitignore");
174
319
  if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
175
320
  for (const name of files) {
176
- const fileRes = await fetch(`http://${host}:${port}/files/${name}`, {
177
- headers: { Authorization: `Bearer ${key}` },
321
+ const fileRes = await fetch(`${protocol}://${host}:${port}/files/${name}`, {
322
+ headers: { Authorization: `Bearer ${parseUrl(url).key}` },
178
323
  });
179
324
  if (!fileRes.ok) continue;
180
325
  const dest = join(dlDir, basename(name));
@@ -186,17 +331,108 @@ async function run(url: string, args: string[]) {
186
331
  process.exit(status);
187
332
  }
188
333
 
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);
340
+ }
341
+ 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);
346
+ }
347
+
348
+ // 2. Interactive profile selection
349
+ 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;
365
+
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;
373
+ }
374
+
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);
380
+ }
381
+
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
+ }
397
+
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");
401
+ const existing = await file(globalEnvPath).text().catch(() => "");
402
+ const rechUrl = new URL(url);
403
+ rechUrl.searchParams.set("extension_id", extId);
404
+ 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();
408
+ if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
409
+ const newLine = `RECHROME_URL=${rechUrl.toString()}`;
410
+ // Remove old separate vars and update RECHROME_URL
411
+ const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
412
+ let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
413
+ const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
414
+ if (rechIdx >= 0) lines[rechIdx] = newLine;
415
+ else lines.push(newLine);
416
+ await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
417
+ console.log(`\nSaved to ${globalEnvPath}:\n ${newLine}`);
418
+ console.log("\nDone!");
419
+ }
420
+
189
421
  if (import.meta.main) {
190
422
  const args = process.argv.slice(2);
191
423
 
192
424
  if (args[0] === "serve") {
193
425
  const { serve } = await import("./serve.js");
194
426
  serve();
427
+ } else if (args[0] === "profiles") {
428
+ await listProfiles();
429
+ } else if (args[0] === "setup") {
430
+ await setup();
195
431
  } else {
196
432
  const url = process.env[ENV_KEY];
197
433
  if (!url) {
198
434
  console.error(
199
- `Usage:\n rech serve\n ${ENV_KEY}=remote-chrome://key@host:${DEFAULT_PORT} rech <playwright-args...>`,
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...>`,
200
436
  );
201
437
  process.exit(1);
202
438
  }
package/rech.ts CHANGED
@@ -4,29 +4,49 @@ import { file } from "bun";
4
4
  import { randomBytes } from "crypto";
5
5
  import { mkdirSync, appendFileSync, existsSync } 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)
15
14
  const envFile = join(import.meta.dir, ".env.local");
16
15
 
17
- /** Load .env.local into process.env. */
18
- async function loadEnv() {
19
- const envRaw = await file(envFile)
20
- .text()
21
- .catch(() => "");
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;
22
20
  for (const line of envRaw.split("\n")) {
23
21
  const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
24
- if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
22
+ if (m) {
23
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
24
+ if (m[1] === ENV_KEY) hasKey = true;
25
+ }
26
+ }
27
+ return hasKey;
28
+ }
29
+
30
+ async function loadEnv() {
31
+ // Walk up from cwd first — project-local .env.local takes priority
32
+ let dir = process.cwd();
33
+ while (true) {
34
+ if (await loadEnvFile(join(dir, ".env.local"))) break;
35
+ const parent = join(dir, "..");
36
+ if (parent === dir) break;
37
+ dir = parent;
25
38
  }
39
+ // Fall back to script dir's .env.local
40
+ if (!process.env[ENV_KEY]) await loadEnvFile(envFile);
41
+ }
42
+ // Shell-set passthrough vars survive .env.local loading
43
+ const _shellPassthrough: Record<string, string> = {};
44
+ for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
45
+ if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
26
46
  }
27
47
  await loadEnv();
48
+ Object.assign(process.env, _shellPassthrough);
28
49
 
29
- // Watch .env.local for changes and hot-reload
30
50
  import { watch } from "node:fs";
31
51
  if (existsSync(envFile)) {
32
52
  watch(envFile, async () => {
@@ -39,8 +59,8 @@ if (existsSync(envFile)) {
39
59
  export const PASSTHROUGH_ENV_KEYS = [
40
60
  "PLAYWRIGHT_MCP_EXTENSION_ID",
41
61
  "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
42
- "PLAYWRIGHT_MCP_USER_DATA_DIR",
43
62
  "PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
63
+ "PLAYWRIGHT_MCP_USER_DATA_DIR",
44
64
  ] as const;
45
65
 
46
66
  export function log(msg: string) {
@@ -54,13 +74,25 @@ export function log(msg: string) {
54
74
 
55
75
  export function parseUrl(raw: string) {
56
76
  const u = new URL(raw);
57
- return { key: u.username, host: u.hostname, port: parseInt(u.port) || DEFAULT_PORT };
77
+ const scheme = u.protocol.replace(":", "");
78
+ const protocol = scheme === "https" ? "https" : "http";
79
+ const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
80
+ return {
81
+ key: u.username,
82
+ host: u.hostname,
83
+ port: parseInt(u.port) || defaultPort,
84
+ protocol,
85
+ extensionId: u.searchParams.get("extension_id") ?? undefined,
86
+ extensionToken: u.searchParams.get("token") ?? undefined,
87
+ profileDirectory: u.searchParams.get("profile") ?? undefined,
88
+ userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
89
+ };
58
90
  }
59
91
 
60
92
  export async function getOrCreateUrl(): Promise<string> {
61
93
  if (process.env[ENV_KEY]) return process.env[ENV_KEY];
62
94
  const key = randomBytes(9).toString("base64url"); // 12 chars
63
- const url = `remote-chrome://${key}@${hostname()}:${DEFAULT_PORT}`;
95
+ const url = `http://${key}@${hostname()}:${DEFAULT_PORT}`;
64
96
  const newLine = `${ENV_KEY}=${url}`;
65
97
  const envRaw = await file(envFile)
66
98
  .text()
@@ -121,49 +153,162 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
121
153
  return { hostname: hostname(), cwd };
122
154
  }
123
155
 
124
- function getClientEnv(): Record<string, string> {
156
+ function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Record<string, string> {
125
157
  const env: Record<string, string> = {};
126
158
  for (const key of PASSTHROUGH_ENV_KEYS) {
127
159
  if (process.env[key]) env[key] = process.env[key];
128
160
  }
161
+ if (urlExtras?.extensionId)
162
+ env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
163
+ if (urlExtras?.extensionToken)
164
+ env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
165
+ if (urlExtras?.profileDirectory)
166
+ env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
167
+ if (urlExtras?.userDataDir)
168
+ env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
129
169
  return env;
130
170
  }
131
171
 
132
- async function run(url: string, args: string[]) {
133
- const { key, host, port } = parseUrl(url);
172
+ const CHROME_LOCAL_STATE_PATHS = () => {
173
+ const home = process.env.HOME || "~";
174
+ return [
175
+ join(home, "Library/Application Support/Google/Chrome/Local State"),
176
+ join(home, ".config/google-chrome/Local State"),
177
+ join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
178
+ ];
179
+ };
180
+
181
+ async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
182
+ for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
183
+ const f = file(statePath);
184
+ if (!(await f.exists())) continue;
185
+ try {
186
+ const data = JSON.parse(await f.text());
187
+ return data?.profile?.info_cache ?? null;
188
+ } catch {}
189
+ }
190
+ return null;
191
+ }
192
+
193
+ async function findChromeUserDataDir(): Promise<string | null> {
194
+ for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
195
+ if (!(await file(statePath).exists())) continue;
196
+ return dirname(statePath);
197
+ }
198
+ return null;
199
+ }
200
+
201
+ export const EXTENSION_DIST_DIR = join(
202
+ import.meta.dir,
203
+ "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
204
+ );
205
+
206
+ // Walk all Chrome profiles' Secure Preferences and find an extension
207
+ // whose loaded `path` matches our dist directory. The extension ID Chrome
208
+ // generates for an unpacked extension is path-dependent, so we cannot rely
209
+ // on a hardcoded ID across machines.
210
+ async function findInstalledExtension(
211
+ profileDir?: string,
212
+ ): Promise<{ id: string; profile: string } | null> {
213
+ const userDataDir = await findChromeUserDataDir();
214
+ if (!userDataDir) return null;
215
+ const cache = await readChromeProfileCache();
216
+ const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
217
+ for (const prof of profiles) {
218
+ const prefsPath = join(userDataDir, prof, "Secure Preferences");
219
+ const f = file(prefsPath);
220
+ if (!(await f.exists())) continue;
221
+ try {
222
+ const data = JSON.parse(await f.text());
223
+ const settings = data?.extensions?.settings ?? {};
224
+ for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
225
+ if (info?.path === EXTENSION_DIST_DIR) return { id: extId, profile: prof };
226
+ }
227
+ } catch {}
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function printInstallInstructions(profileDisplay: string): void {
233
+ console.error("");
234
+ console.error("Multi-tab extension is not installed in this Chrome profile.");
235
+ console.error("");
236
+ console.error("To install:");
237
+ console.error(" 1. Open chrome://extensions/ in the selected profile");
238
+ console.error(` (profile: ${profileDisplay})`);
239
+ console.error(" 2. Enable \"Developer mode\" (top-right toggle)");
240
+ console.error(" 3. Click \"Load unpacked\"");
241
+ console.error(" 4. Select this directory:");
242
+ console.error(` ${EXTENSION_DIST_DIR}`);
243
+ console.error(" 5. Re-run `rech setup`");
244
+ console.error("");
245
+ }
246
+
247
+ async function resolveProfileEmail(dir: string): Promise<string> {
248
+ const cache = await readChromeProfileCache();
249
+ if (cache?.[dir]?.user_name) return cache[dir].user_name;
250
+ return dir;
251
+ }
252
+
253
+ async function listProfiles(): Promise<void> {
254
+ const cache = await readChromeProfileCache();
255
+ if (!cache) { console.error("Chrome Local State not found"); process.exit(1); }
256
+
257
+ const current = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
258
+ // Resolve email/name → dir for current marker
259
+ let currentDir = current;
260
+ if (current && !/^(Default|Profile \d+)$/i.test(current)) {
261
+ for (const [dir, info] of Object.entries(cache)) {
262
+ if (info.user_name === current || info.name === current) { currentDir = dir; break; }
263
+ }
264
+ }
134
265
 
266
+ const rows = Object.entries(cache).map(([dir, info]) => [
267
+ dir,
268
+ info.user_name || "",
269
+ info.name || "",
270
+ dir === currentDir ? "← current" : "",
271
+ ]);
272
+ const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
273
+ for (const row of rows) {
274
+ console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
275
+ }
276
+ }
277
+
278
+ async function callServe(
279
+ url: string,
280
+ args: string[],
281
+ overrideEnv?: Record<string, string>,
282
+ ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
283
+ const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
135
284
  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`, {
285
+ const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
286
+ if (effectiveProfile) (identity as any).profile = effectiveProfile;
287
+ const env = { ...getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir }), ...overrideEnv };
288
+ const res = await fetch(`${protocol}://${host}:${port}/run`, {
140
289
  method: "POST",
141
290
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
142
- body: JSON.stringify({ args, identity, env: getClientEnv() }),
291
+ body: JSON.stringify({ args, identity, env }),
143
292
  signal: AbortSignal.timeout(70_000),
144
- }).catch((e) => {
145
- console.error(`[rech] ${e.message}`);
146
- process.exit(1);
147
- });
293
+ }).catch((e) => { console.error(`[rech] ${e.message}`); process.exit(1); });
294
+ if (res.status === 401) { console.error("Unauthorized: bad key"); process.exit(1); }
295
+ return res.json();
296
+ }
148
297
 
149
- if (res.status === 401) {
150
- console.error("Unauthorized: bad key");
151
- process.exit(1);
152
- }
298
+ 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;
301
+ const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
302
+ const identity = await getClientIdentity();
303
+ const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
304
+ console.error(
305
+ `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
306
+ );
153
307
 
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
- };
308
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
161
309
 
162
- if (existingSession) {
163
- console.error(
164
- `[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
165
- );
166
- }
310
+ if (existingSession)
311
+ console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
167
312
  if (stderr) process.stderr.write(stderr);
168
313
  if (stdout) process.stdout.write(stdout);
169
314
 
@@ -173,8 +318,8 @@ async function run(url: string, args: string[]) {
173
318
  const gitignorePath = join(dlDir, ".gitignore");
174
319
  if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
175
320
  for (const name of files) {
176
- const fileRes = await fetch(`http://${host}:${port}/files/${name}`, {
177
- headers: { Authorization: `Bearer ${key}` },
321
+ const fileRes = await fetch(`${protocol}://${host}:${port}/files/${name}`, {
322
+ headers: { Authorization: `Bearer ${parseUrl(url).key}` },
178
323
  });
179
324
  if (!fileRes.ok) continue;
180
325
  const dest = join(dlDir, basename(name));
@@ -186,17 +331,108 @@ async function run(url: string, args: string[]) {
186
331
  process.exit(status);
187
332
  }
188
333
 
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);
340
+ }
341
+ 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);
346
+ }
347
+
348
+ // 2. Interactive profile selection
349
+ 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;
365
+
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;
373
+ }
374
+
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);
380
+ }
381
+
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
+ }
397
+
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");
401
+ const existing = await file(globalEnvPath).text().catch(() => "");
402
+ const rechUrl = new URL(url);
403
+ rechUrl.searchParams.set("extension_id", extId);
404
+ 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();
408
+ if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
409
+ const newLine = `RECHROME_URL=${rechUrl.toString()}`;
410
+ // Remove old separate vars and update RECHROME_URL
411
+ const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
412
+ let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
413
+ const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
414
+ if (rechIdx >= 0) lines[rechIdx] = newLine;
415
+ else lines.push(newLine);
416
+ await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
417
+ console.log(`\nSaved to ${globalEnvPath}:\n ${newLine}`);
418
+ console.log("\nDone!");
419
+ }
420
+
189
421
  if (import.meta.main) {
190
422
  const args = process.argv.slice(2);
191
423
 
192
424
  if (args[0] === "serve") {
193
425
  const { serve } = await import("./serve.ts");
194
426
  serve();
427
+ } else if (args[0] === "profiles") {
428
+ await listProfiles();
429
+ } else if (args[0] === "setup") {
430
+ await setup();
195
431
  } else {
196
432
  const url = process.env[ENV_KEY];
197
433
  if (!url) {
198
434
  console.error(
199
- `Usage:\n rech serve\n ${ENV_KEY}=remote-chrome://key@host:${DEFAULT_PORT} rech <playwright-args...>`,
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...>`,
200
436
  );
201
437
  process.exit(1);
202
438
  }
package/serve.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { file } from "bun";
2
- import { createHash } from "crypto";
3
- import { mkdirSync } from "fs";
2
+ import { createHash, X509Certificate } from "crypto";
3
+ import { mkdirSync, unlinkSync } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
5
  import {
6
6
  log,
@@ -11,12 +11,59 @@ import {
11
11
  PASSTHROUGH_ENV_KEYS,
12
12
  } from "./rech.js";
13
13
 
14
+ const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
15
+ const CERT_RENEW_THRESHOLD_DAYS = 7;
16
+
17
+ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
18
+ const certContent = await file(certPath).text().catch(() => null);
19
+ if (!certContent) return false;
20
+ try {
21
+ const cert = new X509Certificate(certContent);
22
+ const daysLeft = (new Date(cert.validTo).getTime() - Date.now()) / 86_400_000;
23
+ if (daysLeft > CERT_RENEW_THRESHOLD_DAYS) return false;
24
+ const domain = cert.subjectAltName?.match(/DNS:([^\s,]+)/)?.[1];
25
+ if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
26
+ log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
27
+ const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
28
+ stdout: "pipe", stderr: "pipe",
29
+ });
30
+ const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
31
+ if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
32
+ log(`TLS cert renewed for ${domain}`);
33
+ return true;
34
+ } catch (e) {
35
+ log(`TLS cert check error: ${e}`);
36
+ return false;
37
+ }
38
+ }
39
+
14
40
  export function isUnderDir(base: string, candidate: string): boolean {
15
41
  const absBase = resolve(base) + "/";
16
42
  const absCandidate = resolve(base, candidate);
17
43
  return absCandidate.startsWith(absBase);
18
44
  }
19
45
 
46
+ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
47
+ if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
48
+ const home = process.env.HOME || "~";
49
+ const candidates = [
50
+ join(home, "Library/Application Support/Google/Chrome/Local State"),
51
+ join(home, ".config/google-chrome/Local State"),
52
+ join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
53
+ ];
54
+ for (const statePath of candidates) {
55
+ const f = file(statePath);
56
+ if (!(await f.exists())) continue;
57
+ const data = JSON.parse(await f.text());
58
+ const cache: Record<string, any> = data?.profile?.info_cache ?? {};
59
+ for (const [dir, info] of Object.entries(cache)) {
60
+ if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
61
+ return dir;
62
+ }
63
+ }
64
+ return nameOrEmail;
65
+ }
66
+
20
67
  export async function serve() {
21
68
  const url = await getOrCreateUrl();
22
69
  const { key, port } = parseUrl(url);
@@ -25,9 +72,21 @@ export async function serve() {
25
72
  mkdirSync(workDir, { recursive: true });
26
73
 
27
74
  const listenHost = process.env.RECH_HOST || "127.0.0.1";
75
+ const certPath = process.env.RECH_TLS_CERT;
76
+ const keyPath = process.env.RECH_TLS_KEY;
77
+ if (certPath && keyPath) {
78
+ const renewed = await renewCertIfNeeded(certPath, keyPath);
79
+ if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
80
+ // Check daily; pm2 restarts cleanly after exit(0)
81
+ setInterval(async () => {
82
+ if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
83
+ }, 86_400_000);
84
+ }
85
+ const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
28
86
  const server = Bun.serve({
29
87
  hostname: listenHost,
30
88
  port,
89
+ tls,
31
90
  async fetch(req) {
32
91
  const reqUrl = new URL(req.url);
33
92
 
@@ -61,9 +120,10 @@ export async function serve() {
61
120
  } else {
62
121
  args = body.args;
63
122
  const id = body.identity as
64
- | { gitUrl?: string; hostname?: string; cwd?: string }
123
+ | { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
65
124
  | undefined;
66
- const raw = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
125
+ const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
126
+ const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
67
127
  if (raw) {
68
128
  sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
69
129
  clientName = raw;
@@ -101,11 +161,15 @@ export async function serve() {
101
161
 
102
162
  log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
103
163
 
104
- // For open commands, check if this session already has tabs open
164
+ // For open commands, default to about:blank to avoid leaving connect.html visible
105
165
  const isOpenCmd = filteredArgs[0] === "open";
106
- if (isOpenCmd) {
166
+ if (isOpenCmd && filteredArgs.length === 1)
167
+ filteredArgs.push("about:blank");
168
+
169
+ // bare `rech open` with no URL: warn if session already has tabs
170
+ if (isOpenCmd && filteredArgs.length === 1) {
107
171
  try {
108
- const listProc = Bun.spawn([bin, ...binArgs, "tab-list", "--extension", `-s=${namespacedSession}`], {
172
+ const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
109
173
  cwd: workDir,
110
174
  stdin: "ignore",
111
175
  stdout: "pipe",
@@ -138,6 +202,14 @@ export async function serve() {
138
202
  }
139
203
  Object.assign(passthroughEnv, clientEnv);
140
204
 
205
+ // Resolve profile name/email → directory name
206
+ if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
207
+ const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
208
+ if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
209
+ log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
210
+ passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
211
+ }
212
+
141
213
  const childEnv: Record<string, string | undefined> = {
142
214
  PATH: process.env.PATH,
143
215
  HOME: process.env.HOME,
@@ -146,8 +218,30 @@ export async function serve() {
146
218
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
147
219
  ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
148
220
  ...passthroughEnv,
221
+ // Enable extension bridge when credentials are present
222
+ ...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
223
+ ? { PLAYWRIGHT_MCP_EXTENSION: "1" }
224
+ : {}),
149
225
  };
150
- const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
226
+ // For open commands: clean up stale sockets so a closed browser can be reopened
227
+ if (isOpenCmd) {
228
+ const tmpDir = (process.env.TMPDIR || "/tmp").replace(/\/$/, "");
229
+ const playwrightTmpDir = `${tmpDir}/playwright-cli`;
230
+ try {
231
+ const { readdirSync } = await import("fs");
232
+ for (const sub of readdirSync(playwrightTmpDir)) {
233
+ const subDir = `${playwrightTmpDir}/${sub}`;
234
+ for (const f of readdirSync(subDir)) {
235
+ if (f.startsWith(namespacedSession)) {
236
+ const sockPath = `${subDir}/${f}`;
237
+ try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
238
+ }
239
+ }
240
+ }
241
+ } catch {}
242
+ }
243
+
244
+ const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
151
245
  cwd: workDir,
152
246
  stdin: "ignore",
153
247
  stdout: "pipe",
@@ -171,7 +265,7 @@ export async function serve() {
171
265
  timeout.then(() => [1, "", ""] as [number, string, string]),
172
266
  ]).catch(
173
267
  () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
174
- );
268
+ ) as [number, string, string];
175
269
 
176
270
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
177
271
 
@@ -209,6 +303,6 @@ export async function serve() {
209
303
  },
210
304
  });
211
305
 
212
- log(`serving on http://${server.hostname}:${server.port}`);
306
+ log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
213
307
  log(`Connection URL set (use .env.local to view)`);
214
308
  }
package/serve.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { file } from "bun";
2
- import { createHash } from "crypto";
3
- import { mkdirSync } from "fs";
2
+ import { createHash, X509Certificate } from "crypto";
3
+ import { mkdirSync, unlinkSync } from "fs";
4
4
  import { join, resolve, relative, isAbsolute } from "path";
5
5
  import {
6
6
  log,
@@ -11,12 +11,59 @@ import {
11
11
  PASSTHROUGH_ENV_KEYS,
12
12
  } from "./rech.ts";
13
13
 
14
+ const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
15
+ const CERT_RENEW_THRESHOLD_DAYS = 7;
16
+
17
+ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
18
+ const certContent = await file(certPath).text().catch(() => null);
19
+ if (!certContent) return false;
20
+ try {
21
+ const cert = new X509Certificate(certContent);
22
+ const daysLeft = (new Date(cert.validTo).getTime() - Date.now()) / 86_400_000;
23
+ if (daysLeft > CERT_RENEW_THRESHOLD_DAYS) return false;
24
+ const domain = cert.subjectAltName?.match(/DNS:([^\s,]+)/)?.[1];
25
+ if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
26
+ log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
27
+ const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
28
+ stdout: "pipe", stderr: "pipe",
29
+ });
30
+ const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
31
+ if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
32
+ log(`TLS cert renewed for ${domain}`);
33
+ return true;
34
+ } catch (e) {
35
+ log(`TLS cert check error: ${e}`);
36
+ return false;
37
+ }
38
+ }
39
+
14
40
  export function isUnderDir(base: string, candidate: string): boolean {
15
41
  const absBase = resolve(base) + "/";
16
42
  const absCandidate = resolve(base, candidate);
17
43
  return absCandidate.startsWith(absBase);
18
44
  }
19
45
 
46
+ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
47
+ if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
48
+ const home = process.env.HOME || "~";
49
+ const candidates = [
50
+ join(home, "Library/Application Support/Google/Chrome/Local State"),
51
+ join(home, ".config/google-chrome/Local State"),
52
+ join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
53
+ ];
54
+ for (const statePath of candidates) {
55
+ const f = file(statePath);
56
+ if (!(await f.exists())) continue;
57
+ const data = JSON.parse(await f.text());
58
+ const cache: Record<string, any> = data?.profile?.info_cache ?? {};
59
+ for (const [dir, info] of Object.entries(cache)) {
60
+ if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
61
+ return dir;
62
+ }
63
+ }
64
+ return nameOrEmail;
65
+ }
66
+
20
67
  export async function serve() {
21
68
  const url = await getOrCreateUrl();
22
69
  const { key, port } = parseUrl(url);
@@ -25,9 +72,21 @@ export async function serve() {
25
72
  mkdirSync(workDir, { recursive: true });
26
73
 
27
74
  const listenHost = process.env.RECH_HOST || "127.0.0.1";
75
+ const certPath = process.env.RECH_TLS_CERT;
76
+ const keyPath = process.env.RECH_TLS_KEY;
77
+ if (certPath && keyPath) {
78
+ const renewed = await renewCertIfNeeded(certPath, keyPath);
79
+ if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
80
+ // Check daily; pm2 restarts cleanly after exit(0)
81
+ setInterval(async () => {
82
+ if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
83
+ }, 86_400_000);
84
+ }
85
+ const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
28
86
  const server = Bun.serve({
29
87
  hostname: listenHost,
30
88
  port,
89
+ tls,
31
90
  async fetch(req) {
32
91
  const reqUrl = new URL(req.url);
33
92
 
@@ -61,9 +120,10 @@ export async function serve() {
61
120
  } else {
62
121
  args = body.args;
63
122
  const id = body.identity as
64
- | { gitUrl?: string; hostname?: string; cwd?: string }
123
+ | { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
65
124
  | undefined;
66
- const raw = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
125
+ const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
126
+ const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
67
127
  if (raw) {
68
128
  sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
69
129
  clientName = raw;
@@ -101,11 +161,15 @@ export async function serve() {
101
161
 
102
162
  log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
103
163
 
104
- // For open commands, check if this session already has tabs open
164
+ // For open commands, default to about:blank to avoid leaving connect.html visible
105
165
  const isOpenCmd = filteredArgs[0] === "open";
106
- if (isOpenCmd) {
166
+ if (isOpenCmd && filteredArgs.length === 1)
167
+ filteredArgs.push("about:blank");
168
+
169
+ // bare `rech open` with no URL: warn if session already has tabs
170
+ if (isOpenCmd && filteredArgs.length === 1) {
107
171
  try {
108
- const listProc = Bun.spawn([bin, ...binArgs, "tab-list", "--extension", `-s=${namespacedSession}`], {
172
+ const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
109
173
  cwd: workDir,
110
174
  stdin: "ignore",
111
175
  stdout: "pipe",
@@ -138,6 +202,14 @@ export async function serve() {
138
202
  }
139
203
  Object.assign(passthroughEnv, clientEnv);
140
204
 
205
+ // Resolve profile name/email → directory name
206
+ if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
207
+ const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
208
+ if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
209
+ log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
210
+ passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
211
+ }
212
+
141
213
  const childEnv: Record<string, string | undefined> = {
142
214
  PATH: process.env.PATH,
143
215
  HOME: process.env.HOME,
@@ -146,8 +218,30 @@ export async function serve() {
146
218
  XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
147
219
  ...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
148
220
  ...passthroughEnv,
221
+ // Enable extension bridge when credentials are present
222
+ ...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
223
+ ? { PLAYWRIGHT_MCP_EXTENSION: "1" }
224
+ : {}),
149
225
  };
150
- const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, "--extension", `-s=${namespacedSession}`], {
226
+ // For open commands: clean up stale sockets so a closed browser can be reopened
227
+ if (isOpenCmd) {
228
+ const tmpDir = (process.env.TMPDIR || "/tmp").replace(/\/$/, "");
229
+ const playwrightTmpDir = `${tmpDir}/playwright-cli`;
230
+ try {
231
+ const { readdirSync } = await import("fs");
232
+ for (const sub of readdirSync(playwrightTmpDir)) {
233
+ const subDir = `${playwrightTmpDir}/${sub}`;
234
+ for (const f of readdirSync(subDir)) {
235
+ if (f.startsWith(namespacedSession)) {
236
+ const sockPath = `${subDir}/${f}`;
237
+ try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
238
+ }
239
+ }
240
+ }
241
+ } catch {}
242
+ }
243
+
244
+ const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
151
245
  cwd: workDir,
152
246
  stdin: "ignore",
153
247
  stdout: "pipe",
@@ -171,7 +265,7 @@ export async function serve() {
171
265
  timeout.then(() => [1, "", ""] as [number, string, string]),
172
266
  ]).catch(
173
267
  () => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
174
- );
268
+ ) as [number, string, string];
175
269
 
176
270
  log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
177
271
 
@@ -209,6 +303,6 @@ export async function serve() {
209
303
  },
210
304
  });
211
305
 
212
- log(`serving on http://${server.hostname}:${server.port}`);
306
+ log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
213
307
  log(`Connection URL set (use .env.local to view)`);
214
308
  }