rechrome 1.31.8 → 1.33.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 +178 -14
- package/rechrome.ts +178 -14
- package/serve.js +74 -32
- package/serve.ts +74 -32
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -377,6 +377,9 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
|
|
|
377
377
|
return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
// The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
|
|
381
|
+
// daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
|
|
382
|
+
const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
|
|
380
383
|
const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
|
|
381
384
|
|
|
382
385
|
/** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
|
|
@@ -384,7 +387,7 @@ async function runTailscale(args: string[]): Promise<string | null> {
|
|
|
384
387
|
const binary = tailscaleBinary();
|
|
385
388
|
if (!binary) return null;
|
|
386
389
|
try {
|
|
387
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
390
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
|
|
388
391
|
const timeout = setTimeout(() => proc.kill(), 2000);
|
|
389
392
|
try {
|
|
390
393
|
const output = await new Response(proc.stdout).text();
|
|
@@ -445,6 +448,101 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
445
448
|
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
446
449
|
}
|
|
447
450
|
|
|
451
|
+
type ServeHandler = { hostPort: string; mount: string; target: URL };
|
|
452
|
+
|
|
453
|
+
/** Every Serve route that proxies to a loopback HTTP port. */
|
|
454
|
+
function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
|
|
455
|
+
const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
|
|
456
|
+
const handlers: ServeHandler[] = [];
|
|
457
|
+
for (const [hostPort, config] of Object.entries(web)) {
|
|
458
|
+
for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
|
|
459
|
+
if (!handler.Proxy) continue;
|
|
460
|
+
let target: URL;
|
|
461
|
+
try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
|
|
462
|
+
if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
|
|
463
|
+
handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return handlers;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
|
|
470
|
+
export type ServeStale = { hostPort: string; mount: string; port: number };
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
|
|
474
|
+
* mount that proxies to another port (the listener moved), re-pointed at the listener's port.
|
|
475
|
+
* `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
|
|
476
|
+
* /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
|
|
477
|
+
* Root ("/") mounts are never touched: they could be anything.
|
|
478
|
+
*/
|
|
479
|
+
export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
|
|
480
|
+
const ports = new Set(listeners.map(l => l.port));
|
|
481
|
+
const mounts = new Map<string, number>();
|
|
482
|
+
for (const l of listeners) {
|
|
483
|
+
const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
|
|
484
|
+
if (mount) mounts.set(mount, l.port);
|
|
485
|
+
}
|
|
486
|
+
const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
|
|
487
|
+
const repair: ServeRepair[] = [], stale: ServeStale[] = [];
|
|
488
|
+
for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
|
|
489
|
+
const port = Number(target.port), want = mounts.get(mount);
|
|
490
|
+
if (want !== undefined) {
|
|
491
|
+
if (port !== want) {
|
|
492
|
+
const proxy = new URL(target); proxy.port = String(want);
|
|
493
|
+
repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
|
|
494
|
+
}
|
|
495
|
+
} else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
|
|
496
|
+
stale.push({ hostPort, mount, port });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { repair, stale };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
|
|
503
|
+
async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
|
|
504
|
+
const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
|
|
505
|
+
if (!binary || !raw) return;
|
|
506
|
+
let serveStatus: unknown;
|
|
507
|
+
try { serveStatus = JSON.parse(raw); } catch { return; }
|
|
508
|
+
const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
|
|
509
|
+
for (const h of loopbackServeHandlers(serveStatus)) {
|
|
510
|
+
if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
|
|
511
|
+
const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
|
|
512
|
+
const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
|
|
513
|
+
console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
|
|
519
|
+
* Re-points a moved listener's route on this node; only reports stale routes (removing a
|
|
520
|
+
* route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
|
|
521
|
+
*/
|
|
522
|
+
export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
|
|
523
|
+
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);
|
|
530
|
+
const lines: string[] = [];
|
|
531
|
+
for (const r of repair) {
|
|
532
|
+
const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
|
|
533
|
+
const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
|
|
534
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
|
|
535
|
+
const ok = await proc.exited === 0;
|
|
536
|
+
lines.push(ok
|
|
537
|
+
? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
|
|
538
|
+
: `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
|
+
}
|
|
540
|
+
for (const s of stale) {
|
|
541
|
+
lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
|
|
542
|
+
}
|
|
543
|
+
return lines;
|
|
544
|
+
}
|
|
545
|
+
|
|
448
546
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
449
547
|
export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
|
|
450
548
|
const local = new URL(localUrl);
|
|
@@ -1240,14 +1338,28 @@ export async function promptChoice<T>(
|
|
|
1240
1338
|
} finally { rl.close(); }
|
|
1241
1339
|
}
|
|
1242
1340
|
|
|
1243
|
-
/**
|
|
1244
|
-
|
|
1341
|
+
/**
|
|
1342
|
+
* One picker entry per Chrome profile: its Chrome name and signed-in email (from Local State,
|
|
1343
|
+
* for profiles in the default user data dir), then the folder, e.g. `Work · you@example.com [Profile 2]`.
|
|
1344
|
+
*/
|
|
1345
|
+
export function registeredProfileChoices(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null = null): { label: string; value: string }[] {
|
|
1346
|
+
const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
|
|
1245
1347
|
return canonicalProfileKeys(registry).map(key => {
|
|
1246
|
-
const dir = registry[key]
|
|
1247
|
-
|
|
1348
|
+
const { profileDir: dir, userDataDir } = registry[key];
|
|
1349
|
+
const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
|
|
1350
|
+
const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
|
|
1351
|
+
const shown = [...new Set(names)];
|
|
1352
|
+
return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir, value: key };
|
|
1248
1353
|
});
|
|
1249
1354
|
}
|
|
1250
1355
|
|
|
1356
|
+
/** Chrome profiles (default user data dir) that rech has not registered yet: `[folder, info]`. */
|
|
1357
|
+
export function unregisteredChromeProfiles(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null): [string, ChromeProfileInfo][] {
|
|
1358
|
+
const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
|
|
1359
|
+
const registered = new Set(Object.values(registry).filter(e => !e.userDataDir || defaultDirs.includes(e.userDataDir)).map(e => e.profileDir));
|
|
1360
|
+
return Object.entries(cache ?? {}).filter(([dir]) => !registered.has(dir));
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1251
1363
|
/**
|
|
1252
1364
|
* Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
|
|
1253
1365
|
* profile, preferring one with a public URL. Never the management listener, whose key gives
|
|
@@ -1394,7 +1506,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
|
|
|
1394
1506
|
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1395
1507
|
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1396
1508
|
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1397
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1509
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1398
1510
|
if (await proc.exited !== 0) {
|
|
1399
1511
|
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1400
1512
|
return null;
|
|
@@ -1427,6 +1539,26 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
|
|
|
1427
1539
|
return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
|
|
1428
1540
|
}
|
|
1429
1541
|
|
|
1542
|
+
/** A scoped listener's full link (contains its key): its public URL when recorded, else its own address. */
|
|
1543
|
+
export function listenerLink(listener: Listener, local = false): string {
|
|
1544
|
+
const profiles = listener.profiles as string[];
|
|
1545
|
+
const only = profiles.length === 1 ? `?profile=${encodeURIComponent(profiles[0])}` : "";
|
|
1546
|
+
const direct = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1547
|
+
return listener.publicUrl && !local ? rebaseConnectionUrl(listener.publicUrl, direct) : registeredProfileUrl(direct);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** The links already handed out (stderr: stdout stays the one URL this command prints). */
|
|
1551
|
+
function printExistingLinks(listeners: Listener[], local = false): void {
|
|
1552
|
+
const scoped = listeners.filter(l => l.profiles !== "*" && l.profiles.length);
|
|
1553
|
+
if (!scoped.length) return;
|
|
1554
|
+
console.error("Already shared (secret links):");
|
|
1555
|
+
for (const l of scoped) {
|
|
1556
|
+
console.error(` ${l.name}: ${(l.profiles as string[]).join(", ")}${!l.publicUrl && isLoopback(l.host) ? " (this machine only: no proxy recorded)" : ""}`);
|
|
1557
|
+
console.error(` ${listenerLink(l, local)}`);
|
|
1558
|
+
}
|
|
1559
|
+
console.error("");
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1430
1562
|
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
|
|
1431
1563
|
if (opts.all) {
|
|
1432
1564
|
if (selector) throw new Error("Pass a profile or --all, not both.");
|
|
@@ -1437,14 +1569,41 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1437
1569
|
const registry = await readTokenRegistry();
|
|
1438
1570
|
const cache = await readChromeProfileCache();
|
|
1439
1571
|
const cancelled = () => new Error("Cancelled; nothing shared.");
|
|
1572
|
+
let config = await readListeners();
|
|
1440
1573
|
const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
|
|
1441
|
-
const
|
|
1574
|
+
const sharedOn = (key: string) => ((config?.listeners ?? []).filter(l => l.profiles !== "*" && (l.profiles as string[]).includes(key))).map(l => l.name);
|
|
1575
|
+
// Chrome profiles rech doesn't manage yet are listed too; picking one runs setup for it first.
|
|
1576
|
+
const SETUP = "\0setup:";
|
|
1577
|
+
const pickProfile = async (question: string, choices = [
|
|
1578
|
+
...registeredProfileChoices(registry, cache).map(c => {
|
|
1579
|
+
const on = sharedOn(c.value);
|
|
1580
|
+
return on.length ? { ...c, label: `${c.label} (shared: ${on.join(", ")})` } : c;
|
|
1581
|
+
}),
|
|
1582
|
+
...unregisteredChromeProfiles(registry, cache).map(([dir, info]) => ({
|
|
1583
|
+
label: `${[...new Set([info.name, info.user_name].filter(Boolean))].join(" · ") || dir} [${dir}] (not set up: runs rech setup)`,
|
|
1584
|
+
value: `${SETUP}${dir}`,
|
|
1585
|
+
})),
|
|
1586
|
+
]) => {
|
|
1442
1587
|
const def = Math.max(0, choices.findIndex(c => c.value === current || registry[c.value]?.profileDir === current));
|
|
1443
|
-
|
|
1588
|
+
const picked = (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
|
|
1589
|
+
if (!picked.startsWith(SETUP)) return picked;
|
|
1590
|
+
const dir = picked.slice(SETUP.length);
|
|
1591
|
+
console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
|
|
1592
|
+
await setup({ profile: dir });
|
|
1593
|
+
const after = await readTokenRegistry();
|
|
1594
|
+
const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
|
|
1595
|
+
if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
|
|
1596
|
+
Object.assign(registry, after);
|
|
1597
|
+
config = await readListeners(); // setup may have added a listener: never write back a stale copy
|
|
1598
|
+
return key;
|
|
1444
1599
|
};
|
|
1445
1600
|
if (!selector) {
|
|
1446
|
-
// No profile given: ask, defaulting to the current one
|
|
1447
|
-
|
|
1601
|
+
// No profile given: show what is already shared, then ask, defaulting to the current one
|
|
1602
|
+
// (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
|
|
1603
|
+
if (interactive && Object.keys(registry).length) {
|
|
1604
|
+
printExistingLinks(config?.listeners ?? [], opts.local);
|
|
1605
|
+
selector = await pickProfile("Share which profile?");
|
|
1606
|
+
}
|
|
1448
1607
|
else {
|
|
1449
1608
|
selector = current;
|
|
1450
1609
|
if (!selector) throw new Error("No current profile to share. Name one: rech share <profile> (see rech profile; rech share ls lists what is shared)");
|
|
@@ -1467,7 +1626,6 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1467
1626
|
} else throw error;
|
|
1468
1627
|
}
|
|
1469
1628
|
}
|
|
1470
|
-
const config = await readListeners();
|
|
1471
1629
|
const listeners = config?.listeners ?? [];
|
|
1472
1630
|
// On a host, share through a scoped listener by default, never the management key.
|
|
1473
1631
|
if (!listener && config) {
|
|
@@ -1609,7 +1767,9 @@ async function callServe(
|
|
|
1609
1767
|
return JSON.parse(text);
|
|
1610
1768
|
} catch {
|
|
1611
1769
|
// Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
|
|
1612
|
-
|
|
1770
|
+
let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
|
|
1771
|
+
if (res.status === 502 || res.status === 503 || res.status === 504)
|
|
1772
|
+
detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
|
|
1613
1773
|
if (throwOnFailure) throw new Error(detail);
|
|
1614
1774
|
console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
|
|
1615
1775
|
process.exit(1);
|
|
@@ -2699,6 +2859,7 @@ async function removeListener(name: string): Promise<void> {
|
|
|
2699
2859
|
config.listeners = config.listeners.filter(l => l !== listener);
|
|
2700
2860
|
await writeListeners(config);
|
|
2701
2861
|
console.log("Listener removed from configuration; daemon reloads automatically.");
|
|
2862
|
+
await removeTailscaleServeRoutes(listener);
|
|
2702
2863
|
}
|
|
2703
2864
|
|
|
2704
2865
|
async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
|
|
@@ -2819,8 +2980,11 @@ async function connect(url: string): Promise<void> {
|
|
|
2819
2980
|
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
2820
2981
|
const saved = await saveProjectUrl(url);
|
|
2821
2982
|
console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
|
|
2822
|
-
if (Array.isArray(body.profiles) && body.profiles.length > 1
|
|
2823
|
-
console.log(`This link shares ${body.profiles.length} profiles
|
|
2983
|
+
if (Array.isArray(body.profiles) && body.profiles.length > 1) {
|
|
2984
|
+
console.log(`This link shares ${body.profiles.length} profiles:`);
|
|
2985
|
+
for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
|
|
2986
|
+
console.log(`${parsed.profileDirectory ? "Use another" : "Pick one"} per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
|
|
2987
|
+
}
|
|
2824
2988
|
}
|
|
2825
2989
|
|
|
2826
2990
|
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
package/rechrome.ts
CHANGED
|
@@ -377,6 +377,9 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
|
|
|
377
377
|
return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
// The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
|
|
381
|
+
// daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
|
|
382
|
+
const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
|
|
380
383
|
const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
|
|
381
384
|
|
|
382
385
|
/** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
|
|
@@ -384,7 +387,7 @@ async function runTailscale(args: string[]): Promise<string | null> {
|
|
|
384
387
|
const binary = tailscaleBinary();
|
|
385
388
|
if (!binary) return null;
|
|
386
389
|
try {
|
|
387
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
390
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
|
|
388
391
|
const timeout = setTimeout(() => proc.kill(), 2000);
|
|
389
392
|
try {
|
|
390
393
|
const output = await new Response(proc.stdout).text();
|
|
@@ -445,6 +448,101 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
445
448
|
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
446
449
|
}
|
|
447
450
|
|
|
451
|
+
type ServeHandler = { hostPort: string; mount: string; target: URL };
|
|
452
|
+
|
|
453
|
+
/** Every Serve route that proxies to a loopback HTTP port. */
|
|
454
|
+
function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
|
|
455
|
+
const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
|
|
456
|
+
const handlers: ServeHandler[] = [];
|
|
457
|
+
for (const [hostPort, config] of Object.entries(web)) {
|
|
458
|
+
for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
|
|
459
|
+
if (!handler.Proxy) continue;
|
|
460
|
+
let target: URL;
|
|
461
|
+
try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
|
|
462
|
+
if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
|
|
463
|
+
handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return handlers;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
|
|
470
|
+
export type ServeStale = { hostPort: string; mount: string; port: number };
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
|
|
474
|
+
* mount that proxies to another port (the listener moved), re-pointed at the listener's port.
|
|
475
|
+
* `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
|
|
476
|
+
* /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
|
|
477
|
+
* Root ("/") mounts are never touched: they could be anything.
|
|
478
|
+
*/
|
|
479
|
+
export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
|
|
480
|
+
const ports = new Set(listeners.map(l => l.port));
|
|
481
|
+
const mounts = new Map<string, number>();
|
|
482
|
+
for (const l of listeners) {
|
|
483
|
+
const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
|
|
484
|
+
if (mount) mounts.set(mount, l.port);
|
|
485
|
+
}
|
|
486
|
+
const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
|
|
487
|
+
const repair: ServeRepair[] = [], stale: ServeStale[] = [];
|
|
488
|
+
for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
|
|
489
|
+
const port = Number(target.port), want = mounts.get(mount);
|
|
490
|
+
if (want !== undefined) {
|
|
491
|
+
if (port !== want) {
|
|
492
|
+
const proxy = new URL(target); proxy.port = String(want);
|
|
493
|
+
repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
|
|
494
|
+
}
|
|
495
|
+
} else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
|
|
496
|
+
stale.push({ hostPort, mount, port });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { repair, stale };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
|
|
503
|
+
async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
|
|
504
|
+
const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
|
|
505
|
+
if (!binary || !raw) return;
|
|
506
|
+
let serveStatus: unknown;
|
|
507
|
+
try { serveStatus = JSON.parse(raw); } catch { return; }
|
|
508
|
+
const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
|
|
509
|
+
for (const h of loopbackServeHandlers(serveStatus)) {
|
|
510
|
+
if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
|
|
511
|
+
const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
|
|
512
|
+
const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
|
|
513
|
+
console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
|
|
519
|
+
* Re-points a moved listener's route on this node; only reports stale routes (removing a
|
|
520
|
+
* route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
|
|
521
|
+
*/
|
|
522
|
+
export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
|
|
523
|
+
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);
|
|
530
|
+
const lines: string[] = [];
|
|
531
|
+
for (const r of repair) {
|
|
532
|
+
const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
|
|
533
|
+
const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
|
|
534
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
|
|
535
|
+
const ok = await proc.exited === 0;
|
|
536
|
+
lines.push(ok
|
|
537
|
+
? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
|
|
538
|
+
: `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
|
+
}
|
|
540
|
+
for (const s of stale) {
|
|
541
|
+
lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
|
|
542
|
+
}
|
|
543
|
+
return lines;
|
|
544
|
+
}
|
|
545
|
+
|
|
448
546
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
449
547
|
export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
|
|
450
548
|
const local = new URL(localUrl);
|
|
@@ -1240,14 +1338,28 @@ export async function promptChoice<T>(
|
|
|
1240
1338
|
} finally { rl.close(); }
|
|
1241
1339
|
}
|
|
1242
1340
|
|
|
1243
|
-
/**
|
|
1244
|
-
|
|
1341
|
+
/**
|
|
1342
|
+
* One picker entry per Chrome profile: its Chrome name and signed-in email (from Local State,
|
|
1343
|
+
* for profiles in the default user data dir), then the folder, e.g. `Work · you@example.com [Profile 2]`.
|
|
1344
|
+
*/
|
|
1345
|
+
export function registeredProfileChoices(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null = null): { label: string; value: string }[] {
|
|
1346
|
+
const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
|
|
1245
1347
|
return canonicalProfileKeys(registry).map(key => {
|
|
1246
|
-
const dir = registry[key]
|
|
1247
|
-
|
|
1348
|
+
const { profileDir: dir, userDataDir } = registry[key];
|
|
1349
|
+
const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
|
|
1350
|
+
const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
|
|
1351
|
+
const shown = [...new Set(names)];
|
|
1352
|
+
return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir, value: key };
|
|
1248
1353
|
});
|
|
1249
1354
|
}
|
|
1250
1355
|
|
|
1356
|
+
/** Chrome profiles (default user data dir) that rech has not registered yet: `[folder, info]`. */
|
|
1357
|
+
export function unregisteredChromeProfiles(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null): [string, ChromeProfileInfo][] {
|
|
1358
|
+
const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
|
|
1359
|
+
const registered = new Set(Object.values(registry).filter(e => !e.userDataDir || defaultDirs.includes(e.userDataDir)).map(e => e.profileDir));
|
|
1360
|
+
return Object.entries(cache ?? {}).filter(([dir]) => !registered.has(dir));
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1251
1363
|
/**
|
|
1252
1364
|
* Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
|
|
1253
1365
|
* profile, preferring one with a public URL. Never the management listener, whose key gives
|
|
@@ -1394,7 +1506,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
|
|
|
1394
1506
|
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1395
1507
|
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1396
1508
|
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1397
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1509
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1398
1510
|
if (await proc.exited !== 0) {
|
|
1399
1511
|
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1400
1512
|
return null;
|
|
@@ -1427,6 +1539,26 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
|
|
|
1427
1539
|
return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
|
|
1428
1540
|
}
|
|
1429
1541
|
|
|
1542
|
+
/** A scoped listener's full link (contains its key): its public URL when recorded, else its own address. */
|
|
1543
|
+
export function listenerLink(listener: Listener, local = false): string {
|
|
1544
|
+
const profiles = listener.profiles as string[];
|
|
1545
|
+
const only = profiles.length === 1 ? `?profile=${encodeURIComponent(profiles[0])}` : "";
|
|
1546
|
+
const direct = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1547
|
+
return listener.publicUrl && !local ? rebaseConnectionUrl(listener.publicUrl, direct) : registeredProfileUrl(direct);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** The links already handed out (stderr: stdout stays the one URL this command prints). */
|
|
1551
|
+
function printExistingLinks(listeners: Listener[], local = false): void {
|
|
1552
|
+
const scoped = listeners.filter(l => l.profiles !== "*" && l.profiles.length);
|
|
1553
|
+
if (!scoped.length) return;
|
|
1554
|
+
console.error("Already shared (secret links):");
|
|
1555
|
+
for (const l of scoped) {
|
|
1556
|
+
console.error(` ${l.name}: ${(l.profiles as string[]).join(", ")}${!l.publicUrl && isLoopback(l.host) ? " (this machine only: no proxy recorded)" : ""}`);
|
|
1557
|
+
console.error(` ${listenerLink(l, local)}`);
|
|
1558
|
+
}
|
|
1559
|
+
console.error("");
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1430
1562
|
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
|
|
1431
1563
|
if (opts.all) {
|
|
1432
1564
|
if (selector) throw new Error("Pass a profile or --all, not both.");
|
|
@@ -1437,14 +1569,41 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1437
1569
|
const registry = await readTokenRegistry();
|
|
1438
1570
|
const cache = await readChromeProfileCache();
|
|
1439
1571
|
const cancelled = () => new Error("Cancelled; nothing shared.");
|
|
1572
|
+
let config = await readListeners();
|
|
1440
1573
|
const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
|
|
1441
|
-
const
|
|
1574
|
+
const sharedOn = (key: string) => ((config?.listeners ?? []).filter(l => l.profiles !== "*" && (l.profiles as string[]).includes(key))).map(l => l.name);
|
|
1575
|
+
// Chrome profiles rech doesn't manage yet are listed too; picking one runs setup for it first.
|
|
1576
|
+
const SETUP = "\0setup:";
|
|
1577
|
+
const pickProfile = async (question: string, choices = [
|
|
1578
|
+
...registeredProfileChoices(registry, cache).map(c => {
|
|
1579
|
+
const on = sharedOn(c.value);
|
|
1580
|
+
return on.length ? { ...c, label: `${c.label} (shared: ${on.join(", ")})` } : c;
|
|
1581
|
+
}),
|
|
1582
|
+
...unregisteredChromeProfiles(registry, cache).map(([dir, info]) => ({
|
|
1583
|
+
label: `${[...new Set([info.name, info.user_name].filter(Boolean))].join(" · ") || dir} [${dir}] (not set up: runs rech setup)`,
|
|
1584
|
+
value: `${SETUP}${dir}`,
|
|
1585
|
+
})),
|
|
1586
|
+
]) => {
|
|
1442
1587
|
const def = Math.max(0, choices.findIndex(c => c.value === current || registry[c.value]?.profileDir === current));
|
|
1443
|
-
|
|
1588
|
+
const picked = (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
|
|
1589
|
+
if (!picked.startsWith(SETUP)) return picked;
|
|
1590
|
+
const dir = picked.slice(SETUP.length);
|
|
1591
|
+
console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
|
|
1592
|
+
await setup({ profile: dir });
|
|
1593
|
+
const after = await readTokenRegistry();
|
|
1594
|
+
const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
|
|
1595
|
+
if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
|
|
1596
|
+
Object.assign(registry, after);
|
|
1597
|
+
config = await readListeners(); // setup may have added a listener: never write back a stale copy
|
|
1598
|
+
return key;
|
|
1444
1599
|
};
|
|
1445
1600
|
if (!selector) {
|
|
1446
|
-
// No profile given: ask, defaulting to the current one
|
|
1447
|
-
|
|
1601
|
+
// No profile given: show what is already shared, then ask, defaulting to the current one
|
|
1602
|
+
// (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
|
|
1603
|
+
if (interactive && Object.keys(registry).length) {
|
|
1604
|
+
printExistingLinks(config?.listeners ?? [], opts.local);
|
|
1605
|
+
selector = await pickProfile("Share which profile?");
|
|
1606
|
+
}
|
|
1448
1607
|
else {
|
|
1449
1608
|
selector = current;
|
|
1450
1609
|
if (!selector) throw new Error("No current profile to share. Name one: rech share <profile> (see rech profile; rech share ls lists what is shared)");
|
|
@@ -1467,7 +1626,6 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1467
1626
|
} else throw error;
|
|
1468
1627
|
}
|
|
1469
1628
|
}
|
|
1470
|
-
const config = await readListeners();
|
|
1471
1629
|
const listeners = config?.listeners ?? [];
|
|
1472
1630
|
// On a host, share through a scoped listener by default, never the management key.
|
|
1473
1631
|
if (!listener && config) {
|
|
@@ -1609,7 +1767,9 @@ async function callServe(
|
|
|
1609
1767
|
return JSON.parse(text);
|
|
1610
1768
|
} catch {
|
|
1611
1769
|
// Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
|
|
1612
|
-
|
|
1770
|
+
let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
|
|
1771
|
+
if (res.status === 502 || res.status === 503 || res.status === 504)
|
|
1772
|
+
detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
|
|
1613
1773
|
if (throwOnFailure) throw new Error(detail);
|
|
1614
1774
|
console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
|
|
1615
1775
|
process.exit(1);
|
|
@@ -2699,6 +2859,7 @@ async function removeListener(name: string): Promise<void> {
|
|
|
2699
2859
|
config.listeners = config.listeners.filter(l => l !== listener);
|
|
2700
2860
|
await writeListeners(config);
|
|
2701
2861
|
console.log("Listener removed from configuration; daemon reloads automatically.");
|
|
2862
|
+
await removeTailscaleServeRoutes(listener);
|
|
2702
2863
|
}
|
|
2703
2864
|
|
|
2704
2865
|
async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
|
|
@@ -2819,8 +2980,11 @@ async function connect(url: string): Promise<void> {
|
|
|
2819
2980
|
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
2820
2981
|
const saved = await saveProjectUrl(url);
|
|
2821
2982
|
console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
|
|
2822
|
-
if (Array.isArray(body.profiles) && body.profiles.length > 1
|
|
2823
|
-
console.log(`This link shares ${body.profiles.length} profiles
|
|
2983
|
+
if (Array.isArray(body.profiles) && body.profiles.length > 1) {
|
|
2984
|
+
console.log(`This link shares ${body.profiles.length} profiles:`);
|
|
2985
|
+
for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
|
|
2986
|
+
console.log(`${parsed.profileDirectory ? "Use another" : "Pick one"} per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
|
|
2987
|
+
}
|
|
2824
2988
|
}
|
|
2825
2989
|
|
|
2826
2990
|
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
package/serve.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolvePlaywrightCli,
|
|
18
18
|
readTokenRegistry,
|
|
19
19
|
readChromeProfileCache,
|
|
20
|
+
checkTailscaleServe,
|
|
20
21
|
} from "./rechrome.js";
|
|
21
22
|
|
|
22
23
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
@@ -411,12 +412,12 @@ export const LANDING_HEADERS = {
|
|
|
411
412
|
"Cache-Control": "no-store",
|
|
412
413
|
"Referrer-Policy": "no-referrer",
|
|
413
414
|
"X-Content-Type-Options": "nosniff",
|
|
414
|
-
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
415
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
415
416
|
};
|
|
416
417
|
|
|
417
418
|
/**
|
|
418
|
-
* The page a shared connection URL shows in a browser: install-and-connect
|
|
419
|
-
*
|
|
419
|
+
* The page a shared connection URL shows in a browser: two install-and-connect lines to copy,
|
|
420
|
+
* identical for every shell. It is static; the script reads the full URL (with its #key) from the address
|
|
420
421
|
* bar, shows it with the key masked, and copies it whole.
|
|
421
422
|
*/
|
|
422
423
|
export function landingPage(): string {
|
|
@@ -432,56 +433,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
|
|
|
432
433
|
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
|
433
434
|
p { margin: 0 0 16px; color: var(--muted); }
|
|
434
435
|
.row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
|
|
435
|
-
.label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
|
436
436
|
.cmd { display: flex; gap: 10px; align-items: flex-start; }
|
|
437
437
|
code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
|
|
438
438
|
button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
|
|
439
439
|
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
440
440
|
.warn { color: var(--warn); font-weight: 600; }
|
|
441
441
|
.small { font-size: .85rem; }
|
|
442
|
+
#plist { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
|
|
443
|
+
#plist button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
|
442
444
|
a { color: var(--accent); }
|
|
443
445
|
</style></head>
|
|
444
446
|
<body><main>
|
|
445
447
|
<h1>Connect to a shared Chrome</h1>
|
|
446
448
|
<p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
|
|
447
|
-
On your computer, inside the project folder that should use it, run
|
|
449
|
+
On your computer, inside the project folder that should use it, run these two lines
|
|
450
|
+
(the same in macOS/Linux terminals, PowerShell and cmd):</p>
|
|
448
451
|
<p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
|
|
449
452
|
<div id="cmds"></div>
|
|
453
|
+
<div id="profiles" hidden><p>Profiles this link can use (pick one to make it the default in the command above):</p><div id="plist"></div></div>
|
|
450
454
|
<p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
|
|
451
455
|
This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
|
|
452
456
|
</main>
|
|
453
457
|
<script>
|
|
454
458
|
(() => {
|
|
455
|
-
|
|
459
|
+
let url = location.href;
|
|
460
|
+
const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
|
|
456
461
|
if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
|
|
464
|
-
];
|
|
465
|
-
const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
|
|
462
|
+
// One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
|
|
463
|
+
// shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
|
|
464
|
+
// %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
|
|
465
|
+
const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
|
|
466
|
+
const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
|
|
467
|
+
const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
|
|
466
468
|
const root = document.getElementById("cmds");
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
469
|
+
const row = document.createElement("div"); row.className = "row";
|
|
470
|
+
const line = document.createElement("div"); line.className = "cmd";
|
|
471
|
+
const code = document.createElement("code");
|
|
472
|
+
const entry = { text: "", update() { this.text = build(url); code.textContent = mask(this.text); } };
|
|
473
|
+
entry.update();
|
|
474
|
+
const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
|
|
475
|
+
button.setAttribute("aria-label", "Copy the commands");
|
|
476
|
+
button.addEventListener("click", async () => {
|
|
477
|
+
const text = entry.text;
|
|
478
|
+
try { await navigator.clipboard.writeText(text); }
|
|
479
|
+
catch {
|
|
480
|
+
// Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
|
|
481
|
+
const area = document.createElement("textarea"); area.value = text; document.body.append(area);
|
|
482
|
+
area.select(); document.execCommand("copy"); area.remove();
|
|
483
|
+
}
|
|
484
|
+
button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
|
485
|
+
});
|
|
486
|
+
line.append(code, button); row.append(line); root.append(row);
|
|
487
|
+
// The key never leaves the browser except to this listener's own /ping, which lists what it
|
|
488
|
+
// allows. Picking a profile only rewrites ?profile= in the command (the key works for all).
|
|
489
|
+
if (!key || typeof fetch !== "function") return;
|
|
490
|
+
const path = location.href.split("#")[0].split("?")[0], base = path.endsWith("/") ? path : path + "/";
|
|
491
|
+
fetch(base + "ping", { headers: { Authorization: "Bearer " + decodeURIComponent(key) } })
|
|
492
|
+
.then(r => r.ok ? r.json() : null)
|
|
493
|
+
.then(body => {
|
|
494
|
+
const profiles = body && Array.isArray(body.profiles) ? body.profiles : [];
|
|
495
|
+
if (!profiles.length) return;
|
|
496
|
+
const list = document.getElementById("plist"), buttons = [];
|
|
497
|
+
const select = p => {
|
|
498
|
+
const u = new URL(url); u.searchParams.set("profile", p); url = u.href;
|
|
499
|
+
for (const b of buttons) b.setAttribute("aria-pressed", String(b.textContent === p));
|
|
500
|
+
entry.update();
|
|
501
|
+
};
|
|
502
|
+
const current = new URL(url).searchParams.get("profile");
|
|
503
|
+
for (const p of profiles) {
|
|
504
|
+
const b = document.createElement("button"); b.type = "button"; b.textContent = p;
|
|
505
|
+
b.setAttribute("aria-pressed", String(p === current));
|
|
506
|
+
b.addEventListener("click", async () => select(p));
|
|
507
|
+
buttons.push(b); list.append(b);
|
|
480
508
|
}
|
|
481
|
-
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
}
|
|
509
|
+
document.getElementById("profiles").hidden = false;
|
|
510
|
+
})
|
|
511
|
+
.catch(() => {});
|
|
485
512
|
})();
|
|
486
513
|
</script>
|
|
487
514
|
</body></html>
|
|
@@ -1015,4 +1042,19 @@ export async function serve() {
|
|
|
1015
1042
|
} finally { reloading = false; }
|
|
1016
1043
|
}, 1000);
|
|
1017
1044
|
log("Connection credentials remain in local configuration; listener changes reload automatically");
|
|
1045
|
+
// Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
|
|
1046
|
+
// change, and every few minutes (routes can be edited by hand). Each finding is logged once.
|
|
1047
|
+
let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
|
|
1048
|
+
setInterval(async () => {
|
|
1049
|
+
if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
|
|
1050
|
+
checking = true;
|
|
1051
|
+
try {
|
|
1052
|
+
const listeners = JSON.parse(applied || "[]") as Listener[];
|
|
1053
|
+
const lines = await checkTailscaleServe(listeners);
|
|
1054
|
+
const report = lines.join("\n");
|
|
1055
|
+
if (report && report !== lastReport) for (const line of lines) log(line);
|
|
1056
|
+
lastReport = report; checkedFor = applied; lastCheck = Date.now();
|
|
1057
|
+
} catch (error) { log(`tailscale serve health check failed: ${error}`); }
|
|
1058
|
+
finally { checking = false; }
|
|
1059
|
+
}, 2000);
|
|
1018
1060
|
}
|
package/serve.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolvePlaywrightCli,
|
|
18
18
|
readTokenRegistry,
|
|
19
19
|
readChromeProfileCache,
|
|
20
|
+
checkTailscaleServe,
|
|
20
21
|
} from "./rechrome.ts";
|
|
21
22
|
|
|
22
23
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
@@ -411,12 +412,12 @@ export const LANDING_HEADERS = {
|
|
|
411
412
|
"Cache-Control": "no-store",
|
|
412
413
|
"Referrer-Policy": "no-referrer",
|
|
413
414
|
"X-Content-Type-Options": "nosniff",
|
|
414
|
-
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
415
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
415
416
|
};
|
|
416
417
|
|
|
417
418
|
/**
|
|
418
|
-
* The page a shared connection URL shows in a browser: install-and-connect
|
|
419
|
-
*
|
|
419
|
+
* The page a shared connection URL shows in a browser: two install-and-connect lines to copy,
|
|
420
|
+
* identical for every shell. It is static; the script reads the full URL (with its #key) from the address
|
|
420
421
|
* bar, shows it with the key masked, and copies it whole.
|
|
421
422
|
*/
|
|
422
423
|
export function landingPage(): string {
|
|
@@ -432,56 +433,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
|
|
|
432
433
|
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
|
433
434
|
p { margin: 0 0 16px; color: var(--muted); }
|
|
434
435
|
.row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
|
|
435
|
-
.label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
|
436
436
|
.cmd { display: flex; gap: 10px; align-items: flex-start; }
|
|
437
437
|
code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
|
|
438
438
|
button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
|
|
439
439
|
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
440
440
|
.warn { color: var(--warn); font-weight: 600; }
|
|
441
441
|
.small { font-size: .85rem; }
|
|
442
|
+
#plist { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
|
|
443
|
+
#plist button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
|
442
444
|
a { color: var(--accent); }
|
|
443
445
|
</style></head>
|
|
444
446
|
<body><main>
|
|
445
447
|
<h1>Connect to a shared Chrome</h1>
|
|
446
448
|
<p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
|
|
447
|
-
On your computer, inside the project folder that should use it, run
|
|
449
|
+
On your computer, inside the project folder that should use it, run these two lines
|
|
450
|
+
(the same in macOS/Linux terminals, PowerShell and cmd):</p>
|
|
448
451
|
<p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
|
|
449
452
|
<div id="cmds"></div>
|
|
453
|
+
<div id="profiles" hidden><p>Profiles this link can use (pick one to make it the default in the command above):</p><div id="plist"></div></div>
|
|
450
454
|
<p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
|
|
451
455
|
This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
|
|
452
456
|
</main>
|
|
453
457
|
<script>
|
|
454
458
|
(() => {
|
|
455
|
-
|
|
459
|
+
let url = location.href;
|
|
460
|
+
const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
|
|
456
461
|
if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
|
|
464
|
-
];
|
|
465
|
-
const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
|
|
462
|
+
// One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
|
|
463
|
+
// shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
|
|
464
|
+
// %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
|
|
465
|
+
const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
|
|
466
|
+
const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
|
|
467
|
+
const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
|
|
466
468
|
const root = document.getElementById("cmds");
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
469
|
+
const row = document.createElement("div"); row.className = "row";
|
|
470
|
+
const line = document.createElement("div"); line.className = "cmd";
|
|
471
|
+
const code = document.createElement("code");
|
|
472
|
+
const entry = { text: "", update() { this.text = build(url); code.textContent = mask(this.text); } };
|
|
473
|
+
entry.update();
|
|
474
|
+
const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
|
|
475
|
+
button.setAttribute("aria-label", "Copy the commands");
|
|
476
|
+
button.addEventListener("click", async () => {
|
|
477
|
+
const text = entry.text;
|
|
478
|
+
try { await navigator.clipboard.writeText(text); }
|
|
479
|
+
catch {
|
|
480
|
+
// Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
|
|
481
|
+
const area = document.createElement("textarea"); area.value = text; document.body.append(area);
|
|
482
|
+
area.select(); document.execCommand("copy"); area.remove();
|
|
483
|
+
}
|
|
484
|
+
button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
|
485
|
+
});
|
|
486
|
+
line.append(code, button); row.append(line); root.append(row);
|
|
487
|
+
// The key never leaves the browser except to this listener's own /ping, which lists what it
|
|
488
|
+
// allows. Picking a profile only rewrites ?profile= in the command (the key works for all).
|
|
489
|
+
if (!key || typeof fetch !== "function") return;
|
|
490
|
+
const path = location.href.split("#")[0].split("?")[0], base = path.endsWith("/") ? path : path + "/";
|
|
491
|
+
fetch(base + "ping", { headers: { Authorization: "Bearer " + decodeURIComponent(key) } })
|
|
492
|
+
.then(r => r.ok ? r.json() : null)
|
|
493
|
+
.then(body => {
|
|
494
|
+
const profiles = body && Array.isArray(body.profiles) ? body.profiles : [];
|
|
495
|
+
if (!profiles.length) return;
|
|
496
|
+
const list = document.getElementById("plist"), buttons = [];
|
|
497
|
+
const select = p => {
|
|
498
|
+
const u = new URL(url); u.searchParams.set("profile", p); url = u.href;
|
|
499
|
+
for (const b of buttons) b.setAttribute("aria-pressed", String(b.textContent === p));
|
|
500
|
+
entry.update();
|
|
501
|
+
};
|
|
502
|
+
const current = new URL(url).searchParams.get("profile");
|
|
503
|
+
for (const p of profiles) {
|
|
504
|
+
const b = document.createElement("button"); b.type = "button"; b.textContent = p;
|
|
505
|
+
b.setAttribute("aria-pressed", String(p === current));
|
|
506
|
+
b.addEventListener("click", async () => select(p));
|
|
507
|
+
buttons.push(b); list.append(b);
|
|
480
508
|
}
|
|
481
|
-
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
}
|
|
509
|
+
document.getElementById("profiles").hidden = false;
|
|
510
|
+
})
|
|
511
|
+
.catch(() => {});
|
|
485
512
|
})();
|
|
486
513
|
</script>
|
|
487
514
|
</body></html>
|
|
@@ -1015,4 +1042,19 @@ export async function serve() {
|
|
|
1015
1042
|
} finally { reloading = false; }
|
|
1016
1043
|
}, 1000);
|
|
1017
1044
|
log("Connection credentials remain in local configuration; listener changes reload automatically");
|
|
1045
|
+
// Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
|
|
1046
|
+
// change, and every few minutes (routes can be edited by hand). Each finding is logged once.
|
|
1047
|
+
let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
|
|
1048
|
+
setInterval(async () => {
|
|
1049
|
+
if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
|
|
1050
|
+
checking = true;
|
|
1051
|
+
try {
|
|
1052
|
+
const listeners = JSON.parse(applied || "[]") as Listener[];
|
|
1053
|
+
const lines = await checkTailscaleServe(listeners);
|
|
1054
|
+
const report = lines.join("\n");
|
|
1055
|
+
if (report && report !== lastReport) for (const line of lines) log(line);
|
|
1056
|
+
lastReport = report; checkedFor = applied; lastCheck = Date.now();
|
|
1057
|
+
} catch (error) { log(`tailscale serve health check failed: ${error}`); }
|
|
1058
|
+
finally { checking = false; }
|
|
1059
|
+
}, 2000);
|
|
1018
1060
|
}
|