rechrome 1.31.2 → 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 (3) hide show
  1. package/package.json +1 -1
  2. package/rechrome.js +117 -15
  3. package/rechrome.ts +117 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.31.2",
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)}`);
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)}`);