rechrome 1.32.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.32.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
@@ -377,20 +377,32 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
377
377
  return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
378
378
  }
379
379
 
380
+ // The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
381
+ // daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
382
+ const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
380
383
  const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
381
384
 
382
- /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
383
- 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 }> {
384
387
  const binary = tailscaleBinary();
385
- if (!binary) return null;
388
+ if (!binary) return { error: "not installed", missing: true };
386
389
  try {
387
- const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
388
- 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);
389
393
  try {
390
- const output = await new Response(proc.stdout).text();
391
- 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}` };
392
398
  } finally { clearTimeout(timeout); }
393
- } 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;
394
406
  }
395
407
 
396
408
  export async function detectListenChoices(): Promise<ListenChoice[]> {
@@ -445,6 +457,140 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
445
457
  return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
446
458
  }
447
459
 
460
+ type ServeHandler = { hostPort: string; mount: string; target: URL };
461
+
462
+ /** Every Serve route that proxies to a loopback HTTP port. */
463
+ function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
464
+ const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
465
+ const handlers: ServeHandler[] = [];
466
+ for (const [hostPort, config] of Object.entries(web)) {
467
+ for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
468
+ if (!handler.Proxy) continue;
469
+ let target: URL;
470
+ try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
471
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
472
+ handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
473
+ }
474
+ }
475
+ return handlers;
476
+ }
477
+
478
+ export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
479
+ export type ServeStale = { hostPort: string; mount: string; port: number };
480
+
481
+ /**
482
+ * Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
483
+ * mount that proxies to another port (the listener moved), re-pointed at the listener's port.
484
+ * `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
485
+ * /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
486
+ * Root ("/") mounts are never touched: they could be anything.
487
+ */
488
+ export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
489
+ const ports = new Set(listeners.map(l => l.port));
490
+ const mounts = new Map<string, number>();
491
+ for (const l of listeners) {
492
+ const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
493
+ if (mount) mounts.set(mount, l.port);
494
+ }
495
+ const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
496
+ const repair: ServeRepair[] = [], stale: ServeStale[] = [];
497
+ for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
498
+ const port = Number(target.port), want = mounts.get(mount);
499
+ if (want !== undefined) {
500
+ if (port !== want) {
501
+ const proxy = new URL(target); proxy.port = String(want);
502
+ repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
503
+ }
504
+ } else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
505
+ stale.push({ hostPort, mount, port });
506
+ }
507
+ }
508
+ return { repair, stale };
509
+ }
510
+
511
+ /** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
512
+ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
513
+ const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
514
+ if (!binary || !raw) return;
515
+ let serveStatus: unknown;
516
+ try { serveStatus = JSON.parse(raw); } catch { return; }
517
+ const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
518
+ for (const h of loopbackServeHandlers(serveStatus)) {
519
+ if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
520
+ const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
521
+ const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
522
+ console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
523
+ }
524
+ }
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
+
571
+ /**
572
+ * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
573
+ * Re-points a moved listener's route on this node; only reports stale routes (removing a
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.
576
+ */
577
+ export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
578
+ const health = await inspectTailscaleServe(listeners);
579
+ const binary = tailscaleBinary();
580
+ if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
581
+ const lines: string[] = [];
582
+ for (const r of health.repair) {
583
+ const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
584
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
585
+ const ok = await proc.exited === 0;
586
+ lines.push(ok
587
+ ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
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(" ")}`);
589
+ }
590
+ lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
591
+ return lines;
592
+ }
593
+
448
594
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
449
595
  export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
450
596
  const local = new URL(localUrl);
@@ -1408,7 +1554,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
1408
1554
  const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1409
1555
  const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1410
1556
  console.error(`Running: tailscale ${args.join(" ")}`);
1411
- const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1557
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
1412
1558
  if (await proc.exited !== 0) {
1413
1559
  console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1414
1560
  return null;
@@ -1669,7 +1815,9 @@ async function callServe(
1669
1815
  return JSON.parse(text);
1670
1816
  } catch {
1671
1817
  // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
1672
- const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1818
+ let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1819
+ if (res.status === 502 || res.status === 503 || res.status === 504)
1820
+ detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
1673
1821
  if (throwOnFailure) throw new Error(detail);
1674
1822
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1675
1823
  process.exit(1);
@@ -2618,6 +2766,9 @@ async function provisionExtensionToken(opts: {
2618
2766
  `--profile-directory=${profileDir}`,
2619
2767
  `--load-extension=${dist}`,
2620
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",
2621
2772
  "--remote-debugging-port=0",
2622
2773
  "--no-first-run",
2623
2774
  "--no-default-browser-check",
@@ -2625,40 +2776,70 @@ async function provisionExtensionToken(opts: {
2625
2776
  ];
2626
2777
  if (!opts.headed) args.push("--headless=new");
2627
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");
2628
2781
  args.push("about:blank");
2629
2782
  const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
2630
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`); })]);
2631
2787
  try {
2632
2788
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
2633
2789
  let port: number | null = null;
2634
- for (let i = 0; i < 100; i++) {
2790
+ for (let i = 0; i < 300; i++) {
2635
2791
  await Bun.sleep(100);
2636
2792
  const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
2637
2793
  if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
2638
2794
  if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
2639
2795
  }
2640
- if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
2641
- 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();
2642
2798
  cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
2643
- await cdp.open();
2644
- 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` });
2645
2810
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
2646
2811
  // The extension page may still be loading; retry the write until localStorage reflects it.
2647
- let ok = false;
2812
+ let ok = false, last = "";
2648
2813
  const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
2649
- for (let i = 0; i < 50; i++) {
2650
- const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId).catch(() => null);
2651
- if (r?.result?.value === token) { ok = true; break; }
2652
- 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);
2653
2821
  }
2654
- 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})` : ""}`);
2655
2823
  // Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
2656
- 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(() => {});
2657
2826
  } finally {
2658
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) {
2659
2838
  try { proc.kill(); } catch {}
2660
- 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)]); }
2661
2841
  }
2842
+ if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
2662
2843
  }
2663
2844
 
2664
2845
  async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
@@ -2759,6 +2940,7 @@ async function removeListener(name: string): Promise<void> {
2759
2940
  config.listeners = config.listeners.filter(l => l !== listener);
2760
2941
  await writeListeners(config);
2761
2942
  console.log("Listener removed from configuration; daemon reloads automatically.");
2943
+ await removeTailscaleServeRoutes(listener);
2762
2944
  }
2763
2945
 
2764
2946
  async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
@@ -2851,6 +3033,8 @@ async function urlList(): Promise<void> {
2851
3033
  printTable(rows);
2852
3034
  const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2853
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")}`);
2854
3038
  console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2855
3039
  }
2856
3040
 
@@ -3302,7 +3486,11 @@ async function status(): Promise<void> {
3302
3486
  : null;
3303
3487
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
3304
3488
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
3305
- 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>'\``}`);
3306
3494
  if (pingResponse?.status === 401)
3307
3495
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3308
3496
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3325,7 +3513,11 @@ async function status(): Promise<void> {
3325
3513
  const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
3326
3514
  const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
3327
3515
  console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
3328
- 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
+ }
3329
3521
  }
3330
3522
 
3331
3523
 
package/rechrome.ts CHANGED
@@ -377,20 +377,32 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
377
377
  return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
378
378
  }
379
379
 
380
+ // The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
381
+ // daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
382
+ const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
380
383
  const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
381
384
 
382
- /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
383
- 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 }> {
384
387
  const binary = tailscaleBinary();
385
- if (!binary) return null;
388
+ if (!binary) return { error: "not installed", missing: true };
386
389
  try {
387
- const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
388
- 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);
389
393
  try {
390
- const output = await new Response(proc.stdout).text();
391
- 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}` };
392
398
  } finally { clearTimeout(timeout); }
393
- } 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;
394
406
  }
395
407
 
396
408
  export async function detectListenChoices(): Promise<ListenChoice[]> {
@@ -445,6 +457,140 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
445
457
  return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
446
458
  }
447
459
 
460
+ type ServeHandler = { hostPort: string; mount: string; target: URL };
461
+
462
+ /** Every Serve route that proxies to a loopback HTTP port. */
463
+ function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
464
+ const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
465
+ const handlers: ServeHandler[] = [];
466
+ for (const [hostPort, config] of Object.entries(web)) {
467
+ for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
468
+ if (!handler.Proxy) continue;
469
+ let target: URL;
470
+ try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
471
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
472
+ handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
473
+ }
474
+ }
475
+ return handlers;
476
+ }
477
+
478
+ export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
479
+ export type ServeStale = { hostPort: string; mount: string; port: number };
480
+
481
+ /**
482
+ * Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
483
+ * mount that proxies to another port (the listener moved), re-pointed at the listener's port.
484
+ * `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
485
+ * /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
486
+ * Root ("/") mounts are never touched: they could be anything.
487
+ */
488
+ export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
489
+ const ports = new Set(listeners.map(l => l.port));
490
+ const mounts = new Map<string, number>();
491
+ for (const l of listeners) {
492
+ const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
493
+ if (mount) mounts.set(mount, l.port);
494
+ }
495
+ const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
496
+ const repair: ServeRepair[] = [], stale: ServeStale[] = [];
497
+ for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
498
+ const port = Number(target.port), want = mounts.get(mount);
499
+ if (want !== undefined) {
500
+ if (port !== want) {
501
+ const proxy = new URL(target); proxy.port = String(want);
502
+ repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
503
+ }
504
+ } else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
505
+ stale.push({ hostPort, mount, port });
506
+ }
507
+ }
508
+ return { repair, stale };
509
+ }
510
+
511
+ /** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
512
+ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
513
+ const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
514
+ if (!binary || !raw) return;
515
+ let serveStatus: unknown;
516
+ try { serveStatus = JSON.parse(raw); } catch { return; }
517
+ const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
518
+ for (const h of loopbackServeHandlers(serveStatus)) {
519
+ if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
520
+ const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
521
+ const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
522
+ console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
523
+ }
524
+ }
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
+
571
+ /**
572
+ * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
573
+ * Re-points a moved listener's route on this node; only reports stale routes (removing a
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.
576
+ */
577
+ export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
578
+ const health = await inspectTailscaleServe(listeners);
579
+ const binary = tailscaleBinary();
580
+ if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
581
+ const lines: string[] = [];
582
+ for (const r of health.repair) {
583
+ const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
584
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
585
+ const ok = await proc.exited === 0;
586
+ lines.push(ok
587
+ ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
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(" ")}`);
589
+ }
590
+ lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
591
+ return lines;
592
+ }
593
+
448
594
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
449
595
  export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
450
596
  const local = new URL(localUrl);
@@ -1408,7 +1554,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
1408
1554
  const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1409
1555
  const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1410
1556
  console.error(`Running: tailscale ${args.join(" ")}`);
1411
- const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1557
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
1412
1558
  if (await proc.exited !== 0) {
1413
1559
  console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1414
1560
  return null;
@@ -1669,7 +1815,9 @@ async function callServe(
1669
1815
  return JSON.parse(text);
1670
1816
  } catch {
1671
1817
  // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
1672
- const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1818
+ let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1819
+ if (res.status === 502 || res.status === 503 || res.status === 504)
1820
+ detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
1673
1821
  if (throwOnFailure) throw new Error(detail);
1674
1822
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1675
1823
  process.exit(1);
@@ -2618,6 +2766,9 @@ async function provisionExtensionToken(opts: {
2618
2766
  `--profile-directory=${profileDir}`,
2619
2767
  `--load-extension=${dist}`,
2620
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",
2621
2772
  "--remote-debugging-port=0",
2622
2773
  "--no-first-run",
2623
2774
  "--no-default-browser-check",
@@ -2625,40 +2776,70 @@ async function provisionExtensionToken(opts: {
2625
2776
  ];
2626
2777
  if (!opts.headed) args.push("--headless=new");
2627
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");
2628
2781
  args.push("about:blank");
2629
2782
  const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
2630
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`); })]);
2631
2787
  try {
2632
2788
  // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
2633
2789
  let port: number | null = null;
2634
- for (let i = 0; i < 100; i++) {
2790
+ for (let i = 0; i < 300; i++) {
2635
2791
  await Bun.sleep(100);
2636
2792
  const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
2637
2793
  if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
2638
2794
  if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
2639
2795
  }
2640
- if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
2641
- 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();
2642
2798
  cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
2643
- await cdp.open();
2644
- 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` });
2645
2810
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
2646
2811
  // The extension page may still be loading; retry the write until localStorage reflects it.
2647
- let ok = false;
2812
+ let ok = false, last = "";
2648
2813
  const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
2649
- for (let i = 0; i < 50; i++) {
2650
- const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId).catch(() => null);
2651
- if (r?.result?.value === token) { ok = true; break; }
2652
- 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);
2653
2821
  }
2654
- 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})` : ""}`);
2655
2823
  // Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
2656
- 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(() => {});
2657
2826
  } finally {
2658
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) {
2659
2838
  try { proc.kill(); } catch {}
2660
- 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)]); }
2661
2841
  }
2842
+ if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
2662
2843
  }
2663
2844
 
2664
2845
  async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
@@ -2759,6 +2940,7 @@ async function removeListener(name: string): Promise<void> {
2759
2940
  config.listeners = config.listeners.filter(l => l !== listener);
2760
2941
  await writeListeners(config);
2761
2942
  console.log("Listener removed from configuration; daemon reloads automatically.");
2943
+ await removeTailscaleServeRoutes(listener);
2762
2944
  }
2763
2945
 
2764
2946
  async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
@@ -2851,6 +3033,8 @@ async function urlList(): Promise<void> {
2851
3033
  printTable(rows);
2852
3034
  const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2853
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")}`);
2854
3038
  console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2855
3039
  }
2856
3040
 
@@ -3302,7 +3486,11 @@ async function status(): Promise<void> {
3302
3486
  : null;
3303
3487
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
3304
3488
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
3305
- 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>'\``}`);
3306
3494
  if (pingResponse?.status === 401)
3307
3495
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3308
3496
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3325,7 +3513,11 @@ async function status(): Promise<void> {
3325
3513
  const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
3326
3514
  const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
3327
3515
  console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
3328
- 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
+ }
3329
3521
  }
3330
3522
 
3331
3523
 
package/serve.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  resolvePlaywrightCli,
18
18
  readTokenRegistry,
19
19
  readChromeProfileCache,
20
+ checkTailscaleServe,
20
21
  } from "./rechrome.js";
21
22
 
22
23
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
@@ -1041,4 +1042,19 @@ export async function serve() {
1041
1042
  } finally { reloading = false; }
1042
1043
  }, 1000);
1043
1044
  log("Connection credentials remain in local configuration; listener changes reload automatically");
1045
+ // Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
1046
+ // change, and every few minutes (routes can be edited by hand). Each finding is logged once.
1047
+ let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
1048
+ setInterval(async () => {
1049
+ if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
1050
+ checking = true;
1051
+ try {
1052
+ const listeners = JSON.parse(applied || "[]") as Listener[];
1053
+ const lines = await checkTailscaleServe(listeners);
1054
+ const report = lines.join("\n");
1055
+ if (report && report !== lastReport) for (const line of lines) log(line);
1056
+ lastReport = report; checkedFor = applied; lastCheck = Date.now();
1057
+ } catch (error) { log(`tailscale serve health check failed: ${error}`); }
1058
+ finally { checking = false; }
1059
+ }, 2000);
1044
1060
  }
package/serve.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  resolvePlaywrightCli,
18
18
  readTokenRegistry,
19
19
  readChromeProfileCache,
20
+ checkTailscaleServe,
20
21
  } from "./rechrome.ts";
21
22
 
22
23
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
@@ -1041,4 +1042,19 @@ export async function serve() {
1041
1042
  } finally { reloading = false; }
1042
1043
  }, 1000);
1043
1044
  log("Connection credentials remain in local configuration; listener changes reload automatically");
1045
+ // Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
1046
+ // change, and every few minutes (routes can be edited by hand). Each finding is logged once.
1047
+ let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
1048
+ setInterval(async () => {
1049
+ if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
1050
+ checking = true;
1051
+ try {
1052
+ const listeners = JSON.parse(applied || "[]") as Listener[];
1053
+ const lines = await checkTailscaleServe(listeners);
1054
+ const report = lines.join("\n");
1055
+ if (report && report !== lastReport) for (const line of lines) log(line);
1056
+ lastReport = report; checkedFor = applied; lastCheck = Date.now();
1057
+ } catch (error) { log(`tailscale serve health check failed: ${error}`); }
1058
+ finally { checking = false; }
1059
+ }, 2000);
1044
1060
  }