rechrome 1.33.0 → 1.35.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 +194 -46
- package/rechrome.ts +194 -46
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -84,6 +84,8 @@ async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const envFile = join(import.meta.dir, ".env.local");
|
|
87
|
+
/** Machine-wide RECHROME_URL from `rech connect --global`: read from every folder, after the project's own files. */
|
|
88
|
+
export const GLOBAL_ENV_FILE = join(RECH_DIR, ".env.local");
|
|
87
89
|
const globalEnvFile = join(HOME || "~", ".env.local");
|
|
88
90
|
|
|
89
91
|
// Capture inherited values once so explicit environment overrides survive reloads,
|
|
@@ -127,10 +129,15 @@ const bunLoaded = bunAutoloadedKeys(process.env,
|
|
|
127
129
|
.map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
|
|
128
130
|
const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
|
|
129
131
|
|
|
132
|
+
/** Where RECHROME_URL came from: "environment", an env file's path, or undefined when unset. */
|
|
133
|
+
export let rechromeUrlSource: string | undefined;
|
|
134
|
+
|
|
130
135
|
// Walk CWD→root loading env files nearest-first; inherited environment wins over files.
|
|
131
136
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
132
|
-
|
|
137
|
+
// The machine-wide file comes last, so it also applies outside $HOME (e.g. an agent's /tmp scratch dir).
|
|
138
|
+
export async function loadNearestEnv(extraFallbacks: string[] = [GLOBAL_ENV_FILE]) {
|
|
133
139
|
const seen = new Set<string>(inheritedEnvKeys);
|
|
140
|
+
rechromeUrlSource = seen.has(ENV_KEY) ? "environment" : undefined;
|
|
134
141
|
const applyFile = async (path: string) => {
|
|
135
142
|
const raw = await file(path).text().catch(() => "");
|
|
136
143
|
for (const line of raw.split("\n")) {
|
|
@@ -138,6 +145,7 @@ export async function loadNearestEnv(extraFallbacks: string[] = []) {
|
|
|
138
145
|
if (!m || m[1].startsWith("#")) continue;
|
|
139
146
|
if (seen.has(m[1])) continue;
|
|
140
147
|
seen.add(m[1]);
|
|
148
|
+
if (m[1] === ENV_KEY) rechromeUrlSource = path;
|
|
141
149
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
142
150
|
}
|
|
143
151
|
};
|
|
@@ -382,18 +390,27 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
|
|
|
382
390
|
const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
|
|
383
391
|
const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
|
|
384
392
|
|
|
385
|
-
/** Run the Tailscale CLI read-only
|
|
386
|
-
async function
|
|
393
|
+
/** Run the Tailscale CLI read-only: its output, or why it gave none (missing, failed, slow). */
|
|
394
|
+
async function runTailscaleDetailed(args: string[]): Promise<{ output: string } | { error: string; missing?: true }> {
|
|
387
395
|
const binary = tailscaleBinary();
|
|
388
|
-
if (!binary) return
|
|
396
|
+
if (!binary) return { error: "not installed", missing: true };
|
|
389
397
|
try {
|
|
390
|
-
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "
|
|
391
|
-
|
|
398
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "pipe" });
|
|
399
|
+
let timedOut = false;
|
|
400
|
+
const timeout = setTimeout(() => { timedOut = true; proc.kill(); }, 2000);
|
|
392
401
|
try {
|
|
393
|
-
const output = await new Response(proc.stdout).text();
|
|
394
|
-
|
|
402
|
+
const [output, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
403
|
+
if (await proc.exited === 0) return { output };
|
|
404
|
+
const reason = timedOut ? "no answer within 2s" : (stderr.trim() || output.trim()).split("\n")[0] || `exit ${proc.exitCode}`;
|
|
405
|
+
return { error: `\`tailscale ${args.join(" ")}\`: ${reason}` };
|
|
395
406
|
} finally { clearTimeout(timeout); }
|
|
396
|
-
} catch { return
|
|
407
|
+
} catch (error) { return { error: String(error) }; }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
|
|
411
|
+
async function runTailscale(args: string[]): Promise<string | null> {
|
|
412
|
+
const result = await runTailscaleDetailed(args);
|
|
413
|
+
return "output" in result ? result.output : null;
|
|
397
414
|
}
|
|
398
415
|
|
|
399
416
|
export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
@@ -514,32 +531,71 @@ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: str
|
|
|
514
531
|
}
|
|
515
532
|
}
|
|
516
533
|
|
|
534
|
+
type HealthListener = Pick<Listener, "name" | "port" | "prefix" | "profiles">;
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Read-only Tailscale Serve health for the listeners: `absent` (no Tailscale here), `unresponsive`
|
|
538
|
+
* (installed, but its CLI doesn't answer — e.g. not running, or the Mac app's GUI binary), or
|
|
539
|
+
* `ok` with each shared listener's route plus the routes to re-point or remove.
|
|
540
|
+
*/
|
|
541
|
+
export type TailscaleHealth =
|
|
542
|
+
| { state: "absent" }
|
|
543
|
+
| { state: "unresponsive"; error: string }
|
|
544
|
+
| { state: "ok"; routes: { listener: string; port: number; url: string | null }[]; repair: ServeRepair[]; stale: ServeStale[] };
|
|
545
|
+
|
|
546
|
+
export async function inspectTailscaleServe(listeners: HealthListener[]): Promise<TailscaleHealth> {
|
|
547
|
+
const [serve, status] = await Promise.all([runTailscaleDetailed(["serve", "status", "--json"]), runTailscale(["status", "--json"])]);
|
|
548
|
+
if (!("output" in serve)) return serve.missing ? { state: "absent" } : { state: "unresponsive", error: serve.error };
|
|
549
|
+
let serveStatus: unknown, dnsName: string | null = null;
|
|
550
|
+
try { serveStatus = JSON.parse(serve.output || "null"); } catch { return { state: "unresponsive", error: "`tailscale serve status --json` printed no JSON" }; }
|
|
551
|
+
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
552
|
+
const routes = listeners.filter(l => l.profiles !== "*")
|
|
553
|
+
.map(l => ({ listener: l.name, port: l.port, url: findTailscaleServeRoute(dnsName, serveStatus, l.port, normalizePrefix(l.prefix)) }));
|
|
554
|
+
return { state: "ok", routes, ...planTailscaleServeRepairs(serveStatus, listeners) };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const httpsPortOf = (hostPort: string) => hostPort.split(/:(?=\d+$)/)[1] ?? "443";
|
|
558
|
+
|
|
559
|
+
/** Problems in a health report, one line each with the command that fixes it; empty when healthy. */
|
|
560
|
+
export function tailscaleHealthProblems(health: TailscaleHealth): string[] {
|
|
561
|
+
if (health.state === "absent") return [];
|
|
562
|
+
if (health.state === "unresponsive") return [`Tailscale is installed but its CLI doesn't answer, so Serve routes go unchecked: ${health.error}`];
|
|
563
|
+
return [
|
|
564
|
+
...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}`),
|
|
565
|
+
...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`),
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The `tailscale:` block of `rech status` / `rech share ls`: each shared listener's route, then problems. */
|
|
570
|
+
export function formatTailscaleHealth(health: TailscaleHealth): string[] {
|
|
571
|
+
if (health.state === "absent") return [];
|
|
572
|
+
const lines = health.state === "ok"
|
|
573
|
+
? 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)`)
|
|
574
|
+
: [];
|
|
575
|
+
lines.push(...tailscaleHealthProblems(health).map(p => `⚠ ${p}`));
|
|
576
|
+
return lines.map((line, i) => `${i ? " ".repeat(11) : "tailscale: "}${line}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
517
579
|
/**
|
|
518
580
|
* Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
|
|
519
581
|
* 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
|
|
582
|
+
* route is the user's call) and a Tailscale CLI that doesn't answer. Returns log lines, empty
|
|
583
|
+
* when healthy or Tailscale is absent.
|
|
521
584
|
*/
|
|
522
|
-
export async function checkTailscaleServe(listeners:
|
|
585
|
+
export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
|
|
586
|
+
const health = await inspectTailscaleServe(listeners);
|
|
523
587
|
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);
|
|
588
|
+
if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
|
|
530
589
|
const lines: string[] = [];
|
|
531
|
-
for (const r of repair) {
|
|
532
|
-
const
|
|
533
|
-
const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
|
|
590
|
+
for (const r of health.repair) {
|
|
591
|
+
const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
|
|
534
592
|
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
|
|
535
593
|
const ok = await proc.exited === 0;
|
|
536
594
|
lines.push(ok
|
|
537
595
|
? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
|
|
538
596
|
: `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
597
|
}
|
|
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
|
-
}
|
|
598
|
+
lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
|
|
543
599
|
return lines;
|
|
544
600
|
}
|
|
545
601
|
|
|
@@ -2718,6 +2774,9 @@ async function provisionExtensionToken(opts: {
|
|
|
2718
2774
|
`--profile-directory=${profileDir}`,
|
|
2719
2775
|
`--load-extension=${dist}`,
|
|
2720
2776
|
`--disable-extensions-except=${dist}`,
|
|
2777
|
+
// Chromium 137+ ignores --load-extension unless this feature is off (Chrome for Testing
|
|
2778
|
+
// too): the extension silently never loads and status.html is an error page.
|
|
2779
|
+
"--disable-features=DisableLoadExtensionCommandLineSwitch",
|
|
2721
2780
|
"--remote-debugging-port=0",
|
|
2722
2781
|
"--no-first-run",
|
|
2723
2782
|
"--no-default-browser-check",
|
|
@@ -2725,40 +2784,70 @@ async function provisionExtensionToken(opts: {
|
|
|
2725
2784
|
];
|
|
2726
2785
|
if (!opts.headed) args.push("--headless=new");
|
|
2727
2786
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
2787
|
+
// Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
|
|
2788
|
+
if (process.platform === "darwin") args.push("--use-mock-keychain");
|
|
2728
2789
|
args.push("about:blank");
|
|
2729
2790
|
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
2730
2791
|
let cdp: CDPClient | null = null;
|
|
2792
|
+
// Every step is bounded: a stuck Chrome must end in an error, never a silent hang.
|
|
2793
|
+
const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
|
|
2794
|
+
Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
|
|
2731
2795
|
try {
|
|
2732
2796
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
2733
2797
|
let port: number | null = null;
|
|
2734
|
-
for (let i = 0; i <
|
|
2798
|
+
for (let i = 0; i < 300; i++) {
|
|
2735
2799
|
await Bun.sleep(100);
|
|
2736
2800
|
const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
|
|
2737
2801
|
if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
|
|
2738
2802
|
if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
|
|
2739
2803
|
}
|
|
2740
|
-
if (!port) throw new Error("Chrome
|
|
2741
|
-
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version
|
|
2804
|
+
if (!port) throw new Error("Chrome didn't open its DevTools port within 30s");
|
|
2805
|
+
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
|
|
2742
2806
|
cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
|
|
2743
|
-
await cdp.open();
|
|
2744
|
-
|
|
2807
|
+
await within(5000, "Connecting to Chrome DevTools", cdp.open());
|
|
2808
|
+
// Wait for the extension's service worker: if it never shows, the extension didn't load.
|
|
2809
|
+
const extensionUrl = `chrome-extension://${EXTENSION_ID}/`;
|
|
2810
|
+
let loaded = false;
|
|
2811
|
+
for (let i = 0; i < 50 && !loaded; i++) {
|
|
2812
|
+
const { targetInfos } = await cdp.send("Target.getTargets");
|
|
2813
|
+
loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
|
|
2814
|
+
if (!loaded) await Bun.sleep(200);
|
|
2815
|
+
}
|
|
2816
|
+
if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
|
|
2817
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
|
|
2745
2818
|
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
2746
2819
|
// The extension page may still be loading; retry the write until localStorage reflects it.
|
|
2747
|
-
let ok = false;
|
|
2820
|
+
let ok = false, last = "";
|
|
2748
2821
|
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
|
-
|
|
2822
|
+
const deadline = Date.now() + 10_000;
|
|
2823
|
+
while (Date.now() < deadline) {
|
|
2824
|
+
const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
|
|
2825
|
+
const value = (r as any)?.result?.value;
|
|
2826
|
+
if (value === token) { ok = true; break; }
|
|
2827
|
+
last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
|
|
2828
|
+
await Bun.sleep(200);
|
|
2753
2829
|
}
|
|
2754
|
-
if (!ok) throw new Error(`Could not seed auth token into
|
|
2830
|
+
if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
|
|
2755
2831
|
// Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
|
|
2756
|
-
await cdp.send("Browser.close").catch(() => {});
|
|
2832
|
+
await within(5000, "Browser.close", cdp.send("Browser.close")).catch(() => {});
|
|
2833
|
+
await within(5000, "Chrome exit", proc.exited).catch(() => {});
|
|
2757
2834
|
} finally {
|
|
2758
2835
|
cdp?.close();
|
|
2836
|
+
await stopThrowawayChrome(proc, userDataDir);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
|
|
2842
|
+
* still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
|
|
2843
|
+
*/
|
|
2844
|
+
async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
|
|
2845
|
+
if (proc.exitCode === null) {
|
|
2759
2846
|
try { proc.kill(); } catch {}
|
|
2760
|
-
await proc.exited.
|
|
2847
|
+
const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
|
|
2848
|
+
if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
|
|
2761
2849
|
}
|
|
2850
|
+
if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
|
|
2762
2851
|
}
|
|
2763
2852
|
|
|
2764
2853
|
async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
|
|
@@ -2952,6 +3041,8 @@ async function urlList(): Promise<void> {
|
|
|
2952
3041
|
printTable(rows);
|
|
2953
3042
|
const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
|
|
2954
3043
|
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.`);
|
|
3044
|
+
const tailscale = formatTailscaleHealth(await inspectTailscaleServe(config.listeners));
|
|
3045
|
+
if (tailscale.length) console.log(`\n${tailscale.join("\n")}`);
|
|
2955
3046
|
console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
|
|
2956
3047
|
}
|
|
2957
3048
|
|
|
@@ -2961,14 +3052,42 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2961
3052
|
mkdirSync(dataDir, { recursive: true });
|
|
2962
3053
|
const gitignore = join(dataDir, ".gitignore");
|
|
2963
3054
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
2964
|
-
|
|
3055
|
+
return writeEnvUrl(join(dataDir, ".env.local"), url);
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
/** Write RECHROME_URL to ~/.rechrome/.env.local: the default for every project without its own. */
|
|
3059
|
+
async function saveGlobalUrl(url: string): Promise<string> {
|
|
3060
|
+
mkdirSync(dirname(GLOBAL_ENV_FILE), { recursive: true });
|
|
3061
|
+
return writeEnvUrl(GLOBAL_ENV_FILE, url);
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
/** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
|
|
3065
|
+
async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
2965
3066
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
2966
3067
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
2967
3068
|
await chmod(envPath, 0o600);
|
|
2968
3069
|
return envPath;
|
|
2969
3070
|
}
|
|
2970
3071
|
|
|
2971
|
-
|
|
3072
|
+
/**
|
|
3073
|
+
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3074
|
+
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
3075
|
+
*/
|
|
3076
|
+
export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GLOBAL_ENV_FILE): string | null {
|
|
3077
|
+
if (inherited) return "your shell's RECHROME_URL";
|
|
3078
|
+
const globalReal = (() => { try { return realpathSync(globalFile); } catch { return globalFile; } })();
|
|
3079
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
3080
|
+
for (const path of [join(dir, ".rechrome", ".env.local"), join(dir, ".env.local")]) {
|
|
3081
|
+
if (path === globalFile || path === globalReal) return null;
|
|
3082
|
+
const text = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
|
|
3083
|
+
if (text.split("\n").some(line => line.trimStart().startsWith(`${ENV_KEY}=`))) return path;
|
|
3084
|
+
}
|
|
3085
|
+
if (dirname(dir) === dir) return null;
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
|
|
3090
|
+
if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
|
|
2972
3091
|
const parsed = parseUrl(url);
|
|
2973
3092
|
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
2974
3093
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
@@ -2978,8 +3097,24 @@ async function connect(url: string): Promise<void> {
|
|
|
2978
3097
|
const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
|
|
2979
3098
|
if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
|
|
2980
3099
|
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
2981
|
-
|
|
2982
|
-
|
|
3100
|
+
// Where to save: the flag, else ask in a terminal (this project by default), else this project.
|
|
3101
|
+
let global = !!opts.global;
|
|
3102
|
+
if (!opts.global && !opts.project && isInteractive()) {
|
|
3103
|
+
const { dataDir } = await getClientIdentity();
|
|
3104
|
+
const choice = await promptChoice("Use this link for:", [
|
|
3105
|
+
{ label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
|
|
3106
|
+
{ label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
|
|
3107
|
+
], 0);
|
|
3108
|
+
if (!choice) throw new Error("Cancelled; nothing saved.");
|
|
3109
|
+
global = choice === "global";
|
|
3110
|
+
}
|
|
3111
|
+
// A machine running its own daemon would stop reaching its own Chrome from every project without a link.
|
|
3112
|
+
if (global && !(await isLocalDaemon(url)) && (await readListeners().catch(() => null))?.listeners.length)
|
|
3113
|
+
console.error(`[rech] warning: this machine runs its own rech daemon. Projects without their own RECHROME_URL will now use ${serviceUrl(url)}, not this machine's Chrome. Undo: remove ${ENV_KEY} from ${GLOBAL_ENV_FILE}`);
|
|
3114
|
+
const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
|
|
3115
|
+
console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
|
|
3116
|
+
const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
|
|
3117
|
+
if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
|
|
2983
3118
|
if (Array.isArray(body.profiles) && body.profiles.length > 1) {
|
|
2984
3119
|
console.log(`This link shares ${body.profiles.length} profiles:`);
|
|
2985
3120
|
for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
|
|
@@ -3403,7 +3538,12 @@ async function status(): Promise<void> {
|
|
|
3403
3538
|
: null;
|
|
3404
3539
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
3405
3540
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
3406
|
-
|
|
3541
|
+
// Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
|
|
3542
|
+
const answered = pingResponse?.ok || pingResponse?.status === 401;
|
|
3543
|
+
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
3544
|
+
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
3545
|
+
: `✗ ${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>'\``}`);
|
|
3546
|
+
if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
|
|
3407
3547
|
if (pingResponse?.status === 401)
|
|
3408
3548
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
3409
3549
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
@@ -3426,7 +3566,11 @@ async function status(): Promise<void> {
|
|
|
3426
3566
|
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
3427
3567
|
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
3428
3568
|
console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
|
|
3429
|
-
if (isHost)
|
|
3569
|
+
if (isHost) {
|
|
3570
|
+
const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
|
|
3571
|
+
for (const line of formatTailscaleHealth(await inspectTailscaleServe(listeners))) console.log(line);
|
|
3572
|
+
console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
|
|
3573
|
+
}
|
|
3430
3574
|
}
|
|
3431
3575
|
|
|
3432
3576
|
|
|
@@ -3442,7 +3586,7 @@ export type RechHandlers = {
|
|
|
3442
3586
|
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
|
|
3443
3587
|
urlList(): Promise<void>;
|
|
3444
3588
|
shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
|
|
3445
|
-
connect(url: string): Promise<void>;
|
|
3589
|
+
connect(url: string, opts?: { global?: boolean; project?: boolean }): Promise<void>;
|
|
3446
3590
|
listenerPort(name?: string): Promise<void>;
|
|
3447
3591
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
3448
3592
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
@@ -3537,10 +3681,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
3537
3681
|
}
|
|
3538
3682
|
return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
|
|
3539
3683
|
})
|
|
3540
|
-
.command("connect <url>", "Use a URL from another machine
|
|
3684
|
+
.command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
|
|
3541
3685
|
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
|
|
3542
|
-
.
|
|
3543
|
-
|
|
3686
|
+
.option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
|
|
3687
|
+
.option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
|
|
3688
|
+
.conflicts("project", "global")
|
|
3689
|
+
.example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", "")
|
|
3690
|
+
.example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
|
|
3691
|
+
a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
|
|
3544
3692
|
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
3545
3693
|
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
3546
3694
|
.command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
|
package/rechrome.ts
CHANGED
|
@@ -84,6 +84,8 @@ async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const envFile = join(import.meta.dir, ".env.local");
|
|
87
|
+
/** Machine-wide RECHROME_URL from `rech connect --global`: read from every folder, after the project's own files. */
|
|
88
|
+
export const GLOBAL_ENV_FILE = join(RECH_DIR, ".env.local");
|
|
87
89
|
const globalEnvFile = join(HOME || "~", ".env.local");
|
|
88
90
|
|
|
89
91
|
// Capture inherited values once so explicit environment overrides survive reloads,
|
|
@@ -127,10 +129,15 @@ const bunLoaded = bunAutoloadedKeys(process.env,
|
|
|
127
129
|
.map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
|
|
128
130
|
const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
|
|
129
131
|
|
|
132
|
+
/** Where RECHROME_URL came from: "environment", an env file's path, or undefined when unset. */
|
|
133
|
+
export let rechromeUrlSource: string | undefined;
|
|
134
|
+
|
|
130
135
|
// Walk CWD→root loading env files nearest-first; inherited environment wins over files.
|
|
131
136
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
132
|
-
|
|
137
|
+
// The machine-wide file comes last, so it also applies outside $HOME (e.g. an agent's /tmp scratch dir).
|
|
138
|
+
export async function loadNearestEnv(extraFallbacks: string[] = [GLOBAL_ENV_FILE]) {
|
|
133
139
|
const seen = new Set<string>(inheritedEnvKeys);
|
|
140
|
+
rechromeUrlSource = seen.has(ENV_KEY) ? "environment" : undefined;
|
|
134
141
|
const applyFile = async (path: string) => {
|
|
135
142
|
const raw = await file(path).text().catch(() => "");
|
|
136
143
|
for (const line of raw.split("\n")) {
|
|
@@ -138,6 +145,7 @@ export async function loadNearestEnv(extraFallbacks: string[] = []) {
|
|
|
138
145
|
if (!m || m[1].startsWith("#")) continue;
|
|
139
146
|
if (seen.has(m[1])) continue;
|
|
140
147
|
seen.add(m[1]);
|
|
148
|
+
if (m[1] === ENV_KEY) rechromeUrlSource = path;
|
|
141
149
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
142
150
|
}
|
|
143
151
|
};
|
|
@@ -382,18 +390,27 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
|
|
|
382
390
|
const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
|
|
383
391
|
const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
|
|
384
392
|
|
|
385
|
-
/** Run the Tailscale CLI read-only
|
|
386
|
-
async function
|
|
393
|
+
/** Run the Tailscale CLI read-only: its output, or why it gave none (missing, failed, slow). */
|
|
394
|
+
async function runTailscaleDetailed(args: string[]): Promise<{ output: string } | { error: string; missing?: true }> {
|
|
387
395
|
const binary = tailscaleBinary();
|
|
388
|
-
if (!binary) return
|
|
396
|
+
if (!binary) return { error: "not installed", missing: true };
|
|
389
397
|
try {
|
|
390
|
-
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "
|
|
391
|
-
|
|
398
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "pipe" });
|
|
399
|
+
let timedOut = false;
|
|
400
|
+
const timeout = setTimeout(() => { timedOut = true; proc.kill(); }, 2000);
|
|
392
401
|
try {
|
|
393
|
-
const output = await new Response(proc.stdout).text();
|
|
394
|
-
|
|
402
|
+
const [output, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
403
|
+
if (await proc.exited === 0) return { output };
|
|
404
|
+
const reason = timedOut ? "no answer within 2s" : (stderr.trim() || output.trim()).split("\n")[0] || `exit ${proc.exitCode}`;
|
|
405
|
+
return { error: `\`tailscale ${args.join(" ")}\`: ${reason}` };
|
|
395
406
|
} finally { clearTimeout(timeout); }
|
|
396
|
-
} catch { return
|
|
407
|
+
} catch (error) { return { error: String(error) }; }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
|
|
411
|
+
async function runTailscale(args: string[]): Promise<string | null> {
|
|
412
|
+
const result = await runTailscaleDetailed(args);
|
|
413
|
+
return "output" in result ? result.output : null;
|
|
397
414
|
}
|
|
398
415
|
|
|
399
416
|
export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
@@ -514,32 +531,71 @@ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: str
|
|
|
514
531
|
}
|
|
515
532
|
}
|
|
516
533
|
|
|
534
|
+
type HealthListener = Pick<Listener, "name" | "port" | "prefix" | "profiles">;
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Read-only Tailscale Serve health for the listeners: `absent` (no Tailscale here), `unresponsive`
|
|
538
|
+
* (installed, but its CLI doesn't answer — e.g. not running, or the Mac app's GUI binary), or
|
|
539
|
+
* `ok` with each shared listener's route plus the routes to re-point or remove.
|
|
540
|
+
*/
|
|
541
|
+
export type TailscaleHealth =
|
|
542
|
+
| { state: "absent" }
|
|
543
|
+
| { state: "unresponsive"; error: string }
|
|
544
|
+
| { state: "ok"; routes: { listener: string; port: number; url: string | null }[]; repair: ServeRepair[]; stale: ServeStale[] };
|
|
545
|
+
|
|
546
|
+
export async function inspectTailscaleServe(listeners: HealthListener[]): Promise<TailscaleHealth> {
|
|
547
|
+
const [serve, status] = await Promise.all([runTailscaleDetailed(["serve", "status", "--json"]), runTailscale(["status", "--json"])]);
|
|
548
|
+
if (!("output" in serve)) return serve.missing ? { state: "absent" } : { state: "unresponsive", error: serve.error };
|
|
549
|
+
let serveStatus: unknown, dnsName: string | null = null;
|
|
550
|
+
try { serveStatus = JSON.parse(serve.output || "null"); } catch { return { state: "unresponsive", error: "`tailscale serve status --json` printed no JSON" }; }
|
|
551
|
+
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
552
|
+
const routes = listeners.filter(l => l.profiles !== "*")
|
|
553
|
+
.map(l => ({ listener: l.name, port: l.port, url: findTailscaleServeRoute(dnsName, serveStatus, l.port, normalizePrefix(l.prefix)) }));
|
|
554
|
+
return { state: "ok", routes, ...planTailscaleServeRepairs(serveStatus, listeners) };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const httpsPortOf = (hostPort: string) => hostPort.split(/:(?=\d+$)/)[1] ?? "443";
|
|
558
|
+
|
|
559
|
+
/** Problems in a health report, one line each with the command that fixes it; empty when healthy. */
|
|
560
|
+
export function tailscaleHealthProblems(health: TailscaleHealth): string[] {
|
|
561
|
+
if (health.state === "absent") return [];
|
|
562
|
+
if (health.state === "unresponsive") return [`Tailscale is installed but its CLI doesn't answer, so Serve routes go unchecked: ${health.error}`];
|
|
563
|
+
return [
|
|
564
|
+
...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}`),
|
|
565
|
+
...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`),
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The `tailscale:` block of `rech status` / `rech share ls`: each shared listener's route, then problems. */
|
|
570
|
+
export function formatTailscaleHealth(health: TailscaleHealth): string[] {
|
|
571
|
+
if (health.state === "absent") return [];
|
|
572
|
+
const lines = health.state === "ok"
|
|
573
|
+
? 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)`)
|
|
574
|
+
: [];
|
|
575
|
+
lines.push(...tailscaleHealthProblems(health).map(p => `⚠ ${p}`));
|
|
576
|
+
return lines.map((line, i) => `${i ? " ".repeat(11) : "tailscale: "}${line}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
517
579
|
/**
|
|
518
580
|
* Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
|
|
519
581
|
* 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
|
|
582
|
+
* route is the user's call) and a Tailscale CLI that doesn't answer. Returns log lines, empty
|
|
583
|
+
* when healthy or Tailscale is absent.
|
|
521
584
|
*/
|
|
522
|
-
export async function checkTailscaleServe(listeners:
|
|
585
|
+
export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
|
|
586
|
+
const health = await inspectTailscaleServe(listeners);
|
|
523
587
|
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);
|
|
588
|
+
if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
|
|
530
589
|
const lines: string[] = [];
|
|
531
|
-
for (const r of repair) {
|
|
532
|
-
const
|
|
533
|
-
const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
|
|
590
|
+
for (const r of health.repair) {
|
|
591
|
+
const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
|
|
534
592
|
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
|
|
535
593
|
const ok = await proc.exited === 0;
|
|
536
594
|
lines.push(ok
|
|
537
595
|
? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
|
|
538
596
|
: `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
597
|
}
|
|
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
|
-
}
|
|
598
|
+
lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
|
|
543
599
|
return lines;
|
|
544
600
|
}
|
|
545
601
|
|
|
@@ -2718,6 +2774,9 @@ async function provisionExtensionToken(opts: {
|
|
|
2718
2774
|
`--profile-directory=${profileDir}`,
|
|
2719
2775
|
`--load-extension=${dist}`,
|
|
2720
2776
|
`--disable-extensions-except=${dist}`,
|
|
2777
|
+
// Chromium 137+ ignores --load-extension unless this feature is off (Chrome for Testing
|
|
2778
|
+
// too): the extension silently never loads and status.html is an error page.
|
|
2779
|
+
"--disable-features=DisableLoadExtensionCommandLineSwitch",
|
|
2721
2780
|
"--remote-debugging-port=0",
|
|
2722
2781
|
"--no-first-run",
|
|
2723
2782
|
"--no-default-browser-check",
|
|
@@ -2725,40 +2784,70 @@ async function provisionExtensionToken(opts: {
|
|
|
2725
2784
|
];
|
|
2726
2785
|
if (!opts.headed) args.push("--headless=new");
|
|
2727
2786
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
2787
|
+
// Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
|
|
2788
|
+
if (process.platform === "darwin") args.push("--use-mock-keychain");
|
|
2728
2789
|
args.push("about:blank");
|
|
2729
2790
|
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
2730
2791
|
let cdp: CDPClient | null = null;
|
|
2792
|
+
// Every step is bounded: a stuck Chrome must end in an error, never a silent hang.
|
|
2793
|
+
const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
|
|
2794
|
+
Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
|
|
2731
2795
|
try {
|
|
2732
2796
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
2733
2797
|
let port: number | null = null;
|
|
2734
|
-
for (let i = 0; i <
|
|
2798
|
+
for (let i = 0; i < 300; i++) {
|
|
2735
2799
|
await Bun.sleep(100);
|
|
2736
2800
|
const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
|
|
2737
2801
|
if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
|
|
2738
2802
|
if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
|
|
2739
2803
|
}
|
|
2740
|
-
if (!port) throw new Error("Chrome
|
|
2741
|
-
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version
|
|
2804
|
+
if (!port) throw new Error("Chrome didn't open its DevTools port within 30s");
|
|
2805
|
+
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
|
|
2742
2806
|
cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
|
|
2743
|
-
await cdp.open();
|
|
2744
|
-
|
|
2807
|
+
await within(5000, "Connecting to Chrome DevTools", cdp.open());
|
|
2808
|
+
// Wait for the extension's service worker: if it never shows, the extension didn't load.
|
|
2809
|
+
const extensionUrl = `chrome-extension://${EXTENSION_ID}/`;
|
|
2810
|
+
let loaded = false;
|
|
2811
|
+
for (let i = 0; i < 50 && !loaded; i++) {
|
|
2812
|
+
const { targetInfos } = await cdp.send("Target.getTargets");
|
|
2813
|
+
loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
|
|
2814
|
+
if (!loaded) await Bun.sleep(200);
|
|
2815
|
+
}
|
|
2816
|
+
if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
|
|
2817
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
|
|
2745
2818
|
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
2746
2819
|
// The extension page may still be loading; retry the write until localStorage reflects it.
|
|
2747
|
-
let ok = false;
|
|
2820
|
+
let ok = false, last = "";
|
|
2748
2821
|
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
|
-
|
|
2822
|
+
const deadline = Date.now() + 10_000;
|
|
2823
|
+
while (Date.now() < deadline) {
|
|
2824
|
+
const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
|
|
2825
|
+
const value = (r as any)?.result?.value;
|
|
2826
|
+
if (value === token) { ok = true; break; }
|
|
2827
|
+
last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
|
|
2828
|
+
await Bun.sleep(200);
|
|
2753
2829
|
}
|
|
2754
|
-
if (!ok) throw new Error(`Could not seed auth token into
|
|
2830
|
+
if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
|
|
2755
2831
|
// Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
|
|
2756
|
-
await cdp.send("Browser.close").catch(() => {});
|
|
2832
|
+
await within(5000, "Browser.close", cdp.send("Browser.close")).catch(() => {});
|
|
2833
|
+
await within(5000, "Chrome exit", proc.exited).catch(() => {});
|
|
2757
2834
|
} finally {
|
|
2758
2835
|
cdp?.close();
|
|
2836
|
+
await stopThrowawayChrome(proc, userDataDir);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
|
|
2842
|
+
* still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
|
|
2843
|
+
*/
|
|
2844
|
+
async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
|
|
2845
|
+
if (proc.exitCode === null) {
|
|
2759
2846
|
try { proc.kill(); } catch {}
|
|
2760
|
-
await proc.exited.
|
|
2847
|
+
const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
|
|
2848
|
+
if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
|
|
2761
2849
|
}
|
|
2850
|
+
if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
|
|
2762
2851
|
}
|
|
2763
2852
|
|
|
2764
2853
|
async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
|
|
@@ -2952,6 +3041,8 @@ async function urlList(): Promise<void> {
|
|
|
2952
3041
|
printTable(rows);
|
|
2953
3042
|
const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
|
|
2954
3043
|
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.`);
|
|
3044
|
+
const tailscale = formatTailscaleHealth(await inspectTailscaleServe(config.listeners));
|
|
3045
|
+
if (tailscale.length) console.log(`\n${tailscale.join("\n")}`);
|
|
2955
3046
|
console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
|
|
2956
3047
|
}
|
|
2957
3048
|
|
|
@@ -2961,14 +3052,42 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2961
3052
|
mkdirSync(dataDir, { recursive: true });
|
|
2962
3053
|
const gitignore = join(dataDir, ".gitignore");
|
|
2963
3054
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
2964
|
-
|
|
3055
|
+
return writeEnvUrl(join(dataDir, ".env.local"), url);
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
/** Write RECHROME_URL to ~/.rechrome/.env.local: the default for every project without its own. */
|
|
3059
|
+
async function saveGlobalUrl(url: string): Promise<string> {
|
|
3060
|
+
mkdirSync(dirname(GLOBAL_ENV_FILE), { recursive: true });
|
|
3061
|
+
return writeEnvUrl(GLOBAL_ENV_FILE, url);
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
/** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
|
|
3065
|
+
async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
2965
3066
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
2966
3067
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
2967
3068
|
await chmod(envPath, 0o600);
|
|
2968
3069
|
return envPath;
|
|
2969
3070
|
}
|
|
2970
3071
|
|
|
2971
|
-
|
|
3072
|
+
/**
|
|
3073
|
+
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3074
|
+
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
3075
|
+
*/
|
|
3076
|
+
export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GLOBAL_ENV_FILE): string | null {
|
|
3077
|
+
if (inherited) return "your shell's RECHROME_URL";
|
|
3078
|
+
const globalReal = (() => { try { return realpathSync(globalFile); } catch { return globalFile; } })();
|
|
3079
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
3080
|
+
for (const path of [join(dir, ".rechrome", ".env.local"), join(dir, ".env.local")]) {
|
|
3081
|
+
if (path === globalFile || path === globalReal) return null;
|
|
3082
|
+
const text = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
|
|
3083
|
+
if (text.split("\n").some(line => line.trimStart().startsWith(`${ENV_KEY}=`))) return path;
|
|
3084
|
+
}
|
|
3085
|
+
if (dirname(dir) === dir) return null;
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
|
|
3090
|
+
if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
|
|
2972
3091
|
const parsed = parseUrl(url);
|
|
2973
3092
|
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
2974
3093
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
@@ -2978,8 +3097,24 @@ async function connect(url: string): Promise<void> {
|
|
|
2978
3097
|
const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
|
|
2979
3098
|
if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
|
|
2980
3099
|
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
2981
|
-
|
|
2982
|
-
|
|
3100
|
+
// Where to save: the flag, else ask in a terminal (this project by default), else this project.
|
|
3101
|
+
let global = !!opts.global;
|
|
3102
|
+
if (!opts.global && !opts.project && isInteractive()) {
|
|
3103
|
+
const { dataDir } = await getClientIdentity();
|
|
3104
|
+
const choice = await promptChoice("Use this link for:", [
|
|
3105
|
+
{ label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
|
|
3106
|
+
{ label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
|
|
3107
|
+
], 0);
|
|
3108
|
+
if (!choice) throw new Error("Cancelled; nothing saved.");
|
|
3109
|
+
global = choice === "global";
|
|
3110
|
+
}
|
|
3111
|
+
// A machine running its own daemon would stop reaching its own Chrome from every project without a link.
|
|
3112
|
+
if (global && !(await isLocalDaemon(url)) && (await readListeners().catch(() => null))?.listeners.length)
|
|
3113
|
+
console.error(`[rech] warning: this machine runs its own rech daemon. Projects without their own RECHROME_URL will now use ${serviceUrl(url)}, not this machine's Chrome. Undo: remove ${ENV_KEY} from ${GLOBAL_ENV_FILE}`);
|
|
3114
|
+
const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
|
|
3115
|
+
console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
|
|
3116
|
+
const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
|
|
3117
|
+
if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
|
|
2983
3118
|
if (Array.isArray(body.profiles) && body.profiles.length > 1) {
|
|
2984
3119
|
console.log(`This link shares ${body.profiles.length} profiles:`);
|
|
2985
3120
|
for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
|
|
@@ -3403,7 +3538,12 @@ async function status(): Promise<void> {
|
|
|
3403
3538
|
: null;
|
|
3404
3539
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
3405
3540
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
3406
|
-
|
|
3541
|
+
// Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
|
|
3542
|
+
const answered = pingResponse?.ok || pingResponse?.status === 401;
|
|
3543
|
+
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
3544
|
+
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
3545
|
+
: `✗ ${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>'\``}`);
|
|
3546
|
+
if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
|
|
3407
3547
|
if (pingResponse?.status === 401)
|
|
3408
3548
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
3409
3549
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
@@ -3426,7 +3566,11 @@ async function status(): Promise<void> {
|
|
|
3426
3566
|
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
3427
3567
|
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
3428
3568
|
console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
|
|
3429
|
-
if (isHost)
|
|
3569
|
+
if (isHost) {
|
|
3570
|
+
const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
|
|
3571
|
+
for (const line of formatTailscaleHealth(await inspectTailscaleServe(listeners))) console.log(line);
|
|
3572
|
+
console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
|
|
3573
|
+
}
|
|
3430
3574
|
}
|
|
3431
3575
|
|
|
3432
3576
|
|
|
@@ -3442,7 +3586,7 @@ export type RechHandlers = {
|
|
|
3442
3586
|
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
|
|
3443
3587
|
urlList(): Promise<void>;
|
|
3444
3588
|
shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
|
|
3445
|
-
connect(url: string): Promise<void>;
|
|
3589
|
+
connect(url: string, opts?: { global?: boolean; project?: boolean }): Promise<void>;
|
|
3446
3590
|
listenerPort(name?: string): Promise<void>;
|
|
3447
3591
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
3448
3592
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
@@ -3537,10 +3681,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
3537
3681
|
}
|
|
3538
3682
|
return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
|
|
3539
3683
|
})
|
|
3540
|
-
.command("connect <url>", "Use a URL from another machine
|
|
3684
|
+
.command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
|
|
3541
3685
|
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
|
|
3542
|
-
.
|
|
3543
|
-
|
|
3686
|
+
.option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
|
|
3687
|
+
.option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
|
|
3688
|
+
.conflicts("project", "global")
|
|
3689
|
+
.example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", "")
|
|
3690
|
+
.example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
|
|
3691
|
+
a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
|
|
3544
3692
|
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
3545
3693
|
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
3546
3694
|
.command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
|