rechrome 1.31.1 → 1.31.3

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.
Files changed (5) hide show
  1. package/package.json +1 -1
  2. package/rechrome.js +136 -17
  3. package/rechrome.ts +136 -17
  4. package/serve.js +33 -15
  5. package/serve.ts +33 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.31.1",
3
+ "version": "1.31.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -362,7 +362,18 @@ export async function detectListenChoices(): Promise<ListenChoice[]> {
362
362
  return buildListenChoices(networkInterfaces(), ips);
363
363
  }
364
364
 
365
- export type TailscaleServe = { dnsName: string | null; routeUrl: string | null };
365
+ /**
366
+ * `funnel`: Funnel (public internet) is on for the host that serves — or would serve — this
367
+ * node's routes, so a route there is not tailnet-only. `mounts`: every Serve path in use.
368
+ */
369
+ export type TailscaleServe = { dnsName: string | null; routeUrl: string | null; funnel: boolean; mounts: string[] };
370
+
371
+ export function serveMountsAndFunnel(serveStatus: unknown, host: string | null): { mounts: string[]; funnel: boolean } {
372
+ const status = serveStatus as { Web?: Record<string, { Handlers?: Record<string, unknown> }>; AllowFunnel?: Record<string, boolean> } | null;
373
+ const mounts = Object.values(status?.Web ?? {}).flatMap(config => Object.keys(config.Handlers ?? {}).map(path => path.replace(/\/+$/, "") || "/"));
374
+ const funnel = Object.entries(status?.AllowFunnel ?? {}).some(([hostPort, on]) => on && (!host || hostPort.split(/:(?=\d+$)/)[0] === host));
375
+ return { mounts: [...new Set(mounts)], funnel };
376
+ }
366
377
 
367
378
  /**
368
379
  * Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
@@ -393,7 +404,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
393
404
  let serveStatus: unknown = null;
394
405
  try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
395
406
  try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
396
- return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
407
+ const routeUrl = findTailscaleServeRoute(dnsName, serveStatus, port, prefix);
408
+ return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
397
409
  }
398
410
 
399
411
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
@@ -1091,7 +1103,7 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1091
1103
  throw new ShareListenerError([
1092
1104
  `"${profile}" isn't shared on any listener yet.`,
1093
1105
  scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
1094
- ` Or create one: rech listener add share --listen local --prefix=rechrome --port 13776 --profile ${JSON.stringify(profile)}`,
1106
+ ` ${scoped.length ? "Or give" : "Give"} it its own link: rech share ${JSON.stringify(profile)} (in a terminal it asks who should connect)`,
1095
1107
  ].filter(Boolean).join("\n"), "none", scoped);
1096
1108
  }
1097
1109
 
@@ -1142,7 +1154,7 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
1142
1154
  }
1143
1155
 
1144
1156
  /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1145
- async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string }): Promise<void> {
1157
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
1146
1158
  const config = await requireListeners();
1147
1159
  const name = opts.listener ?? opts.defaultName;
1148
1160
  let listener = config.listeners.find(l => l.name === name);
@@ -1151,7 +1163,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1151
1163
  let changes = "";
1152
1164
  if (!listener) {
1153
1165
  if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1154
- listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1166
+ listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1155
1167
  config.listeners.push(listener);
1156
1168
  changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1157
1169
  } else {
@@ -1161,24 +1173,100 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1161
1173
  changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
1162
1174
  }
1163
1175
  await writeListeners(config);
1164
- console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1176
+ if (changes.startsWith("created") && snapshot.length === 1) console.error(`New link "${name}" for ${snapshot[0]}.`);
1177
+ else console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1165
1178
  if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
1166
1179
  else if (changes !== "no changes" && !changes.startsWith("created")) console.error(`[rech] links already given out for "${name}" now reach exactly these profiles too (same key). For a fresh key: rech listener rotate-key ${name}`);
1167
1180
  // The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
1168
- const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
1181
+ // A one-profile link names its profile, so the other machine needs no --profile.
1182
+ const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
1183
+ const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
1169
1184
  let ready = false;
1170
1185
  for (let i = 0; i < 20 && !ready; i++) {
1171
1186
  ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
1172
1187
  if (!ready) await Bun.sleep(250);
1173
1188
  }
1174
1189
  if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
1190
+ if (opts.tailscaleServe && !listener.publicUrl) {
1191
+ const publicUrl = await exposeWithTailscaleServe(listener);
1192
+ if (publicUrl) {
1193
+ listener.publicUrl = publicUrl;
1194
+ await writeListeners(config);
1195
+ }
1196
+ }
1175
1197
  const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
1176
- if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1198
+ if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1177
1199
  console.log(uri);
1178
- console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
1200
+ console.error(snapshot.length === 1 ? `On the other machine: rech connect '<this link>'` : `On the other machine: rech connect '<this link>', then rech --profile <name> open <url>`);
1179
1201
  if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1180
1202
  }
1181
1203
 
1204
+ /** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
1205
+ export function profileSlug(profile: string): string {
1206
+ const slug = profile.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
1207
+ return slug || "profile";
1208
+ }
1209
+
1210
+ /**
1211
+ * `base`, or `base-2`, `base-3`… — the first name whose listener and path are free. Never a
1212
+ * reserved name (`share --all` and `share a b` reuse theirs and rewrite their allowlists, so a
1213
+ * one-profile link under such a name would later grant its key more profiles), and never a
1214
+ * mount a proxy already serves (Tailscale Serve would replace that route).
1215
+ */
1216
+ export function uniqueShareName(base: string, listeners: Listener[], servedMounts: string[] = []): string {
1217
+ const reserved = (name: string) => name === "local" || name === "share-all" || /^share-[0-9a-f]{6}$/.test(name);
1218
+ const taken = (name: string) => reserved(name) || servedMounts.includes(`/rechrome/${name}`)
1219
+ || listeners.some(l => l.name === name || normalizePrefix(l.prefix) === `/rechrome/${name}/`);
1220
+ for (let i = 1; ; i++) { const name = i === 1 ? base : `${base}-${i}`; if (!taken(name)) return name; }
1221
+ }
1222
+
1223
+ /** Route a loopback listener through Tailscale Serve (HTTPS, tailnet only); returns its public URL. */
1224
+ async function exposeWithTailscaleServe(listener: Listener): Promise<string | null> {
1225
+ const prefix = normalizePrefix(listener.prefix);
1226
+ const existing = await detectTailscaleServe(listener.port, prefix);
1227
+ if (existing.funnel) {
1228
+ console.error("Tailscale Funnel is on for this machine, so a Serve route here would be public, not tailnet-only. Not exposing it.\n Turn Funnel off (tailscale funnel reset), then rech share again.");
1229
+ return null;
1230
+ }
1231
+ if (existing.routeUrl) return existing.routeUrl;
1232
+ const binary = tailscaleBinary();
1233
+ if (!binary) return null;
1234
+ const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1235
+ const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1236
+ console.error(`Running: tailscale ${args.join(" ")}`);
1237
+ const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1238
+ if (await proc.exited !== 0) {
1239
+ console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1240
+ return null;
1241
+ }
1242
+ return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
1243
+ }
1244
+
1245
+ /**
1246
+ * Give one profile its own link. In a terminal, ask how the other machines will reach it
1247
+ * (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
1248
+ * loopback link, like `rech share a b`.
1249
+ */
1250
+ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boolean }): Promise<void> {
1251
+ const served = tailscaleBinary() ? (await detectTailscaleServe(0, "/")).mounts : [];
1252
+ const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
1253
+ let host = "127.0.0.1", tailscaleServe = false;
1254
+ if (isInteractive()) {
1255
+ const serve = !!tailscaleBinary();
1256
+ // With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
1257
+ const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
1258
+ const how = await promptChoice(`How will your other machines reach it?`, [
1259
+ ...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
1260
+ ...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
1261
+ { label: "My own reverse proxy", value: "127.0.0.1" },
1262
+ ], 0);
1263
+ if (!how) throw new Error("Cancelled; nothing shared.");
1264
+ tailscaleServe = how === "tailscale-serve";
1265
+ host = tailscaleServe ? "127.0.0.1" : how;
1266
+ }
1267
+ return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
1268
+ }
1269
+
1182
1270
  async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1183
1271
  if (opts.all) {
1184
1272
  if (selector) throw new Error("Pass a profile or --all, not both.");
@@ -1225,17 +1313,24 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1225
1313
  if (!listener && config) {
1226
1314
  try { listener = chooseShareListener(profile, listeners); }
1227
1315
  catch (error) {
1228
- if (!interactive || !(error instanceof ShareListenerError)) throw error;
1316
+ if (!(error instanceof ShareListenerError)) throw error;
1317
+ // Not shared yet: give it its own link rather than stopping with instructions.
1318
+ if (!interactive) { if (error.kind === "none") return shareNewLink(profile, opts); throw error; }
1229
1319
  const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
1230
1320
  if (error.kind === "several") {
1231
1321
  listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
1232
1322
  if (!listener) throw cancelled();
1233
1323
  } else {
1234
- if (!error.listeners.length) throw error; // nothing to allow it on: the error names `rech listener add`
1235
- // Allowing is a config change, so the default is to cancel.
1236
- const target = await promptChoice(`"${profile}" isn't shared on any listener yet. Allow it on:`,
1237
- [...error.listeners.map(l => ({ label: describe(l), value: l.name as string | null })), { label: "Cancel (change nothing)", value: null }], error.listeners.length);
1324
+ // Keep going until there is a URL (or the user cancels): a new link of its own is the
1325
+ // default, since allowing it on an existing listener hands it to that listener's key holders.
1326
+ const NEW_LINK = "\0new";
1327
+ const target = error.listeners.length ? await promptChoice(`${profile} isn't shared yet.`, [
1328
+ { label: "Create a new link for it", value: NEW_LINK as string | null },
1329
+ ...error.listeners.map(l => ({ label: `Add it to link "${l.name}" (its existing key)`, value: l.name as string | null })),
1330
+ { label: "Cancel", value: null },
1331
+ ], 0) : NEW_LINK;
1238
1332
  if (!target) throw cancelled();
1333
+ if (target === NEW_LINK) return shareNewLink(profile, opts);
1239
1334
  allowProfiles(config, target, [profile]);
1240
1335
  await writeListeners(config);
1241
1336
  console.error(`[rech] allowed "${profile}" on ${target}`);
@@ -1250,7 +1345,14 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1250
1345
  console.error(`[rech] note: this link's key works for every profile on listener "${chosen.name}" (${chosen.profiles.join(", ")}); ?profile= only picks the default. For a one-profile link, give that profile its own listener.`);
1251
1346
  let uri = profileConnectionUri(profile, url, listeners, listener);
1252
1347
  // Prefer where a proxy exposes the listener, when it has been recorded.
1253
- const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
1348
+ // A link whose proxy was set up by hand since (e.g. after `tailscale serve` failed here):
1349
+ // record the tailnet-only route so this and later shares print it.
1350
+ const pending = listeners.find(l => l.key === parseUrl(uri).key);
1351
+ if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && tailscaleBinary()) {
1352
+ const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
1353
+ if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
1354
+ }
1355
+ const publicUrl = pending?.publicUrl;
1254
1356
  if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
1255
1357
  console.log(uri);
1256
1358
  if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
@@ -1922,14 +2024,31 @@ async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<str
1922
2024
  daemonManager();
1923
2025
  }
1924
2026
 
2027
+ /**
2028
+ * Rewriting RECHROME_URL for a new daemon key must not drop the saved `?profile=`: without it
2029
+ * every client loses its Chrome profile (and a setup aborted before its final save step used
2030
+ * to leave it that way).
2031
+ */
2032
+ export function keepProfileParam(next: string, previous?: string): string {
2033
+ try {
2034
+ const url = new URL(next);
2035
+ // A .env.local value may be quoted: RECHROME_URL="http://…/?profile=work".
2036
+ const raw = previous?.trim().replace(/^(['"])(.*)\1$/, "$2");
2037
+ const profile = raw ? new URL(raw).searchParams.get("profile") : null;
2038
+ if (profile && !url.searchParams.has("profile")) url.searchParams.set("profile", profile);
2039
+ return url.toString();
2040
+ } catch { return next; }
2041
+ }
2042
+
1925
2043
  export async function daemonInstall(serveUrl: string): Promise<void> {
1926
2044
  // Resolve the manager first so a missing dependency fails before config is mutated.
1927
2045
  const mgr = daemonManager();
1928
2046
  // Persist the URL for future clients without an explicit environment override.
1929
2047
  // The daemon's explicit environment takes precedence over this saved default.
1930
2048
  const envRaw = await file(globalEnvFile).text().catch(() => "");
1931
- const filtered = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
1932
- await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${serveUrl}`, ""].join("\n"));
2049
+ const lines = envRaw.trimEnd().split("\n");
2050
+ const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
2051
+ await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))}`, ""].join("\n"));
1933
2052
 
1934
2053
  const home = HOME;
1935
2054
  const bunBin = Bun.which("bun") ?? process.execPath;
package/rechrome.ts CHANGED
@@ -362,7 +362,18 @@ export async function detectListenChoices(): Promise<ListenChoice[]> {
362
362
  return buildListenChoices(networkInterfaces(), ips);
363
363
  }
364
364
 
365
- export type TailscaleServe = { dnsName: string | null; routeUrl: string | null };
365
+ /**
366
+ * `funnel`: Funnel (public internet) is on for the host that serves — or would serve — this
367
+ * node's routes, so a route there is not tailnet-only. `mounts`: every Serve path in use.
368
+ */
369
+ export type TailscaleServe = { dnsName: string | null; routeUrl: string | null; funnel: boolean; mounts: string[] };
370
+
371
+ export function serveMountsAndFunnel(serveStatus: unknown, host: string | null): { mounts: string[]; funnel: boolean } {
372
+ const status = serveStatus as { Web?: Record<string, { Handlers?: Record<string, unknown> }>; AllowFunnel?: Record<string, boolean> } | null;
373
+ const mounts = Object.values(status?.Web ?? {}).flatMap(config => Object.keys(config.Handlers ?? {}).map(path => path.replace(/\/+$/, "") || "/"));
374
+ const funnel = Object.entries(status?.AllowFunnel ?? {}).some(([hostPort, on]) => on && (!host || hostPort.split(/:(?=\d+$)/)[0] === host));
375
+ return { mounts: [...new Set(mounts)], funnel };
376
+ }
366
377
 
367
378
  /**
368
379
  * Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
@@ -393,7 +404,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
393
404
  let serveStatus: unknown = null;
394
405
  try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
395
406
  try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
396
- return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
407
+ const routeUrl = findTailscaleServeRoute(dnsName, serveStatus, port, prefix);
408
+ return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
397
409
  }
398
410
 
399
411
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
@@ -1091,7 +1103,7 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1091
1103
  throw new ShareListenerError([
1092
1104
  `"${profile}" isn't shared on any listener yet.`,
1093
1105
  scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
1094
- ` Or create one: rech listener add share --listen local --prefix=rechrome --port 13776 --profile ${JSON.stringify(profile)}`,
1106
+ ` ${scoped.length ? "Or give" : "Give"} it its own link: rech share ${JSON.stringify(profile)} (in a terminal it asks who should connect)`,
1095
1107
  ].filter(Boolean).join("\n"), "none", scoped);
1096
1108
  }
1097
1109
 
@@ -1142,7 +1154,7 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
1142
1154
  }
1143
1155
 
1144
1156
  /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1145
- async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string }): Promise<void> {
1157
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
1146
1158
  const config = await requireListeners();
1147
1159
  const name = opts.listener ?? opts.defaultName;
1148
1160
  let listener = config.listeners.find(l => l.name === name);
@@ -1151,7 +1163,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1151
1163
  let changes = "";
1152
1164
  if (!listener) {
1153
1165
  if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1154
- listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1166
+ listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1155
1167
  config.listeners.push(listener);
1156
1168
  changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1157
1169
  } else {
@@ -1161,24 +1173,100 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1161
1173
  changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
1162
1174
  }
1163
1175
  await writeListeners(config);
1164
- console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1176
+ if (changes.startsWith("created") && snapshot.length === 1) console.error(`New link "${name}" for ${snapshot[0]}.`);
1177
+ else console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1165
1178
  if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
1166
1179
  else if (changes !== "no changes" && !changes.startsWith("created")) console.error(`[rech] links already given out for "${name}" now reach exactly these profiles too (same key). For a fresh key: rech listener rotate-key ${name}`);
1167
1180
  // The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
1168
- const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
1181
+ // A one-profile link names its profile, so the other machine needs no --profile.
1182
+ const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
1183
+ const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
1169
1184
  let ready = false;
1170
1185
  for (let i = 0; i < 20 && !ready; i++) {
1171
1186
  ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
1172
1187
  if (!ready) await Bun.sleep(250);
1173
1188
  }
1174
1189
  if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
1190
+ if (opts.tailscaleServe && !listener.publicUrl) {
1191
+ const publicUrl = await exposeWithTailscaleServe(listener);
1192
+ if (publicUrl) {
1193
+ listener.publicUrl = publicUrl;
1194
+ await writeListeners(config);
1195
+ }
1196
+ }
1175
1197
  const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
1176
- if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1198
+ if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1177
1199
  console.log(uri);
1178
- console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
1200
+ console.error(snapshot.length === 1 ? `On the other machine: rech connect '<this link>'` : `On the other machine: rech connect '<this link>', then rech --profile <name> open <url>`);
1179
1201
  if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1180
1202
  }
1181
1203
 
1204
+ /** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
1205
+ export function profileSlug(profile: string): string {
1206
+ const slug = profile.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
1207
+ return slug || "profile";
1208
+ }
1209
+
1210
+ /**
1211
+ * `base`, or `base-2`, `base-3`… — the first name whose listener and path are free. Never a
1212
+ * reserved name (`share --all` and `share a b` reuse theirs and rewrite their allowlists, so a
1213
+ * one-profile link under such a name would later grant its key more profiles), and never a
1214
+ * mount a proxy already serves (Tailscale Serve would replace that route).
1215
+ */
1216
+ export function uniqueShareName(base: string, listeners: Listener[], servedMounts: string[] = []): string {
1217
+ const reserved = (name: string) => name === "local" || name === "share-all" || /^share-[0-9a-f]{6}$/.test(name);
1218
+ const taken = (name: string) => reserved(name) || servedMounts.includes(`/rechrome/${name}`)
1219
+ || listeners.some(l => l.name === name || normalizePrefix(l.prefix) === `/rechrome/${name}/`);
1220
+ for (let i = 1; ; i++) { const name = i === 1 ? base : `${base}-${i}`; if (!taken(name)) return name; }
1221
+ }
1222
+
1223
+ /** Route a loopback listener through Tailscale Serve (HTTPS, tailnet only); returns its public URL. */
1224
+ async function exposeWithTailscaleServe(listener: Listener): Promise<string | null> {
1225
+ const prefix = normalizePrefix(listener.prefix);
1226
+ const existing = await detectTailscaleServe(listener.port, prefix);
1227
+ if (existing.funnel) {
1228
+ console.error("Tailscale Funnel is on for this machine, so a Serve route here would be public, not tailnet-only. Not exposing it.\n Turn Funnel off (tailscale funnel reset), then rech share again.");
1229
+ return null;
1230
+ }
1231
+ if (existing.routeUrl) return existing.routeUrl;
1232
+ const binary = tailscaleBinary();
1233
+ if (!binary) return null;
1234
+ const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1235
+ const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1236
+ console.error(`Running: tailscale ${args.join(" ")}`);
1237
+ const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1238
+ if (await proc.exited !== 0) {
1239
+ console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1240
+ return null;
1241
+ }
1242
+ return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
1243
+ }
1244
+
1245
+ /**
1246
+ * Give one profile its own link. In a terminal, ask how the other machines will reach it
1247
+ * (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
1248
+ * loopback link, like `rech share a b`.
1249
+ */
1250
+ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boolean }): Promise<void> {
1251
+ const served = tailscaleBinary() ? (await detectTailscaleServe(0, "/")).mounts : [];
1252
+ const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
1253
+ let host = "127.0.0.1", tailscaleServe = false;
1254
+ if (isInteractive()) {
1255
+ const serve = !!tailscaleBinary();
1256
+ // With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
1257
+ const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
1258
+ const how = await promptChoice(`How will your other machines reach it?`, [
1259
+ ...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
1260
+ ...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
1261
+ { label: "My own reverse proxy", value: "127.0.0.1" },
1262
+ ], 0);
1263
+ if (!how) throw new Error("Cancelled; nothing shared.");
1264
+ tailscaleServe = how === "tailscale-serve";
1265
+ host = tailscaleServe ? "127.0.0.1" : how;
1266
+ }
1267
+ return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
1268
+ }
1269
+
1182
1270
  async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1183
1271
  if (opts.all) {
1184
1272
  if (selector) throw new Error("Pass a profile or --all, not both.");
@@ -1225,17 +1313,24 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1225
1313
  if (!listener && config) {
1226
1314
  try { listener = chooseShareListener(profile, listeners); }
1227
1315
  catch (error) {
1228
- if (!interactive || !(error instanceof ShareListenerError)) throw error;
1316
+ if (!(error instanceof ShareListenerError)) throw error;
1317
+ // Not shared yet: give it its own link rather than stopping with instructions.
1318
+ if (!interactive) { if (error.kind === "none") return shareNewLink(profile, opts); throw error; }
1229
1319
  const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
1230
1320
  if (error.kind === "several") {
1231
1321
  listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
1232
1322
  if (!listener) throw cancelled();
1233
1323
  } else {
1234
- if (!error.listeners.length) throw error; // nothing to allow it on: the error names `rech listener add`
1235
- // Allowing is a config change, so the default is to cancel.
1236
- const target = await promptChoice(`"${profile}" isn't shared on any listener yet. Allow it on:`,
1237
- [...error.listeners.map(l => ({ label: describe(l), value: l.name as string | null })), { label: "Cancel (change nothing)", value: null }], error.listeners.length);
1324
+ // Keep going until there is a URL (or the user cancels): a new link of its own is the
1325
+ // default, since allowing it on an existing listener hands it to that listener's key holders.
1326
+ const NEW_LINK = "\0new";
1327
+ const target = error.listeners.length ? await promptChoice(`${profile} isn't shared yet.`, [
1328
+ { label: "Create a new link for it", value: NEW_LINK as string | null },
1329
+ ...error.listeners.map(l => ({ label: `Add it to link "${l.name}" (its existing key)`, value: l.name as string | null })),
1330
+ { label: "Cancel", value: null },
1331
+ ], 0) : NEW_LINK;
1238
1332
  if (!target) throw cancelled();
1333
+ if (target === NEW_LINK) return shareNewLink(profile, opts);
1239
1334
  allowProfiles(config, target, [profile]);
1240
1335
  await writeListeners(config);
1241
1336
  console.error(`[rech] allowed "${profile}" on ${target}`);
@@ -1250,7 +1345,14 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1250
1345
  console.error(`[rech] note: this link's key works for every profile on listener "${chosen.name}" (${chosen.profiles.join(", ")}); ?profile= only picks the default. For a one-profile link, give that profile its own listener.`);
1251
1346
  let uri = profileConnectionUri(profile, url, listeners, listener);
1252
1347
  // Prefer where a proxy exposes the listener, when it has been recorded.
1253
- const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
1348
+ // A link whose proxy was set up by hand since (e.g. after `tailscale serve` failed here):
1349
+ // record the tailnet-only route so this and later shares print it.
1350
+ const pending = listeners.find(l => l.key === parseUrl(uri).key);
1351
+ if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && tailscaleBinary()) {
1352
+ const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
1353
+ if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
1354
+ }
1355
+ const publicUrl = pending?.publicUrl;
1254
1356
  if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
1255
1357
  console.log(uri);
1256
1358
  if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
@@ -1922,14 +2024,31 @@ async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<str
1922
2024
  daemonManager();
1923
2025
  }
1924
2026
 
2027
+ /**
2028
+ * Rewriting RECHROME_URL for a new daemon key must not drop the saved `?profile=`: without it
2029
+ * every client loses its Chrome profile (and a setup aborted before its final save step used
2030
+ * to leave it that way).
2031
+ */
2032
+ export function keepProfileParam(next: string, previous?: string): string {
2033
+ try {
2034
+ const url = new URL(next);
2035
+ // A .env.local value may be quoted: RECHROME_URL="http://…/?profile=work".
2036
+ const raw = previous?.trim().replace(/^(['"])(.*)\1$/, "$2");
2037
+ const profile = raw ? new URL(raw).searchParams.get("profile") : null;
2038
+ if (profile && !url.searchParams.has("profile")) url.searchParams.set("profile", profile);
2039
+ return url.toString();
2040
+ } catch { return next; }
2041
+ }
2042
+
1925
2043
  export async function daemonInstall(serveUrl: string): Promise<void> {
1926
2044
  // Resolve the manager first so a missing dependency fails before config is mutated.
1927
2045
  const mgr = daemonManager();
1928
2046
  // Persist the URL for future clients without an explicit environment override.
1929
2047
  // The daemon's explicit environment takes precedence over this saved default.
1930
2048
  const envRaw = await file(globalEnvFile).text().catch(() => "");
1931
- const filtered = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
1932
- await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${serveUrl}`, ""].join("\n"));
2049
+ const lines = envRaw.trimEnd().split("\n");
2050
+ const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
2051
+ await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))}`, ""].join("\n"));
1933
2052
 
1934
2053
  const home = HOME;
1935
2054
  const bunBin = Bun.which("bun") ?? process.execPath;
package/serve.js CHANGED
@@ -691,6 +691,39 @@ export async function serve() {
691
691
  const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
692
692
  if (isOpenNoUrl) filteredArgs.push("about:blank");
693
693
 
694
+ // Merge passthrough env: server .env.local defaults, then client overrides
695
+ const passthroughEnv: Record<string, string | undefined> = {};
696
+ for (const key of PASSTHROUGH_ENV_KEYS) {
697
+ if (process.env[key]) passthroughEnv[key] = process.env[key];
698
+ }
699
+ Object.assign(passthroughEnv, clientEnv, profileEnv);
700
+
701
+ // Resolve profile name/email → directory name
702
+ if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
703
+ const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
704
+ if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
705
+ log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
706
+ passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
707
+ }
708
+
709
+ // Never fall back silently: without extension credentials (a registered profile) or a managed
710
+ // user-data dir, `open` would launch a separate headless browser with a throwaway profile —
711
+ // the command "succeeds" and nothing shows up in the user's Chrome.
712
+ const extensionMode = !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN);
713
+ // Checked before the existing-session shortcuts below: reusing a leftover headless session
714
+ // (bare `open` answered with its tab list, or `open <url>` rewritten to goto) is the same
715
+ // silent fallback.
716
+ if (isOpenCmd && !extensionMode && !passthroughEnv.PLAYWRIGHT_MCP_USER_DATA_DIR) {
717
+ const requested = passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
718
+ const registered = Object.keys(await readTokenRegistry().catch(() => ({})));
719
+ const known = registered.length ? `Registered here: ${registered.join(", ")}` : "No profiles are registered here yet: rech setup";
720
+ const stderr = requested
721
+ ? `[rech] profile "${requested}" has no extension token on this machine, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Register it: rech setup --profile ${JSON.stringify(requested)}\n ${known}\n`
722
+ : `[rech] no Chrome profile selected, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Pick one: rech --profile <email> open <url>, or add ?profile=<email> to RECHROME_URL\n ${known}\n`;
723
+ log(`refused open without a profile (session=${namespacedSession}, requested=${requested ?? "-"})`);
724
+ return Response.json({ status: 1, stdout: "", stderr, files: [] });
725
+ }
726
+
694
727
  // open against an existing session: bare `open` returns a tab-list hint; `open <url>`
695
728
  // converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
696
729
  // was dead — about:blank/<url> is already appended above, so length is always >=2.)
@@ -730,21 +763,6 @@ export async function serve() {
730
763
  }
731
764
  }
732
765
 
733
- // Merge passthrough env: server .env.local defaults, then client overrides
734
- const passthroughEnv: Record<string, string | undefined> = {};
735
- for (const key of PASSTHROUGH_ENV_KEYS) {
736
- if (process.env[key]) passthroughEnv[key] = process.env[key];
737
- }
738
- Object.assign(passthroughEnv, clientEnv, profileEnv);
739
-
740
- // Resolve profile name/email → directory name
741
- if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
742
- const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
743
- if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
744
- log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
745
- passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
746
- }
747
-
748
766
  const childEnv: Record<string, string | undefined> = {
749
767
  PATH: process.env.PATH,
750
768
  HOME: HOME,
package/serve.ts CHANGED
@@ -691,6 +691,39 @@ export async function serve() {
691
691
  const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
692
692
  if (isOpenNoUrl) filteredArgs.push("about:blank");
693
693
 
694
+ // Merge passthrough env: server .env.local defaults, then client overrides
695
+ const passthroughEnv: Record<string, string | undefined> = {};
696
+ for (const key of PASSTHROUGH_ENV_KEYS) {
697
+ if (process.env[key]) passthroughEnv[key] = process.env[key];
698
+ }
699
+ Object.assign(passthroughEnv, clientEnv, profileEnv);
700
+
701
+ // Resolve profile name/email → directory name
702
+ if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
703
+ const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
704
+ if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
705
+ log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
706
+ passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
707
+ }
708
+
709
+ // Never fall back silently: without extension credentials (a registered profile) or a managed
710
+ // user-data dir, `open` would launch a separate headless browser with a throwaway profile —
711
+ // the command "succeeds" and nothing shows up in the user's Chrome.
712
+ const extensionMode = !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN);
713
+ // Checked before the existing-session shortcuts below: reusing a leftover headless session
714
+ // (bare `open` answered with its tab list, or `open <url>` rewritten to goto) is the same
715
+ // silent fallback.
716
+ if (isOpenCmd && !extensionMode && !passthroughEnv.PLAYWRIGHT_MCP_USER_DATA_DIR) {
717
+ const requested = passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
718
+ const registered = Object.keys(await readTokenRegistry().catch(() => ({})));
719
+ const known = registered.length ? `Registered here: ${registered.join(", ")}` : "No profiles are registered here yet: rech setup";
720
+ const stderr = requested
721
+ ? `[rech] profile "${requested}" has no extension token on this machine, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Register it: rech setup --profile ${JSON.stringify(requested)}\n ${known}\n`
722
+ : `[rech] no Chrome profile selected, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Pick one: rech --profile <email> open <url>, or add ?profile=<email> to RECHROME_URL\n ${known}\n`;
723
+ log(`refused open without a profile (session=${namespacedSession}, requested=${requested ?? "-"})`);
724
+ return Response.json({ status: 1, stdout: "", stderr, files: [] });
725
+ }
726
+
694
727
  // open against an existing session: bare `open` returns a tab-list hint; `open <url>`
695
728
  // converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
696
729
  // was dead — about:blank/<url> is already appended above, so length is always >=2.)
@@ -730,21 +763,6 @@ export async function serve() {
730
763
  }
731
764
  }
732
765
 
733
- // Merge passthrough env: server .env.local defaults, then client overrides
734
- const passthroughEnv: Record<string, string | undefined> = {};
735
- for (const key of PASSTHROUGH_ENV_KEYS) {
736
- if (process.env[key]) passthroughEnv[key] = process.env[key];
737
- }
738
- Object.assign(passthroughEnv, clientEnv, profileEnv);
739
-
740
- // Resolve profile name/email → directory name
741
- if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
742
- const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
743
- if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
744
- log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
745
- passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
746
- }
747
-
748
766
  const childEnv: Record<string, string | undefined> = {
749
767
  PATH: process.env.PATH,
750
768
  HOME: HOME,