rechrome 1.33.0 → 1.34.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 (3) hide show
  1. package/package.json +1 -1
  2. package/rechrome.js +128 -37
  3. package/rechrome.ts +128 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -382,18 +382,27 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
382
382
  const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
383
383
  const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
384
384
 
385
- /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
386
- async function runTailscale(args: string[]): Promise<string | null> {
385
+ /** Run the Tailscale CLI read-only: its output, or why it gave none (missing, failed, slow). */
386
+ async function runTailscaleDetailed(args: string[]): Promise<{ output: string } | { error: string; missing?: true }> {
387
387
  const binary = tailscaleBinary();
388
- if (!binary) return null;
388
+ if (!binary) return { error: "not installed", missing: true };
389
389
  try {
390
- const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
391
- const timeout = setTimeout(() => proc.kill(), 2000);
390
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "pipe" });
391
+ let timedOut = false;
392
+ const timeout = setTimeout(() => { timedOut = true; proc.kill(); }, 2000);
392
393
  try {
393
- const output = await new Response(proc.stdout).text();
394
- return await proc.exited === 0 ? output : null;
394
+ const [output, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
395
+ if (await proc.exited === 0) return { output };
396
+ const reason = timedOut ? "no answer within 2s" : (stderr.trim() || output.trim()).split("\n")[0] || `exit ${proc.exitCode}`;
397
+ return { error: `\`tailscale ${args.join(" ")}\`: ${reason}` };
395
398
  } finally { clearTimeout(timeout); }
396
- } catch { return null; }
399
+ } catch (error) { return { error: String(error) }; }
400
+ }
401
+
402
+ /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
403
+ async function runTailscale(args: string[]): Promise<string | null> {
404
+ const result = await runTailscaleDetailed(args);
405
+ return "output" in result ? result.output : null;
397
406
  }
398
407
 
399
408
  export async function detectListenChoices(): Promise<ListenChoice[]> {
@@ -514,32 +523,71 @@ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: str
514
523
  }
515
524
  }
516
525
 
526
+ type HealthListener = Pick<Listener, "name" | "port" | "prefix" | "profiles">;
527
+
528
+ /**
529
+ * Read-only Tailscale Serve health for the listeners: `absent` (no Tailscale here), `unresponsive`
530
+ * (installed, but its CLI doesn't answer — e.g. not running, or the Mac app's GUI binary), or
531
+ * `ok` with each shared listener's route plus the routes to re-point or remove.
532
+ */
533
+ export type TailscaleHealth =
534
+ | { state: "absent" }
535
+ | { state: "unresponsive"; error: string }
536
+ | { state: "ok"; routes: { listener: string; port: number; url: string | null }[]; repair: ServeRepair[]; stale: ServeStale[] };
537
+
538
+ export async function inspectTailscaleServe(listeners: HealthListener[]): Promise<TailscaleHealth> {
539
+ const [serve, status] = await Promise.all([runTailscaleDetailed(["serve", "status", "--json"]), runTailscale(["status", "--json"])]);
540
+ if (!("output" in serve)) return serve.missing ? { state: "absent" } : { state: "unresponsive", error: serve.error };
541
+ let serveStatus: unknown, dnsName: string | null = null;
542
+ try { serveStatus = JSON.parse(serve.output || "null"); } catch { return { state: "unresponsive", error: "`tailscale serve status --json` printed no JSON" }; }
543
+ try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
544
+ const routes = listeners.filter(l => l.profiles !== "*")
545
+ .map(l => ({ listener: l.name, port: l.port, url: findTailscaleServeRoute(dnsName, serveStatus, l.port, normalizePrefix(l.prefix)) }));
546
+ return { state: "ok", routes, ...planTailscaleServeRepairs(serveStatus, listeners) };
547
+ }
548
+
549
+ const httpsPortOf = (hostPort: string) => hostPort.split(/:(?=\d+$)/)[1] ?? "443";
550
+
551
+ /** Problems in a health report, one line each with the command that fixes it; empty when healthy. */
552
+ export function tailscaleHealthProblems(health: TailscaleHealth): string[] {
553
+ if (health.state === "absent") return [];
554
+ if (health.state === "unresponsive") return [`Tailscale is installed but its CLI doesn't answer, so Serve routes go unchecked: ${health.error}`];
555
+ return [
556
+ ...health.repair.map(r => `${r.hostPort}${r.mount} proxies to 127.0.0.1:${r.from}, but the listener is on ${r.to} (clients get HTTP 502). Fix: tailscale serve --bg --https=${httpsPortOf(r.hostPort)} --set-path=${r.mount} ${r.proxy}`),
557
+ ...health.stale.map(st => `${st.hostPort}${st.mount} proxies to 127.0.0.1:${st.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${httpsPortOf(st.hostPort)} --set-path=${st.mount} off`),
558
+ ];
559
+ }
560
+
561
+ /** The `tailscale:` block of `rech status` / `rech share ls`: each shared listener's route, then problems. */
562
+ export function formatTailscaleHealth(health: TailscaleHealth): string[] {
563
+ if (health.state === "absent") return [];
564
+ const lines = health.state === "ok"
565
+ ? health.routes.map(r => r.url ? `✓ ${r.listener} → ${r.url} (127.0.0.1:${r.port})` : `- ${r.listener}: no Tailscale Serve route (fine if another proxy exposes it)`)
566
+ : [];
567
+ lines.push(...tailscaleHealthProblems(health).map(p => `⚠ ${p}`));
568
+ return lines.map((line, i) => `${i ? " ".repeat(11) : "tailscale: "}${line}`);
569
+ }
570
+
517
571
  /**
518
572
  * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
519
573
  * Re-points a moved listener's route on this node; only reports stale routes (removing a
520
- * route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
574
+ * route is the user's call) and a Tailscale CLI that doesn't answer. Returns log lines, empty
575
+ * when healthy or Tailscale is absent.
521
576
  */
522
- export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
577
+ export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
578
+ const health = await inspectTailscaleServe(listeners);
523
579
  const binary = tailscaleBinary();
524
- if (!binary) return [];
525
- const raw = await runTailscale(["serve", "status", "--json"]);
526
- if (!raw) return [];
527
- let serveStatus: unknown;
528
- try { serveStatus = JSON.parse(raw); } catch { return []; }
529
- const { repair, stale } = planTailscaleServeRepairs(serveStatus, listeners);
580
+ if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
530
581
  const lines: string[] = [];
531
- for (const r of repair) {
532
- const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
533
- const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
582
+ for (const r of health.repair) {
583
+ const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
534
584
  const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
535
585
  const ok = await proc.exited === 0;
536
586
  lines.push(ok
537
587
  ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
538
588
  : `tailscale serve: ${r.hostPort}${r.mount} points at 127.0.0.1:${r.from}, listener is on ${r.to}; re-pointing failed (${(await new Response(proc.stderr).text()).trim()}). Run: tailscale ${args.join(" ")}`);
539
589
  }
540
- for (const s of stale) {
541
- lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
542
- }
590
+ lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
543
591
  return lines;
544
592
  }
545
593
 
@@ -2718,6 +2766,9 @@ async function provisionExtensionToken(opts: {
2718
2766
  `--profile-directory=${profileDir}`,
2719
2767
  `--load-extension=${dist}`,
2720
2768
  `--disable-extensions-except=${dist}`,
2769
+ // Chromium 137+ ignores --load-extension unless this feature is off (Chrome for Testing
2770
+ // too): the extension silently never loads and status.html is an error page.
2771
+ "--disable-features=DisableLoadExtensionCommandLineSwitch",
2721
2772
  "--remote-debugging-port=0",
2722
2773
  "--no-first-run",
2723
2774
  "--no-default-browser-check",
@@ -2725,40 +2776,70 @@ async function provisionExtensionToken(opts: {
2725
2776
  ];
2726
2777
  if (!opts.headed) args.push("--headless=new");
2727
2778
  if (process.platform === "linux") args.push("--no-sandbox");
2779
+ // Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
2780
+ if (process.platform === "darwin") args.push("--use-mock-keychain");
2728
2781
  args.push("about:blank");
2729
2782
  const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
2730
2783
  let cdp: CDPClient | null = null;
2784
+ // Every step is bounded: a stuck Chrome must end in an error, never a silent hang.
2785
+ const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
2786
+ Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
2731
2787
  try {
2732
2788
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
2733
2789
  let port: number | null = null;
2734
- for (let i = 0; i < 100; i++) {
2790
+ for (let i = 0; i < 300; i++) {
2735
2791
  await Bun.sleep(100);
2736
2792
  const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
2737
2793
  if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
2738
2794
  if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
2739
2795
  }
2740
- if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
2741
- const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
2796
+ if (!port) throw new Error("Chrome didn't open its DevTools port within 30s");
2797
+ const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
2742
2798
  cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
2743
- await cdp.open();
2744
- const { targetId } = await cdp.send("Target.createTarget", { url: `chrome-extension://${EXTENSION_ID}/status.html` });
2799
+ await within(5000, "Connecting to Chrome DevTools", cdp.open());
2800
+ // Wait for the extension's service worker: if it never shows, the extension didn't load.
2801
+ const extensionUrl = `chrome-extension://${EXTENSION_ID}/`;
2802
+ let loaded = false;
2803
+ for (let i = 0; i < 50 && !loaded; i++) {
2804
+ const { targetInfos } = await cdp.send("Target.getTargets");
2805
+ loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
2806
+ if (!loaded) await Bun.sleep(200);
2807
+ }
2808
+ if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
2809
+ const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
2745
2810
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
2746
2811
  // The extension page may still be loading; retry the write until localStorage reflects it.
2747
- let ok = false;
2812
+ let ok = false, last = "";
2748
2813
  const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
2749
- for (let i = 0; i < 50; i++) {
2750
- const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId).catch(() => null);
2751
- if (r?.result?.value === token) { ok = true; break; }
2752
- await Bun.sleep(100);
2814
+ const deadline = Date.now() + 10_000;
2815
+ while (Date.now() < deadline) {
2816
+ const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
2817
+ const value = (r as any)?.result?.value;
2818
+ if (value === token) { ok = true; break; }
2819
+ last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
2820
+ await Bun.sleep(200);
2753
2821
  }
2754
- if (!ok) throw new Error(`Could not seed auth token into chrome-extension://${EXTENSION_ID}/ (is the extension loading?)`);
2822
+ if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
2755
2823
  // Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
2756
- await cdp.send("Browser.close").catch(() => {});
2824
+ await within(5000, "Browser.close", cdp.send("Browser.close")).catch(() => {});
2825
+ await within(5000, "Chrome exit", proc.exited).catch(() => {});
2757
2826
  } finally {
2758
2827
  cdp?.close();
2828
+ await stopThrowawayChrome(proc, userDataDir);
2829
+ }
2830
+ }
2831
+
2832
+ /**
2833
+ * Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
2834
+ * still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
2835
+ */
2836
+ async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
2837
+ if (proc.exitCode === null) {
2759
2838
  try { proc.kill(); } catch {}
2760
- await proc.exited.catch(() => {});
2839
+ const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
2840
+ if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
2761
2841
  }
2842
+ if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
2762
2843
  }
2763
2844
 
2764
2845
  async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
@@ -2952,6 +3033,8 @@ async function urlList(): Promise<void> {
2952
3033
  printTable(rows);
2953
3034
  const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2954
3035
  if (wide.length) console.log(`\nOne key covers several profiles on: ${wide.map(l => `${l.name} (${(l.profiles as string[]).length})`).join(", ")}. Anyone with such a link can use each of them.`);
3036
+ const tailscale = formatTailscaleHealth(await inspectTailscaleServe(config.listeners));
3037
+ if (tailscale.length) console.log(`\n${tailscale.join("\n")}`);
2955
3038
  console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2956
3039
  }
2957
3040
 
@@ -3403,7 +3486,11 @@ async function status(): Promise<void> {
3403
3486
  : null;
3404
3487
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
3405
3488
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
3406
- console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
3489
+ // Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
3490
+ const answered = pingResponse?.ok || pingResponse?.status === 401;
3491
+ console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
3492
+ : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
3493
+ : `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
3407
3494
  if (pingResponse?.status === 401)
3408
3495
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3409
3496
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3426,7 +3513,11 @@ async function status(): Promise<void> {
3426
3513
  const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
3427
3514
  const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
3428
3515
  console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
3429
- if (isHost) console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
3516
+ if (isHost) {
3517
+ const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
3518
+ for (const line of formatTailscaleHealth(await inspectTailscaleServe(listeners))) console.log(line);
3519
+ console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
3520
+ }
3430
3521
  }
3431
3522
 
3432
3523
 
package/rechrome.ts CHANGED
@@ -382,18 +382,27 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
382
382
  const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
383
383
  const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
384
384
 
385
- /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
386
- async function runTailscale(args: string[]): Promise<string | null> {
385
+ /** Run the Tailscale CLI read-only: its output, or why it gave none (missing, failed, slow). */
386
+ async function runTailscaleDetailed(args: string[]): Promise<{ output: string } | { error: string; missing?: true }> {
387
387
  const binary = tailscaleBinary();
388
- if (!binary) return null;
388
+ if (!binary) return { error: "not installed", missing: true };
389
389
  try {
390
- const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
391
- const timeout = setTimeout(() => proc.kill(), 2000);
390
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "pipe" });
391
+ let timedOut = false;
392
+ const timeout = setTimeout(() => { timedOut = true; proc.kill(); }, 2000);
392
393
  try {
393
- const output = await new Response(proc.stdout).text();
394
- return await proc.exited === 0 ? output : null;
394
+ const [output, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
395
+ if (await proc.exited === 0) return { output };
396
+ const reason = timedOut ? "no answer within 2s" : (stderr.trim() || output.trim()).split("\n")[0] || `exit ${proc.exitCode}`;
397
+ return { error: `\`tailscale ${args.join(" ")}\`: ${reason}` };
395
398
  } finally { clearTimeout(timeout); }
396
- } catch { return null; }
399
+ } catch (error) { return { error: String(error) }; }
400
+ }
401
+
402
+ /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
403
+ async function runTailscale(args: string[]): Promise<string | null> {
404
+ const result = await runTailscaleDetailed(args);
405
+ return "output" in result ? result.output : null;
397
406
  }
398
407
 
399
408
  export async function detectListenChoices(): Promise<ListenChoice[]> {
@@ -514,32 +523,71 @@ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: str
514
523
  }
515
524
  }
516
525
 
526
+ type HealthListener = Pick<Listener, "name" | "port" | "prefix" | "profiles">;
527
+
528
+ /**
529
+ * Read-only Tailscale Serve health for the listeners: `absent` (no Tailscale here), `unresponsive`
530
+ * (installed, but its CLI doesn't answer — e.g. not running, or the Mac app's GUI binary), or
531
+ * `ok` with each shared listener's route plus the routes to re-point or remove.
532
+ */
533
+ export type TailscaleHealth =
534
+ | { state: "absent" }
535
+ | { state: "unresponsive"; error: string }
536
+ | { state: "ok"; routes: { listener: string; port: number; url: string | null }[]; repair: ServeRepair[]; stale: ServeStale[] };
537
+
538
+ export async function inspectTailscaleServe(listeners: HealthListener[]): Promise<TailscaleHealth> {
539
+ const [serve, status] = await Promise.all([runTailscaleDetailed(["serve", "status", "--json"]), runTailscale(["status", "--json"])]);
540
+ if (!("output" in serve)) return serve.missing ? { state: "absent" } : { state: "unresponsive", error: serve.error };
541
+ let serveStatus: unknown, dnsName: string | null = null;
542
+ try { serveStatus = JSON.parse(serve.output || "null"); } catch { return { state: "unresponsive", error: "`tailscale serve status --json` printed no JSON" }; }
543
+ try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
544
+ const routes = listeners.filter(l => l.profiles !== "*")
545
+ .map(l => ({ listener: l.name, port: l.port, url: findTailscaleServeRoute(dnsName, serveStatus, l.port, normalizePrefix(l.prefix)) }));
546
+ return { state: "ok", routes, ...planTailscaleServeRepairs(serveStatus, listeners) };
547
+ }
548
+
549
+ const httpsPortOf = (hostPort: string) => hostPort.split(/:(?=\d+$)/)[1] ?? "443";
550
+
551
+ /** Problems in a health report, one line each with the command that fixes it; empty when healthy. */
552
+ export function tailscaleHealthProblems(health: TailscaleHealth): string[] {
553
+ if (health.state === "absent") return [];
554
+ if (health.state === "unresponsive") return [`Tailscale is installed but its CLI doesn't answer, so Serve routes go unchecked: ${health.error}`];
555
+ return [
556
+ ...health.repair.map(r => `${r.hostPort}${r.mount} proxies to 127.0.0.1:${r.from}, but the listener is on ${r.to} (clients get HTTP 502). Fix: tailscale serve --bg --https=${httpsPortOf(r.hostPort)} --set-path=${r.mount} ${r.proxy}`),
557
+ ...health.stale.map(st => `${st.hostPort}${st.mount} proxies to 127.0.0.1:${st.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${httpsPortOf(st.hostPort)} --set-path=${st.mount} off`),
558
+ ];
559
+ }
560
+
561
+ /** The `tailscale:` block of `rech status` / `rech share ls`: each shared listener's route, then problems. */
562
+ export function formatTailscaleHealth(health: TailscaleHealth): string[] {
563
+ if (health.state === "absent") return [];
564
+ const lines = health.state === "ok"
565
+ ? health.routes.map(r => r.url ? `✓ ${r.listener} → ${r.url} (127.0.0.1:${r.port})` : `- ${r.listener}: no Tailscale Serve route (fine if another proxy exposes it)`)
566
+ : [];
567
+ lines.push(...tailscaleHealthProblems(health).map(p => `⚠ ${p}`));
568
+ return lines.map((line, i) => `${i ? " ".repeat(11) : "tailscale: "}${line}`);
569
+ }
570
+
517
571
  /**
518
572
  * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
519
573
  * Re-points a moved listener's route on this node; only reports stale routes (removing a
520
- * route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
574
+ * route is the user's call) and a Tailscale CLI that doesn't answer. Returns log lines, empty
575
+ * when healthy or Tailscale is absent.
521
576
  */
522
- export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
577
+ export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
578
+ const health = await inspectTailscaleServe(listeners);
523
579
  const binary = tailscaleBinary();
524
- if (!binary) return [];
525
- const raw = await runTailscale(["serve", "status", "--json"]);
526
- if (!raw) return [];
527
- let serveStatus: unknown;
528
- try { serveStatus = JSON.parse(raw); } catch { return []; }
529
- const { repair, stale } = planTailscaleServeRepairs(serveStatus, listeners);
580
+ if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
530
581
  const lines: string[] = [];
531
- for (const r of repair) {
532
- const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
533
- const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
582
+ for (const r of health.repair) {
583
+ const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
534
584
  const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
535
585
  const ok = await proc.exited === 0;
536
586
  lines.push(ok
537
587
  ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
538
588
  : `tailscale serve: ${r.hostPort}${r.mount} points at 127.0.0.1:${r.from}, listener is on ${r.to}; re-pointing failed (${(await new Response(proc.stderr).text()).trim()}). Run: tailscale ${args.join(" ")}`);
539
589
  }
540
- for (const s of stale) {
541
- lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
542
- }
590
+ lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
543
591
  return lines;
544
592
  }
545
593
 
@@ -2718,6 +2766,9 @@ async function provisionExtensionToken(opts: {
2718
2766
  `--profile-directory=${profileDir}`,
2719
2767
  `--load-extension=${dist}`,
2720
2768
  `--disable-extensions-except=${dist}`,
2769
+ // Chromium 137+ ignores --load-extension unless this feature is off (Chrome for Testing
2770
+ // too): the extension silently never loads and status.html is an error page.
2771
+ "--disable-features=DisableLoadExtensionCommandLineSwitch",
2721
2772
  "--remote-debugging-port=0",
2722
2773
  "--no-first-run",
2723
2774
  "--no-default-browser-check",
@@ -2725,40 +2776,70 @@ async function provisionExtensionToken(opts: {
2725
2776
  ];
2726
2777
  if (!opts.headed) args.push("--headless=new");
2727
2778
  if (process.platform === "linux") args.push("--no-sandbox");
2779
+ // Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
2780
+ if (process.platform === "darwin") args.push("--use-mock-keychain");
2728
2781
  args.push("about:blank");
2729
2782
  const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
2730
2783
  let cdp: CDPClient | null = null;
2784
+ // Every step is bounded: a stuck Chrome must end in an error, never a silent hang.
2785
+ const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
2786
+ Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
2731
2787
  try {
2732
2788
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
2733
2789
  let port: number | null = null;
2734
- for (let i = 0; i < 100; i++) {
2790
+ for (let i = 0; i < 300; i++) {
2735
2791
  await Bun.sleep(100);
2736
2792
  const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
2737
2793
  if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
2738
2794
  if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
2739
2795
  }
2740
- if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
2741
- const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
2796
+ if (!port) throw new Error("Chrome didn't open its DevTools port within 30s");
2797
+ const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
2742
2798
  cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
2743
- await cdp.open();
2744
- const { targetId } = await cdp.send("Target.createTarget", { url: `chrome-extension://${EXTENSION_ID}/status.html` });
2799
+ await within(5000, "Connecting to Chrome DevTools", cdp.open());
2800
+ // Wait for the extension's service worker: if it never shows, the extension didn't load.
2801
+ const extensionUrl = `chrome-extension://${EXTENSION_ID}/`;
2802
+ let loaded = false;
2803
+ for (let i = 0; i < 50 && !loaded; i++) {
2804
+ const { targetInfos } = await cdp.send("Target.getTargets");
2805
+ loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
2806
+ if (!loaded) await Bun.sleep(200);
2807
+ }
2808
+ if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
2809
+ const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
2745
2810
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
2746
2811
  // The extension page may still be loading; retry the write until localStorage reflects it.
2747
- let ok = false;
2812
+ let ok = false, last = "";
2748
2813
  const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
2749
- for (let i = 0; i < 50; i++) {
2750
- const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId).catch(() => null);
2751
- if (r?.result?.value === token) { ok = true; break; }
2752
- await Bun.sleep(100);
2814
+ const deadline = Date.now() + 10_000;
2815
+ while (Date.now() < deadline) {
2816
+ const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
2817
+ const value = (r as any)?.result?.value;
2818
+ if (value === token) { ok = true; break; }
2819
+ last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
2820
+ await Bun.sleep(200);
2753
2821
  }
2754
- if (!ok) throw new Error(`Could not seed auth token into chrome-extension://${EXTENSION_ID}/ (is the extension loading?)`);
2822
+ if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
2755
2823
  // Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
2756
- await cdp.send("Browser.close").catch(() => {});
2824
+ await within(5000, "Browser.close", cdp.send("Browser.close")).catch(() => {});
2825
+ await within(5000, "Chrome exit", proc.exited).catch(() => {});
2757
2826
  } finally {
2758
2827
  cdp?.close();
2828
+ await stopThrowawayChrome(proc, userDataDir);
2829
+ }
2830
+ }
2831
+
2832
+ /**
2833
+ * Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
2834
+ * still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
2835
+ */
2836
+ async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
2837
+ if (proc.exitCode === null) {
2759
2838
  try { proc.kill(); } catch {}
2760
- await proc.exited.catch(() => {});
2839
+ const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
2840
+ if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
2761
2841
  }
2842
+ if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
2762
2843
  }
2763
2844
 
2764
2845
  async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
@@ -2952,6 +3033,8 @@ async function urlList(): Promise<void> {
2952
3033
  printTable(rows);
2953
3034
  const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2954
3035
  if (wide.length) console.log(`\nOne key covers several profiles on: ${wide.map(l => `${l.name} (${(l.profiles as string[]).length})`).join(", ")}. Anyone with such a link can use each of them.`);
3036
+ const tailscale = formatTailscaleHealth(await inspectTailscaleServe(config.listeners));
3037
+ if (tailscale.length) console.log(`\n${tailscale.join("\n")}`);
2955
3038
  console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2956
3039
  }
2957
3040
 
@@ -3403,7 +3486,11 @@ async function status(): Promise<void> {
3403
3486
  : null;
3404
3487
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
3405
3488
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
3406
- console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
3489
+ // Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
3490
+ const answered = pingResponse?.ok || pingResponse?.status === 401;
3491
+ console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
3492
+ : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
3493
+ : `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
3407
3494
  if (pingResponse?.status === 401)
3408
3495
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3409
3496
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3426,7 +3513,11 @@ async function status(): Promise<void> {
3426
3513
  const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
3427
3514
  const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
3428
3515
  console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
3429
- if (isHost) console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
3516
+ if (isHost) {
3517
+ const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
3518
+ for (const line of formatTailscaleHealth(await inspectTailscaleServe(listeners))) console.log(line);
3519
+ console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
3520
+ }
3430
3521
  }
3431
3522
 
3432
3523