rechrome 1.38.0 → 1.38.2

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/listeners.js CHANGED
@@ -15,6 +15,7 @@ import { createHash, randomBytes } from "crypto";
15
15
  import { homedir } from "os";
16
16
  import { join } from "path";
17
17
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
18
+ var MAX_RETIRED_PORTS = 32;
18
19
  var LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
19
20
  var isLoopback = (host) => host === "::1" || /^127\./.test(host);
20
21
  var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
@@ -73,6 +74,8 @@ function validateListeners(value) {
73
74
  addresses.add(address);
74
75
  keys.add(l.key);
75
76
  }
77
+ if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)))
78
+ throw new Error("listeners.json retiredPorts must be a list of ports");
76
79
  return config;
77
80
  }
78
81
  async function readListeners() {
@@ -86,8 +89,20 @@ async function readListeners() {
86
89
  }
87
90
  return validateListeners(JSON.parse(raw));
88
91
  }
92
+ function retirePorts(previous, next) {
93
+ const inUse = new Set(next.listeners.map((l) => l.port));
94
+ const freed = (previous?.listeners ?? []).map((l) => l.port).filter((p) => !inUse.has(p));
95
+ const merged = [...previous?.retiredPorts ?? [], ...next.retiredPorts ?? [], ...freed].filter((p) => !inUse.has(p));
96
+ return [...new Set(merged.reverse())].reverse().slice(-MAX_RETIRED_PORTS);
97
+ }
89
98
  async function writeListeners(config) {
90
99
  validateListeners(config);
100
+ const previous = await readListeners().catch(() => null);
101
+ const retired = retirePorts(previous, config);
102
+ if (retired.length)
103
+ config.retiredPorts = retired;
104
+ else
105
+ delete config.retiredPorts;
91
106
  await mkdir(join(homedir(), ".rechrome"), { recursive: true });
92
107
  const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
93
108
  await writeFile(temp, JSON.stringify(config, null, 2) + `
@@ -219,6 +234,7 @@ export {
219
234
  profileOutputPrefix,
220
235
  readListeners,
221
236
  resolveAllowedProfile,
237
+ retirePorts,
222
238
  rotateListenerKey,
223
239
  serviceUrl,
224
240
  setPublicUrl,
package/listeners.ts CHANGED
@@ -7,7 +7,10 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
7
7
 
8
8
  // publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
9
9
  export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy };
10
- export type ListenerConfig = { version: 1; listeners: Listener[] };
10
+ // retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
11
+ // saved for a removed listener gets "connection refused", not some other listener's answer.
12
+ export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
13
+ const MAX_RETIRED_PORTS = 32;
11
14
  export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
12
15
  export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
13
16
  export const listenerAddress = (l: Listener) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
@@ -60,6 +63,8 @@ export function validateListeners(value: unknown): ListenerConfig {
60
63
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
61
64
  names.add(l.name); addresses.add(address); keys.add(l.key);
62
65
  }
66
+ if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some(p => !Number.isInteger(p) || p < 1 || p > 65535)))
67
+ throw new Error("listeners.json retiredPorts must be a list of ports");
63
68
  return config;
64
69
  }
65
70
 
@@ -70,8 +75,19 @@ export async function readListeners(): Promise<ListenerConfig | null> {
70
75
  return validateListeners(JSON.parse(raw));
71
76
  }
72
77
 
78
+ /** `previous`'s retired ports plus the ports its listeners used that `next` no longer does. */
79
+ export function retirePorts(previous: ListenerConfig | null, next: ListenerConfig): number[] {
80
+ const inUse = new Set(next.listeners.map(l => l.port));
81
+ const freed = (previous?.listeners ?? []).map(l => l.port).filter(p => !inUse.has(p));
82
+ const merged = [...(previous?.retiredPorts ?? []), ...(next.retiredPorts ?? []), ...freed].filter(p => !inUse.has(p));
83
+ return [...new Set(merged.reverse())].reverse().slice(-MAX_RETIRED_PORTS);
84
+ }
85
+
73
86
  export async function writeListeners(config: ListenerConfig): Promise<void> {
74
87
  validateListeners(config);
88
+ const previous = await readListeners().catch(() => null);
89
+ const retired = retirePorts(previous, config);
90
+ if (retired.length) config.retiredPorts = retired; else delete config.retiredPorts;
75
91
  await mkdir(join(homedir(), ".rechrome"), { recursive: true });
76
92
  const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
77
93
  await writeFile(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.38.0",
3
+ "version": "1.38.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.js";
3
- import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.js";
3
+ import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.js";
4
4
 
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
@@ -1493,13 +1493,69 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1493
1493
  ].filter(Boolean).join("\n"), "none", scoped);
1494
1494
  }
1495
1495
 
1496
- /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1497
- function freeListenerPort(listeners: Listener[]): number {
1498
- const used = new Set(listeners.map(l => l.port));
1499
- for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1500
- if (used.has(port)) continue;
1501
- try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1502
- }
1496
+ /**
1497
+ * Why a saved link to one of this machine's listeners stopped working, with the command that
1498
+ * replaces it; null when it still matches its listener or points at another machine.
1499
+ * A removed listener's port goes to the next one created (`freeListenerPort`), so a stale
1500
+ * link can reach a different listener: its path is not served there and its key is unknown.
1501
+ */
1502
+ export function staleLinkHint(url: string, listeners: Listener[], source?: string): string | null {
1503
+ let parsed: ReturnType<typeof parseUrl>;
1504
+ try { parsed = parseUrl(url); } catch { return null; }
1505
+ const loopback = (host: string) => isLoopback(host) || host === "localhost";
1506
+ const urlHost = parsed.host.replace(/^\[|\]$/g, "");
1507
+ // Direct: the link names a listener's own address. Proxied: it names a recorded public URL.
1508
+ const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || loopback(l.host) && loopback(urlHost)));
1509
+ const direct = !!at || loopback(urlHost);
1510
+ const viaProxy = (l: Listener) => {
1511
+ if (!l.publicUrl) return false;
1512
+ const pub = parseUrl(l.publicUrl);
1513
+ return pub.host === parsed.host && pub.port === parsed.port && pub.prefix === parsed.prefix;
1514
+ };
1515
+ const proxied = listeners.find(viaProxy);
1516
+ const owner = listeners.find(l => l.key === parsed.key);
1517
+ if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
1518
+ const profile = parsed.profileDirectory;
1519
+ const allows = (l: Listener) => l.profiles === "*" || !profile || l.profiles.includes(profile);
1520
+ // Through a proxy rech knows nothing about, only the key and the allowlist can be checked.
1521
+ const reachesOwner = owner && (direct ? owner === at && normalizePrefix(owner.prefix) === parsed.prefix : !proxied || owner === proxied);
1522
+ if (owner && reachesOwner && allows(owner)) return null;
1523
+ let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
1524
+ if (profile) {
1525
+ try {
1526
+ const name = owner && owner.profiles !== "*" && allows(owner) ? owner.name : chooseShareListener(profile, listeners);
1527
+ fix = `rech share ${JSON.stringify(profile)} --listener ${name}${direct ? " --local" : ""} --save`;
1528
+ } catch { /* no single listener shares it: the default creates a fresh link */ }
1529
+ }
1530
+ const lines = [!owner
1531
+ ? `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`
1532
+ : !reachesOwner
1533
+ ? `this link's listener "${owner.name}" moved: it now serves ${owner.publicUrl && !direct ? owner.publicUrl : `${listenerAddress(owner)}${normalizePrefix(owner.prefix)}`}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
1534
+ : `this link's listener "${owner.name}" no longer allows "${profile}" (it serves: ${(owner.profiles as string[]).join(", ")}).`];
1535
+ if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
1536
+ lines.push(`Replace it with a current link: ${fix}`);
1537
+ return lines.join("\n ");
1538
+ }
1539
+
1540
+ /** staleLinkHint against this machine's listeners.json; null when it can't be read. */
1541
+ async function localStaleLinkHint(url: string): Promise<string | null> {
1542
+ const config = await readListeners().catch(() => null);
1543
+ return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
1544
+ }
1545
+
1546
+ const loopbackPortIsFree = (port: number) => {
1547
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return true; } catch { return false; }
1548
+ };
1549
+
1550
+ /**
1551
+ * A loopback port no listener uses and nothing else is bound to, from the management port + 2.
1552
+ * A removed listener's port is skipped: reusing it would route that listener's saved links to
1553
+ * this one, a confusing wrong answer instead of a plain "connection refused".
1554
+ */
1555
+ export function freeListenerPort(config: Pick<ListenerConfig, "listeners" | "retiredPorts">, isFree = loopbackPortIsFree): number {
1556
+ const used = new Set(config.listeners.map(l => l.port)), retired = new Set(config.retiredPorts ?? []);
1557
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) if (!used.has(port) && !retired.has(port) && isFree(port)) return port;
1558
+ for (const port of config.retiredPorts ?? []) if (!used.has(port) && isFree(port)) return port; // oldest first, as a last resort
1503
1559
  throw new Error("No free port for the share-all listener; pass one with rech listener add");
1504
1560
  }
1505
1561
 
@@ -1521,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
1521
1577
  }
1522
1578
 
1523
1579
  /**
1524
- * `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
1525
- * sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
1526
- * it reuses a listener with exactly this set, or creates one with its own key.
1580
+ * `rech share a b c`: one link for these profiles, on one listener. With --listener it adds
1581
+ * them to that listener (e.g. one that already has a proxy route) and never removes any:
1582
+ * that is rech listener deny. Otherwise it reuses a listener with exactly this set, or
1583
+ * creates one with its own key.
1527
1584
  */
1528
1585
  async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1529
1586
  const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
@@ -1536,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
1536
1593
  const config = await requireListeners();
1537
1594
  const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
1538
1595
  const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
1539
- return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1596
+ return shareSet(keys, { ...opts, merge: !!opts.listener, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1540
1597
  }
1541
1598
 
1542
1599
  /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1543
- async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
1600
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean; merge?: boolean }): Promise<void> {
1544
1601
  const config = await requireListeners();
1545
1602
  const name = opts.listener ?? opts.defaultName;
1546
1603
  let listener = config.listeners.find(l => l.name === name);
@@ -1549,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1549
1606
  let changes = "";
1550
1607
  if (!listener) {
1551
1608
  if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1552
- listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1609
+ listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1553
1610
  config.listeners.push(listener);
1554
1611
  changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1555
1612
  } else {
1556
1613
  const before = listener.profiles as string[];
1614
+ // Merging (a listener named with --listener) only adds: profiles shared there for someone
1615
+ // else stay. Removing one is explicit: rech listener deny.
1616
+ if (opts.merge) snapshot = [...before, ...snapshot.filter(p => !before.includes(p))];
1557
1617
  const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1558
1618
  listener.profiles = snapshot;
1559
1619
  changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
@@ -1918,8 +1978,9 @@ async function callServe(
1918
1978
  process.exit(1);
1919
1979
  });
1920
1980
  if (res.status === 401) {
1921
- if (throwOnFailure) throw new Error("The daemon rejected its connection key. Run setup again to refresh it.");
1922
- console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
1981
+ const stale = await localStaleLinkHint(url);
1982
+ if (throwOnFailure) throw new Error(stale ? `The daemon rejected this link's key: ${stale}` : "The daemon rejected its connection key. Run setup again to refresh it.");
1983
+ console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]${stale ? `\n ${stale}` : ""}`);
1923
1984
  process.exit(1);
1924
1985
  }
1925
1986
  const text = await res.text();
@@ -1930,6 +1991,8 @@ async function callServe(
1930
1991
  let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1931
1992
  if (res.status === 502 || res.status === 503 || res.status === 504)
1932
1993
  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>\`.`;
1994
+ const stale = await localStaleLinkHint(url);
1995
+ if (stale) detail += `\n ${stale}`;
1933
1996
  if (throwOnFailure) throw new Error(detail);
1934
1997
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1935
1998
  process.exit(1);
@@ -3346,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3346
3409
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3347
3410
  await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
3348
3411
  await chmod(envPath, 0o600);
3412
+ await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
3349
3413
  return envPath;
3350
3414
  }
3351
3415
 
3416
+ /** Env files rech saved a RECHROME_URL into: paths only, never the URLs (they hold keys). */
3417
+ export const SAVED_LINKS_FILE = join(RECH_DIR, "saved-links.json");
3418
+
3419
+ async function readSavedLinks(): Promise<string[]> {
3420
+ const value = await file(SAVED_LINKS_FILE).json().catch(() => []);
3421
+ return Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
3422
+ }
3423
+
3424
+ async function recordSavedLink(envPath: string): Promise<void> {
3425
+ const path = resolve(envPath), known = await readSavedLinks();
3426
+ if (known.includes(path)) return;
3427
+ mkdirSync(dirname(SAVED_LINKS_FILE), { recursive: true });
3428
+ await writeFile(SAVED_LINKS_FILE, JSON.stringify([...known, path], null, 2) + "\n", { mode: 0o600 });
3429
+ }
3430
+
3431
+ /** The RECHROME_URL an env file sets, or null. */
3432
+ function savedUrlIn(envPath: string): string | null {
3433
+ try { return parseBunEnvFile(readFileSync(envPath, "utf8"))[ENV_KEY] || null; } catch { return null; }
3434
+ }
3435
+
3436
+ /**
3437
+ * One warning per saved link that worked with `before`'s listeners and is stale with `after`'s:
3438
+ * a listener change (rm, rotate-key, deny, setup moving a profile) that breaks a link rech
3439
+ * saved says so, with the fix, instead of the link failing later in some other project.
3440
+ * Links that were already stale stay quiet: this change didn't break them.
3441
+ */
3442
+ export function savedLinksBrokenBy(before: Listener[], after: Listener[], saved: { path: string; url: string }[]): string[] {
3443
+ return saved.flatMap(({ path, url }) => {
3444
+ if (staleLinkHint(url, before)) return [];
3445
+ const hint = staleLinkHint(url, after, path);
3446
+ return hint ? [`[rech] warning: a saved link stops working: ${hint}`] : [];
3447
+ });
3448
+ }
3449
+
3450
+ /** writeListeners, then warn about saved links the write broke (see savedLinksBrokenBy). */
3451
+ async function writeListeners(config: ListenerConfig): Promise<void> {
3452
+ const before = await readListeners().catch(() => null);
3453
+ await writeListenersFile(config);
3454
+ if (!before) return;
3455
+ const saved = (await readSavedLinks()).flatMap(path => { const url = savedUrlIn(path); return url ? [{ path, url }] : []; });
3456
+ for (const line of savedLinksBrokenBy(before.listeners, config.listeners, saved)) console.error(line);
3457
+ }
3458
+
3352
3459
  /**
3353
3460
  * What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
3354
3461
  * nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
@@ -3841,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
3841
3948
  const address = chooseListenAddress(await detectListenChoices(), selector);
3842
3949
  const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
3843
3950
  if (existing) return existing.name;
3844
- const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config.listeners), key: randomBytes(24).toString("base64url"), profiles };
3951
+ const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config), key: randomBytes(24).toString("base64url"), profiles };
3845
3952
  config.listeners.push(listener);
3846
3953
  await writeListeners(config);
3847
3954
  if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
@@ -4135,7 +4242,8 @@ async function status(): Promise<void> {
4135
4242
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
4136
4243
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
4137
4244
  // Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
4138
- const answered = pingResponse?.ok || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
4245
+ // A 200 that isn't the /ping JSON is another listener's catch-all: its port was reused.
4246
+ const answered = (pingResponse?.ok && !!pingBody) || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
4139
4247
  console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
4140
4248
  : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
4141
4249
  : `✗ ${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>'\``}`);
@@ -4147,6 +4255,8 @@ async function status(): Promise<void> {
4147
4255
  if (pingResponse?.status === 403 && pingBody?.error) console.log(`auth: ✗ ${pingBody.error}`);
4148
4256
  if (pingResponse?.status === 401)
4149
4257
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
4258
+ const stale = ping && !pingBody ? await localStaleLinkHint(url) : null;
4259
+ if (stale) console.log(`stale: ✗ ${stale}`);
4150
4260
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
4151
4261
  // ReferenceError that took down the whole of `rech status`, so the one command
4152
4262
  // that reports "the relay is wedged" died exactly when the relay was wedged,
package/rechrome.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { prepareStdinUpload, type UploadPayload } from "./scoped-upload.ts";
3
- import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.ts";
3
+ import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.ts";
4
4
 
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
@@ -1493,13 +1493,69 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1493
1493
  ].filter(Boolean).join("\n"), "none", scoped);
1494
1494
  }
1495
1495
 
1496
- /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1497
- function freeListenerPort(listeners: Listener[]): number {
1498
- const used = new Set(listeners.map(l => l.port));
1499
- for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1500
- if (used.has(port)) continue;
1501
- try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1502
- }
1496
+ /**
1497
+ * Why a saved link to one of this machine's listeners stopped working, with the command that
1498
+ * replaces it; null when it still matches its listener or points at another machine.
1499
+ * A removed listener's port goes to the next one created (`freeListenerPort`), so a stale
1500
+ * link can reach a different listener: its path is not served there and its key is unknown.
1501
+ */
1502
+ export function staleLinkHint(url: string, listeners: Listener[], source?: string): string | null {
1503
+ let parsed: ReturnType<typeof parseUrl>;
1504
+ try { parsed = parseUrl(url); } catch { return null; }
1505
+ const loopback = (host: string) => isLoopback(host) || host === "localhost";
1506
+ const urlHost = parsed.host.replace(/^\[|\]$/g, "");
1507
+ // Direct: the link names a listener's own address. Proxied: it names a recorded public URL.
1508
+ const at = listeners.find(l => l.port === parsed.port && (l.host === urlHost || loopback(l.host) && loopback(urlHost)));
1509
+ const direct = !!at || loopback(urlHost);
1510
+ const viaProxy = (l: Listener) => {
1511
+ if (!l.publicUrl) return false;
1512
+ const pub = parseUrl(l.publicUrl);
1513
+ return pub.host === parsed.host && pub.port === parsed.port && pub.prefix === parsed.prefix;
1514
+ };
1515
+ const proxied = listeners.find(viaProxy);
1516
+ const owner = listeners.find(l => l.key === parsed.key);
1517
+ if (!direct && !proxied && !owner) return null; // another machine's daemon: only its owner knows
1518
+ const profile = parsed.profileDirectory;
1519
+ const allows = (l: Listener) => l.profiles === "*" || !profile || l.profiles.includes(profile);
1520
+ // Through a proxy rech knows nothing about, only the key and the allowlist can be checked.
1521
+ const reachesOwner = owner && (direct ? owner === at && normalizePrefix(owner.prefix) === parsed.prefix : !proxied || owner === proxied);
1522
+ if (owner && reachesOwner && allows(owner)) return null;
1523
+ let fix = profile ? `rech share ${JSON.stringify(profile)} --save` : "rech share --save";
1524
+ if (profile) {
1525
+ try {
1526
+ const name = owner && owner.profiles !== "*" && allows(owner) ? owner.name : chooseShareListener(profile, listeners);
1527
+ fix = `rech share ${JSON.stringify(profile)} --listener ${name}${direct ? " --local" : ""} --save`;
1528
+ } catch { /* no single listener shares it: the default creates a fresh link */ }
1529
+ }
1530
+ const lines = [!owner
1531
+ ? `this link's key matches no listener on this machine: its listener was removed or its key rotated.${at ? ` ${listenerAddress(at)} belongs to listener "${at.name}" (${normalizePrefix(at.prefix)}), which this link is not for.` : ""}`
1532
+ : !reachesOwner
1533
+ ? `this link's listener "${owner.name}" moved: it now serves ${owner.publicUrl && !direct ? owner.publicUrl : `${listenerAddress(owner)}${normalizePrefix(owner.prefix)}`}, not ${parsed.host}:${parsed.port}${parsed.prefix}.`
1534
+ : `this link's listener "${owner.name}" no longer allows "${profile}" (it serves: ${(owner.profiles as string[]).join(", ")}).`];
1535
+ if (source && source !== "environment") lines.push(`The stale RECHROME_URL is saved in ${source}.`);
1536
+ lines.push(`Replace it with a current link: ${fix}`);
1537
+ return lines.join("\n ");
1538
+ }
1539
+
1540
+ /** staleLinkHint against this machine's listeners.json; null when it can't be read. */
1541
+ async function localStaleLinkHint(url: string): Promise<string | null> {
1542
+ const config = await readListeners().catch(() => null);
1543
+ return config ? staleLinkHint(url, config.listeners, url === process.env[ENV_KEY] ? rechromeUrlSource : undefined) : null;
1544
+ }
1545
+
1546
+ const loopbackPortIsFree = (port: number) => {
1547
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return true; } catch { return false; }
1548
+ };
1549
+
1550
+ /**
1551
+ * A loopback port no listener uses and nothing else is bound to, from the management port + 2.
1552
+ * A removed listener's port is skipped: reusing it would route that listener's saved links to
1553
+ * this one, a confusing wrong answer instead of a plain "connection refused".
1554
+ */
1555
+ export function freeListenerPort(config: Pick<ListenerConfig, "listeners" | "retiredPorts">, isFree = loopbackPortIsFree): number {
1556
+ const used = new Set(config.listeners.map(l => l.port)), retired = new Set(config.retiredPorts ?? []);
1557
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) if (!used.has(port) && !retired.has(port) && isFree(port)) return port;
1558
+ for (const port of config.retiredPorts ?? []) if (!used.has(port) && isFree(port)) return port; // oldest first, as a last resort
1503
1559
  throw new Error("No free port for the share-all listener; pass one with rech listener add");
1504
1560
  }
1505
1561
 
@@ -1521,9 +1577,10 @@ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): str
1521
1577
  }
1522
1578
 
1523
1579
  /**
1524
- * `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
1525
- * sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
1526
- * it reuses a listener with exactly this set, or creates one with its own key.
1580
+ * `rech share a b c`: one link for these profiles, on one listener. With --listener it adds
1581
+ * them to that listener (e.g. one that already has a proxy route) and never removes any:
1582
+ * that is rech listener deny. Otherwise it reuses a listener with exactly this set, or
1583
+ * creates one with its own key.
1527
1584
  */
1528
1585
  async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1529
1586
  const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
@@ -1536,11 +1593,11 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
1536
1593
  const config = await requireListeners();
1537
1594
  const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
1538
1595
  const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
1539
- return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1596
+ return shareSet(keys, { ...opts, merge: !!opts.listener, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1540
1597
  }
1541
1598
 
1542
1599
  /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1543
- async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
1600
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean; merge?: boolean }): Promise<void> {
1544
1601
  const config = await requireListeners();
1545
1602
  const name = opts.listener ?? opts.defaultName;
1546
1603
  let listener = config.listeners.find(l => l.name === name);
@@ -1549,11 +1606,14 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1549
1606
  let changes = "";
1550
1607
  if (!listener) {
1551
1608
  if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1552
- listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1609
+ listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1553
1610
  config.listeners.push(listener);
1554
1611
  changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1555
1612
  } else {
1556
1613
  const before = listener.profiles as string[];
1614
+ // Merging (a listener named with --listener) only adds: profiles shared there for someone
1615
+ // else stay. Removing one is explicit: rech listener deny.
1616
+ if (opts.merge) snapshot = [...before, ...snapshot.filter(p => !before.includes(p))];
1557
1617
  const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1558
1618
  listener.profiles = snapshot;
1559
1619
  changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
@@ -1918,8 +1978,9 @@ async function callServe(
1918
1978
  process.exit(1);
1919
1979
  });
1920
1980
  if (res.status === 401) {
1921
- if (throwOnFailure) throw new Error("The daemon rejected its connection key. Run setup again to refresh it.");
1922
- console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
1981
+ const stale = await localStaleLinkHint(url);
1982
+ if (throwOnFailure) throw new Error(stale ? `The daemon rejected this link's key: ${stale}` : "The daemon rejected its connection key. Run setup again to refresh it.");
1983
+ console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]${stale ? `\n ${stale}` : ""}`);
1923
1984
  process.exit(1);
1924
1985
  }
1925
1986
  const text = await res.text();
@@ -1930,6 +1991,8 @@ async function callServe(
1930
1991
  let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1931
1992
  if (res.status === 502 || res.status === 503 || res.status === 504)
1932
1993
  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>\`.`;
1994
+ const stale = await localStaleLinkHint(url);
1995
+ if (stale) detail += `\n ${stale}`;
1933
1996
  if (throwOnFailure) throw new Error(detail);
1934
1997
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1935
1998
  process.exit(1);
@@ -3346,9 +3409,53 @@ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3346
3409
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3347
3410
  await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
3348
3411
  await chmod(envPath, 0o600);
3412
+ await recordSavedLink(envPath).catch(() => {}); // only bookkeeping for later warnings
3349
3413
  return envPath;
3350
3414
  }
3351
3415
 
3416
+ /** Env files rech saved a RECHROME_URL into: paths only, never the URLs (they hold keys). */
3417
+ export const SAVED_LINKS_FILE = join(RECH_DIR, "saved-links.json");
3418
+
3419
+ async function readSavedLinks(): Promise<string[]> {
3420
+ const value = await file(SAVED_LINKS_FILE).json().catch(() => []);
3421
+ return Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
3422
+ }
3423
+
3424
+ async function recordSavedLink(envPath: string): Promise<void> {
3425
+ const path = resolve(envPath), known = await readSavedLinks();
3426
+ if (known.includes(path)) return;
3427
+ mkdirSync(dirname(SAVED_LINKS_FILE), { recursive: true });
3428
+ await writeFile(SAVED_LINKS_FILE, JSON.stringify([...known, path], null, 2) + "\n", { mode: 0o600 });
3429
+ }
3430
+
3431
+ /** The RECHROME_URL an env file sets, or null. */
3432
+ function savedUrlIn(envPath: string): string | null {
3433
+ try { return parseBunEnvFile(readFileSync(envPath, "utf8"))[ENV_KEY] || null; } catch { return null; }
3434
+ }
3435
+
3436
+ /**
3437
+ * One warning per saved link that worked with `before`'s listeners and is stale with `after`'s:
3438
+ * a listener change (rm, rotate-key, deny, setup moving a profile) that breaks a link rech
3439
+ * saved says so, with the fix, instead of the link failing later in some other project.
3440
+ * Links that were already stale stay quiet: this change didn't break them.
3441
+ */
3442
+ export function savedLinksBrokenBy(before: Listener[], after: Listener[], saved: { path: string; url: string }[]): string[] {
3443
+ return saved.flatMap(({ path, url }) => {
3444
+ if (staleLinkHint(url, before)) return [];
3445
+ const hint = staleLinkHint(url, after, path);
3446
+ return hint ? [`[rech] warning: a saved link stops working: ${hint}`] : [];
3447
+ });
3448
+ }
3449
+
3450
+ /** writeListeners, then warn about saved links the write broke (see savedLinksBrokenBy). */
3451
+ async function writeListeners(config: ListenerConfig): Promise<void> {
3452
+ const before = await readListeners().catch(() => null);
3453
+ await writeListenersFile(config);
3454
+ if (!before) return;
3455
+ const saved = (await readSavedLinks()).flatMap(path => { const url = savedUrlIn(path); return url ? [{ path, url }] : []; });
3456
+ for (const line of savedLinksBrokenBy(before.listeners, config.listeners, saved)) console.error(line);
3457
+ }
3458
+
3352
3459
  /**
3353
3460
  * What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
3354
3461
  * nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
@@ -3841,7 +3948,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
3841
3948
  const address = chooseListenAddress(await detectListenChoices(), selector);
3842
3949
  const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
3843
3950
  if (existing) return existing.name;
3844
- const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config.listeners), key: randomBytes(24).toString("base64url"), profiles };
3951
+ const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config), key: randomBytes(24).toString("base64url"), profiles };
3845
3952
  config.listeners.push(listener);
3846
3953
  await writeListeners(config);
3847
3954
  if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
@@ -4135,7 +4242,8 @@ async function status(): Promise<void> {
4135
4242
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
4136
4243
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
4137
4244
  // Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
4138
- const answered = pingResponse?.ok || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
4245
+ // A 200 that isn't the /ping JSON is another listener's catch-all: its port was reused.
4246
+ const answered = (pingResponse?.ok && !!pingBody) || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
4139
4247
  console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
4140
4248
  : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
4141
4249
  : `✗ ${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>'\``}`);
@@ -4147,6 +4255,8 @@ async function status(): Promise<void> {
4147
4255
  if (pingResponse?.status === 403 && pingBody?.error) console.log(`auth: ✗ ${pingBody.error}`);
4148
4256
  if (pingResponse?.status === 401)
4149
4257
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
4258
+ const stale = ping && !pingBody ? await localStaleLinkHint(url) : null;
4259
+ if (stale) console.log(`stale: ✗ ${stale}`);
4150
4260
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
4151
4261
  // ReferenceError that took down the whole of `rech status`, so the one command
4152
4262
  // that reports "the relay is wedged" died exactly when the relay was wedged,
package/serve.js CHANGED
@@ -800,7 +800,9 @@ export async function serve() {
800
800
  // #fragment, which browsers never send, so the page is static and the key never reaches here.
801
801
  if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
802
802
  return new Response(landingPage(), { headers: LANDING_HEADERS });
803
- if (reqUrl.pathname !== "/run") return new Response("rech server\n");
803
+ // Unknown paths are a 404, not the banner: a stale link (its listener removed, the port
804
+ // reused by another with a different prefix) must fail loudly, not look like a live daemon.
805
+ if (reqUrl.pathname !== "/run") return reqUrl.pathname === "/" ? new Response("rech server\n") : new Response("rech server: nothing at this path\n", { status: 404 });
804
806
  const denied = authCheck(req, key);
805
807
  if (denied) return denied;
806
808
  markActivity(); // a real command: this serve is not idle
package/serve.ts CHANGED
@@ -800,7 +800,9 @@ export async function serve() {
800
800
  // #fragment, which browsers never send, so the page is static and the key never reaches here.
801
801
  if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
802
802
  return new Response(landingPage(), { headers: LANDING_HEADERS });
803
- if (reqUrl.pathname !== "/run") return new Response("rech server\n");
803
+ // Unknown paths are a 404, not the banner: a stale link (its listener removed, the port
804
+ // reused by another with a different prefix) must fail loudly, not look like a live daemon.
805
+ if (reqUrl.pathname !== "/run") return reqUrl.pathname === "/" ? new Response("rech server\n") : new Response("rech server: nothing at this path\n", { status: 404 });
804
806
  const denied = authCheck(req, key);
805
807
  if (denied) return denied;
806
808
  markActivity(); // a real command: this serve is not idle