rechrome 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/package.json +1 -1
  2. package/rech.js +185 -61
  3. package/rech.ts +185 -61
  4. package/serve.js +231 -40
  5. package/serve.ts +231 -40
package/rech.ts CHANGED
@@ -104,7 +104,7 @@ function openInDefaultApp(target: string): void {
104
104
  const cmd = process.platform === "darwin" ? ["open", target]
105
105
  : process.platform === "win32" ? ["cmd", "/c", "start", "", target]
106
106
  : ["xdg-open", target];
107
- try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
107
+ try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true }); } catch {}
108
108
  }
109
109
 
110
110
  // Best-effort path to the Chrome executable for the current platform (used to open a
@@ -137,7 +137,7 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
137
137
  try {
138
138
  Bun.spawn(
139
139
  [chromeBin, `--profile-directory=${profileDir}`, target],
140
- { stdout: "ignore", stderr: "ignore", detached: true },
140
+ { stdout: "ignore", stderr: "ignore", detached: true, windowsHide: true },
141
141
  );
142
142
  return true;
143
143
  } catch {
@@ -194,48 +194,117 @@ export function authCheck(req: Request, key: string): Response | null {
194
194
  return null;
195
195
  }
196
196
 
197
- async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string; cwd?: string }> {
198
- const cwd = process.cwd();
197
+ function realpathSafe(p: string): string {
198
+ try { return realpathSync(p); } catch { return p; }
199
+ }
200
+
201
+ async function gitOutput(args: string[], cwd: string): Promise<string | null> {
199
202
  try {
200
- const remoteProc = Bun.spawn(["git", "remote", "get-url", "origin"], {
201
- cwd,
202
- stdout: "pipe",
203
- stderr: "ignore",
204
- });
205
- const remoteUrl = (await new Response(remoteProc.stdout).text()).trim();
206
- await remoteProc.exited;
203
+ // windowsHide: don't flash a console window on Windows (git.exe is a console app)
204
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore", windowsHide: true });
205
+ const out = (await new Response(proc.stdout).text()).trim();
206
+ await proc.exited;
207
+ return out || null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
207
212
 
208
- const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
209
- cwd,
210
- stdout: "pipe",
211
- stderr: "ignore",
212
- });
213
- const branch = (await new Response(branchProc.stdout).text()).trim();
214
- await branchProc.exited;
215
-
216
- if (remoteUrl) {
217
- let gitUrl: string;
218
- const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
219
- const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
220
- if (sshMatch) {
221
- gitUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
222
- } else if (httpsMatch) {
223
- gitUrl = `https://${httpsMatch[1]}/${httpsMatch[2]}`;
224
- } else {
225
- gitUrl = remoteUrl.replace(/\.git$/, "");
226
- }
227
- if (branch) gitUrl += `/tree/${branch}`;
228
- // Strip any embedded credentials from the URL
229
- try {
230
- const u = new URL(gitUrl);
231
- u.username = "";
232
- u.password = "";
233
- gitUrl = u.toString();
234
- } catch {}
235
- return { gitUrl };
213
+ // Normalize a git remote URL to "host/owner/repo" — no scheme, no .git, no credentials.
214
+ export function normalizeRemote(remoteUrl: string): string {
215
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
216
+ const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?$/);
217
+ if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`;
218
+ if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`;
219
+ return remoteUrl.replace(/^[^/]*:\/\//, "").replace(/^[^@]*@/, "").replace(/\.git$/, "");
220
+ }
221
+
222
+ // Derive the session bucket KEY (what the server hashes) and a human LABEL (logs / tab group)
223
+ // from already-gathered git facts. KEY and LABEL are deliberately decoupled: the key is keyed
224
+ // on the *worktree root path* for predictability (a human can tell which browser they drive),
225
+ // while the label renders a pretty <remote>#<basename>@<branch> for display only.
226
+ // - mode "worktree" (default): key = realpath(worktree root). Stable across `cd` within the
227
+ // project, distinct per worktree (no same-branch collisions), survives `git checkout`
228
+ // (no mutable-branch surprise), and has no branch so detached HEAD doesn't degrade it.
229
+ // - mode "branch": legacy opt-in — key = <remote>/tree/<branch> (the old behavior).
230
+ // - mode "cwd": key = realpath(cwd).
231
+ // Pass realpath'd `cwd` and `root`.
232
+ export function deriveIdentity(opts: {
233
+ mode: string;
234
+ cwd: string;
235
+ host: string;
236
+ root?: string | null; // worktree root (already realpath'd), null when not in git
237
+ remote?: string | null; // normalized host/owner/repo
238
+ branch?: string | null; // branch name, or short SHA when detached
239
+ }): { key: string; label: string } {
240
+ const { mode, cwd, host } = opts;
241
+ const root = opts.root || null;
242
+ const remote = opts.remote || null;
243
+ const branch = opts.branch || null;
244
+
245
+ let key: string;
246
+ if (mode === "branch") {
247
+ key = remote ? `https://${remote}${branch ? `/tree/${branch}` : ""}` : `${host}:${cwd}`;
248
+ } else if (mode === "cwd") {
249
+ key = `cwd:${cwd}`;
250
+ } else {
251
+ key = `worktree:${root || cwd}`;
252
+ }
253
+
254
+ let label: string;
255
+ if (root) {
256
+ label = `${remote ? `${remote}#` : ""}${basename(root)}${branch ? `@${branch}` : ""}`;
257
+ } else if (remote) {
258
+ label = `${remote}${branch ? `/tree/${branch}` : ""}`;
259
+ } else {
260
+ label = `${host}:${cwd}`;
261
+ }
262
+ return { key, label };
263
+ }
264
+
265
+ async function getClientIdentity(): Promise<{ key: string; label: string; profile?: string }> {
266
+ const cwd = realpathSafe(process.cwd());
267
+ const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
268
+ let root: string | null = null;
269
+ let remote: string | null = null;
270
+ let branch: string | null = null;
271
+
272
+ const top = await gitOutput(["rev-parse", "--show-toplevel"], cwd);
273
+ if (top) {
274
+ // Roll a submodule cwd up to the outermost superproject working tree, so submodule work
275
+ // shares the parent worktree's browser session (monorepo-friendly). Bounded loop guards
276
+ // against pathological nesting.
277
+ let superCwd = top;
278
+ for (let i = 0; i < 16; i++) {
279
+ const sup = await gitOutput(["rev-parse", "--show-superproject-working-tree"], superCwd);
280
+ if (!sup) break;
281
+ superCwd = sup;
236
282
  }
237
- } catch {}
238
- return { hostname: hostname(), cwd };
283
+ root = realpathSafe(superCwd);
284
+ branch = await gitOutput(["rev-parse", "--abbrev-ref", "HEAD"], root);
285
+ if (!branch || branch === "HEAD")
286
+ branch = await gitOutput(["rev-parse", "--short", "HEAD"], root); // detached HEAD
287
+ const remoteUrl = await gitOutput(["remote", "get-url", "origin"], root);
288
+ if (remoteUrl) remote = normalizeRemote(remoteUrl);
289
+ }
290
+
291
+ return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
292
+ }
293
+
294
+ // Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
295
+ // PLAYWRIGHT_MCP_PROFILE_DIRECTORY shell env is the fallback. When both are set and differ,
296
+ // warn once — a silent mismatch here is how an OAuth/login flow can target the WRONG account.
297
+ let _profileMismatchWarned = false;
298
+ function resolveEffectiveProfile(urlProfile?: string): string | undefined {
299
+ const envProfile = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
300
+ if (urlProfile && envProfile && urlProfile !== envProfile && !_profileMismatchWarned) {
301
+ _profileMismatchWarned = true;
302
+ console.error(
303
+ `[rech] warning: profile mismatch — RECHROME_URL profile="${urlProfile}" wins over ` +
304
+ `PLAYWRIGHT_MCP_PROFILE_DIRECTORY="${envProfile}". Unset the env var to silence this.`,
305
+ );
306
+ }
307
+ return urlProfile || envProfile;
239
308
  }
240
309
 
241
310
  async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string; loadExtension?: string }): Promise<Record<string, string>> {
@@ -410,12 +479,18 @@ async function listProfiles(): Promise<void> {
410
479
  }
411
480
  }
412
481
 
413
- const rows = Object.entries(cache).map(([dir, info]) => [
414
- dir,
415
- info.user_name || "",
416
- info.name || "",
417
- dir === currentDir ? " current" : "",
418
- ]);
482
+ // Header clarifies each column; email/nickname sit beside the profile dir so a bare
483
+ // "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
484
+ // from Local State — the gaia real name is deliberately never surfaced.
485
+ const rows = [
486
+ ["PROFILE", "EMAIL", "NICKNAME", ""],
487
+ ...Object.entries(cache).map(([dir, info]) => [
488
+ dir,
489
+ info.user_name || "",
490
+ info.name || "",
491
+ dir === currentDir ? "← current" : "",
492
+ ]),
493
+ ];
419
494
  const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
420
495
  for (const row of rows) {
421
496
  console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
@@ -426,11 +501,15 @@ async function callServe(
426
501
  url: string,
427
502
  args: string[],
428
503
  overrideEnv?: Record<string, string>,
504
+ precomputedIdentity?: { key: string; label: string; profile?: string },
429
505
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
430
506
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
431
- const identity = await getClientIdentity();
432
- const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
433
- if (effectiveProfile) (identity as any).profile = effectiveProfile;
507
+ // Reuse the caller's identity when provided — computing it shells out to `git` several times,
508
+ // and run() has already done so for its log line. Recomputing here would double those git
509
+ // spawns (and, on Windows, the console-window flashes) on every `rech open`.
510
+ const identity = precomputedIdentity ?? await getClientIdentity();
511
+ const effectiveProfile = resolveEffectiveProfile(profileDirectory);
512
+ if (effectiveProfile) identity.profile = effectiveProfile;
434
513
  const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
435
514
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
436
515
  method: "POST",
@@ -468,16 +547,16 @@ async function callServe(
468
547
 
469
548
  async function run(url: string, args: string[]) {
470
549
  const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
471
- const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
550
+ const effectiveProfile = resolveEffectiveProfile(profileDirectory);
472
551
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
473
552
  const identity = await getClientIdentity();
474
553
  const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
475
554
  console.error(
476
- `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
555
+ `[rech] connecting to ${host}:${port} (identity: ${identity.label}${profileSuffix})`,
477
556
  );
478
557
 
479
558
  const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
480
- const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
559
+ const { status, stdout, stderr, files, existingSession } = await callServe(url, args, undefined, identity);
481
560
 
482
561
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
483
562
  if (existingSession && isOpenWithUrl) {
@@ -590,6 +669,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
590
669
  const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
591
670
  stdout: "inherit",
592
671
  stderr: "inherit",
672
+ windowsHide: true, // no console-window flash for the bunx/pm2 child on Windows
593
673
  ...(env ? { env: { ...process.env, ...env } } : {}),
594
674
  });
595
675
  await proc.exited;
@@ -599,7 +679,7 @@ async function runPm(args: string[], env?: Record<string, string>): Promise<numb
599
679
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
600
680
  // Both render the process name verbatim, so callers can substring-match it.
601
681
  async function pmList(): Promise<string> {
602
- const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
682
+ const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
603
683
  return await new Response(proc.stdout).text();
604
684
  }
605
685
 
@@ -915,7 +995,7 @@ async function provisionExtensionToken(opts: {
915
995
  if (!opts.headed) args.push("--headless=new");
916
996
  if (process.platform === "linux") args.push("--no-sandbox");
917
997
  args.push("about:blank");
918
- const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
998
+ const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
919
999
  let cdp: CDPClient | null = null;
920
1000
  try {
921
1001
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
@@ -1130,13 +1210,34 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1130
1210
  );
1131
1211
  if (opts.profile !== undefined) {
1132
1212
  const num = parseInt(opts.profile);
1133
- if (!isNaN(num) && String(num) === opts.profile.trim()) return available[num - 1] ?? null;
1213
+ if (!isNaN(num) && String(num) === opts.profile.trim()) {
1214
+ // A bare integer is a 1-based MENU INDEX, NOT the Chrome directory literally named
1215
+ // "Profile <N>". The two collide in the user's head: `--profile 1` selects the first
1216
+ // *listed* profile (usually Default), not the dir "Profile 1". When such a dir exists
1217
+ // and differs from the indexed pick, warn so the mismatch is caught; echo the resolved
1218
+ // selection either way. Email is the unambiguous selector — steer toward it.
1219
+ const sel = available[num - 1] ?? null;
1220
+ const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
1221
+ if (dirNamed && dirNamed[0] !== sel?.[0]) {
1222
+ const hint = dirNamed[1].user_name || `"Profile ${num}"`;
1223
+ console.error(
1224
+ ` [warn] --profile ${num} = menu index ${num} → ` +
1225
+ `${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
1226
+ `NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
1227
+ `For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
1228
+ );
1229
+ }
1230
+ if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
1231
+ return sel;
1232
+ }
1134
1233
  const needle = opts.profile.toLowerCase();
1135
- return available.find(([dir, info]) =>
1234
+ const match = available.find(([dir, info]) =>
1136
1235
  dir.toLowerCase() === needle
1137
1236
  || (info.name ?? "").toLowerCase() === needle
1138
1237
  || (info.user_name ?? "").toLowerCase().includes(needle)
1139
1238
  ) ?? null;
1239
+ if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
1240
+ return match;
1140
1241
  }
1141
1242
  if (available.length === 1) {
1142
1243
  console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
@@ -1346,14 +1447,17 @@ async function status(): Promise<void> {
1346
1447
  const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
1347
1448
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
1348
1449
  // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
1349
- const bind = ping
1450
+ const pingBody = ping
1350
1451
  ? await fetch(`${protocol}://${host}:${port}/ping`, {
1351
1452
  headers: { Authorization: `Bearer ${parsed.key}` },
1352
1453
  signal: AbortSignal.timeout(2000),
1353
- }).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
1354
- : undefined;
1454
+ }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
1455
+ : null;
1456
+ const bind = pingBody?.bind;
1355
1457
  const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
1356
1458
  console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
1459
+ if (pingBody?.degraded)
1460
+ console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
1357
1461
  const pmOut = await pmList();
1358
1462
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
1359
1463
  console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
@@ -1380,7 +1484,12 @@ function printHelp(): void {
1380
1484
  Usage:
1381
1485
  rech setup [--profile <num|email>] [--token <tok>]
1382
1486
  First-time setup: daemon + Chrome extension + config
1383
- --profile selects the Chrome profile non-interactively
1487
+ --profile selects the Chrome profile non-interactively.
1488
+ A bare number is the 1-based MENU INDEX (position in the
1489
+ listed order), NOT the Chrome directory "Profile N". For
1490
+ scripts prefer the email (e.g. --profile you@gmail.com) —
1491
+ it is the only unambiguous selector; an exact directory
1492
+ name ("Profile 1") also matches.
1384
1493
  --token (or RECH_TOKEN) supplies the auth token for
1385
1494
  non-TTY/agent runs, skipping the interactive paste
1386
1495
  rech provision-profile <name> --experimental [--headed]
@@ -1396,10 +1505,16 @@ Usage:
1396
1505
  rech serve Start the serve server manually (foreground)
1397
1506
  rech profiles List Chrome profiles
1398
1507
  rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
1508
+ rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
1509
+ fragile single-shot flow (OAuth/login) never shares tabs
1510
+ with the worktree's default session
1399
1511
 
1400
1512
  Environment:
1401
1513
  ${ENV_KEY} Server URL set by \`rech setup\`
1402
1514
  RECH_TOKEN Auth token for \`rech setup\` (same as --token)
1515
+ RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
1516
+ client reuses is keyed on the worktree root path; \`branch\` restores the
1517
+ old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
1403
1518
 
1404
1519
  Examples:
1405
1520
  rech setup
@@ -1468,6 +1583,15 @@ if (import.meta.main) {
1468
1583
  printHelp();
1469
1584
  process.exit(1);
1470
1585
  }
1586
+ // --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
1587
+ // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
1588
+ // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
1589
+ // OAuth drive can't leak an orphaned browser context.
1590
+ const isolateIdx = args.findIndex((a) => a === "--isolate" || a === "--isolated");
1591
+ if (isolateIdx !== -1) {
1592
+ args.splice(isolateIdx, 1);
1593
+ args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
1594
+ }
1471
1595
  await run(url, args);
1472
1596
  envWatcher?.close();
1473
1597
  }