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.
- package/package.json +1 -1
- package/rechrome.js +128 -37
- package/rechrome.ts +128 -37
package/package.json
CHANGED
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
|
|
386
|
-
async function
|
|
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
|
|
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: "
|
|
391
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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
|
|
2741
|
-
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version
|
|
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
|
-
|
|
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
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
386
|
-
async function
|
|
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
|
|
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: "
|
|
391
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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
|
|
2741
|
-
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version
|
|
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
|
-
|
|
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
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|