rechrome 1.44.0 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rechrome.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.js";
3
- import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.js";
3
+ import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, parseIceUrl, iceLabel, rtcListeners, shareRtcListeners, defaultRtcListener, DEFAULT_RTC, DEFAULT_RTC_NOTE, LEGACY_RTC, type IceServer, type Listener, type ListenerConfig, type RtcListener } from "./listeners.js";
4
4
 
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
@@ -10,7 +10,7 @@ import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAG
10
10
  import { createHash, randomBytes } from "crypto";
11
11
  import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
12
12
  import { hostname, homedir, networkInterfaces } from "os";
13
- import { isIPv4 } from "net";
13
+ import { isIP, isIPv4 } from "net";
14
14
  import { join, basename, dirname, resolve } from "path";
15
15
  import { pathToFileURL } from "url";
16
16
  import { createRequire } from "node:module";
@@ -1490,7 +1490,7 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1490
1490
  throw new ShareListenerError([
1491
1491
  `"${profile}" isn't shared on any listener yet.`,
1492
1492
  scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
1493
- ` ${scoped.length ? "Or give" : "Give"} it its own link: rech share ${JSON.stringify(profile)} (in a terminal it asks who should connect)`,
1493
+ ` ${scoped.length ? "Or share" : "Share"} it: rech share ${JSON.stringify(profile)} --lan|--tailscale|--webrtc (in a terminal, rech share asks)`,
1494
1494
  ].filter(Boolean).join("\n"), "none", scoped);
1495
1495
  }
1496
1496
 
@@ -1788,11 +1788,13 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1788
1788
  config = await readListeners(); // setup may have added a listener: never write back a stale copy
1789
1789
  return key;
1790
1790
  };
1791
+ let shownLinks = false;
1791
1792
  if (!selector) {
1792
1793
  // No profile given: show what is already shared, then ask, defaulting to the current one
1793
1794
  // (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1794
1795
  if (interactive && Object.keys(registry).length) {
1795
1796
  printExistingLinks(config?.listeners ?? [], opts.local);
1797
+ shownLinks = true;
1796
1798
  selector = await pickProfile("Share which profile?");
1797
1799
  }
1798
1800
  else {
@@ -1817,6 +1819,12 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1817
1819
  } else throw error;
1818
1820
  }
1819
1821
  }
1822
+ // On a host, in a terminal, with no listener named: tick the transports for a new share (the
1823
+ // links already handed out are listed first). --save/--local keep the listener-link flow.
1824
+ if (interactive && config && !listener && !opts.save && !opts.local) {
1825
+ if (!shownLinks) printExistingLinks(config.listeners, opts.local);
1826
+ return shareNew([profile], {});
1827
+ }
1820
1828
  const listeners = config?.listeners ?? [];
1821
1829
  // On a host, share through a scoped listener by default, never the management key.
1822
1830
  if (!listener && config) {
@@ -1824,7 +1832,8 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1824
1832
  catch (error) {
1825
1833
  if (!(error instanceof ShareListenerError)) throw error;
1826
1834
  // Not shared yet: give it its own link rather than stopping with instructions.
1827
- if (!interactive) { if (error.kind === "none") return shareNewLink(profile, opts); throw error; }
1835
+ // No default transport: without a terminal, say how to pick one.
1836
+ if (!interactive) { if (error.kind === "none") throw noTransportError(profile); throw error; }
1828
1837
  const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
1829
1838
  if (error.kind === "several") {
1830
1839
  listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
@@ -3320,14 +3329,86 @@ function printTable(rows: string[][]): void {
3320
3329
  }
3321
3330
 
3322
3331
  async function listListeners(): Promise<void> {
3332
+ const config = await requireListeners();
3323
3333
  const rows = [["NAME", "ADDRESS", "PROFILES", "PUBLIC URL"]];
3324
- for (const l of (await requireListeners()).listeners)
3334
+ for (const l of config.listeners)
3325
3335
  rows.push([l.name, `${listenerAddress(l)}${normalizePrefix(l.prefix)}`, l.profiles === "*" ? "(all — local management)" : l.profiles.join(", "), l.publicUrl ?? "-"]);
3326
3336
  printTable(rows);
3337
+ console.log("");
3338
+ printTable(rtcListenerRows(config));
3339
+ }
3340
+
3341
+ /** `rech listener ls`'s WebRTC table. STUN/TURN servers show their username, never the password. */
3342
+ export function rtcListenerRows(config: ListenerConfig | null): string[][] {
3343
+ const rows = [["WEBRTC", "SIGNAL SERVER", "STUN/TURN"]];
3344
+ for (const l of rtcListeners(config))
3345
+ rows.push([l.name, l.signal, l.builtin ? `public STUN (built in; ${DEFAULT_RTC_NOTE.split(".")[0]})` : l.ice?.length ? l.ice.map(iceLabel).join(", ") : "public STUN"]);
3346
+ return rows;
3347
+ }
3348
+
3349
+ /** STUN/TURN servers for a WebRTC listener: --turn URLs, then the URLs held in --turn-env variables. */
3350
+ export function iceFromOptions(turn: string[] = [], turnEnv: string[] = [], env: Record<string, string | undefined> = process.env): IceServer[] {
3351
+ const urls = [...turn];
3352
+ for (const name of turnEnv) {
3353
+ const value = env[name];
3354
+ if (!value) throw new Error(`--turn-env ${name}: that environment variable is empty or unset`);
3355
+ urls.push(value);
3356
+ }
3357
+ return urls.map(parseIceUrl);
3358
+ }
3359
+
3360
+ /** A new WebRTC listener named `name` (or one derived from the signal host), saved to listeners.json. */
3361
+ export function addRtcListenerTo(config: ListenerConfig, opts: { name?: string; signal: string; ice?: IceServer[] }): RtcListener {
3362
+ const signal = normalizeSignal(opts.signal);
3363
+ const taken = new Set([...config.listeners.map(l => l.name), ...rtcListeners(config).map(l => l.name), LEGACY_RTC]);
3364
+ let name = opts.name;
3365
+ if (name !== undefined) {
3366
+ if (taken.has(name)) throw new Error(`Listener "${name}" already exists (see rech listener ls)`);
3367
+ } else {
3368
+ const host = new URL(signal).hostname;
3369
+ const base = `webrtc-${(isIP(host.replace(/^\[|\]$/g, "")) ? host : host.split(".")[0]!).replace(/[^a-zA-Z0-9_]+/g, "-").replace(/^-|-$/g, "")}`.slice(0, 36);
3370
+ name = base;
3371
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
3372
+ }
3373
+ const listener: RtcListener = { name, kind: "webrtc", signal, ...(opts.ice?.length ? { ice: opts.ice } : {}) };
3374
+ (config.rtcListeners ??= []).push(listener);
3375
+ return listener;
3376
+ }
3377
+
3378
+ /** The WebRTC listener for a signal URL: the default one, an existing one on that server, or a new one. */
3379
+ async function ensureRtcListener(signalUrl: string, ice: IceServer[] = []): Promise<string> {
3380
+ const signal = normalizeSignal(signalUrl);
3381
+ const config = await requireListeners();
3382
+ if (!ice.length) {
3383
+ const found = rtcListeners(config).find(l => l.signal === signal);
3384
+ if (found) return found.name;
3385
+ }
3386
+ const listener = addRtcListenerTo(config, { signal, ice });
3387
+ await writeListeners(config);
3388
+ console.log(`Added WebRTC listener "${listener.name}" (${signal}${ice.length ? `; ${ice.map(iceLabel).join(", ")}` : ""}).`);
3389
+ return listener.name;
3390
+ }
3391
+
3392
+ async function addWebrtcListener(name: string, opts: { webrtc: string; turn?: string[]; turnEnv?: string[] }): Promise<void> {
3393
+ const config = await requireListeners();
3394
+ const listener = addRtcListenerTo(config, { name, signal: opts.webrtc, ice: iceFromOptions(opts.turn, opts.turnEnv) });
3395
+ await writeListeners(config);
3396
+ console.log(`WebRTC listener "${listener.name}" saved (${listener.signal}${listener.ice?.length ? `; ${listener.ice.map(iceLabel).join(", ")}` : ""}).`);
3397
+ console.log(`Share over it: rech share <profile> --via ${listener.name}`);
3327
3398
  }
3328
3399
 
3329
3400
  async function removeListener(name: string): Promise<void> {
3330
3401
  const config = await requireListeners();
3402
+ if (name === DEFAULT_RTC || name === LEGACY_RTC) throw new Error(`"${name}" is built in; it can't be removed. Shares stop using it when they are revoked (rech share revoke <share>).`);
3403
+ const rtc = config.rtcListeners?.find(l => l.name === name);
3404
+ if (rtc) {
3405
+ const using = (await readShares()).shares.filter(s => !s.revokedAt && s.via.includes(name)).map(s => s.label);
3406
+ if (using.length) throw new Error(`Shares still use WebRTC listener "${name}": ${using.join(", ")}. Revoke them first (rech share revoke <share>).`);
3407
+ config.rtcListeners = config.rtcListeners!.filter(l => l !== rtc);
3408
+ await writeListeners(config);
3409
+ console.log("WebRTC listener removed; daemon reloads automatically.");
3410
+ return;
3411
+ }
3331
3412
  const listener = config.listeners.find(l => l.name === name);
3332
3413
  if (!listener) throw new Error("Unknown listener");
3333
3414
  if (listener.profiles === "*") throw new Error("Keep the local management listener for setup and recovery");
@@ -3408,7 +3489,7 @@ async function rotateKey(name: string): Promise<void> {
3408
3489
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3409
3490
  }
3410
3491
 
3411
- async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void> {
3492
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void> {
3412
3493
  const config = await requireListeners();
3413
3494
  let listener = config.listeners.find(l => l.name === name);
3414
3495
  if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
@@ -3434,7 +3515,9 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
3434
3515
  if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
3435
3516
  if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
3436
3517
  }
3518
+ if (opts.keepDownloads !== undefined) { if (opts.keepDownloads) listener.keepDownloads = true; else delete listener.keepDownloads; }
3437
3519
  await writeListeners(config);
3520
+ if (opts.keepDownloads !== undefined) console.log(opts.keepDownloads ? `${name}: browser downloads are also kept on this host (each is logged)` : `${name}: browser downloads go to the client only, then are deleted here`);
3438
3521
  if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
3439
3522
  if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
3440
3523
  if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
@@ -4054,8 +4137,9 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
4054
4137
  guide?.close();
4055
4138
  console.log(` Saved token for ${extraDisplay}`);
4056
4139
  }
4057
- if (bridgeVerified && isTTY && !opts.listen && !opts.skipShare) await setupShareStep(ask, profileEmail);
4140
+ // The share step asks with its own prompts (a hidden one for TURN passwords): free stdin first.
4058
4141
  rl?.close();
4142
+ if (bridgeVerified && isTTY && !opts.listen && !opts.skipShare) await setupShareStep(profileEmail);
4059
4143
  envWatcher?.close();
4060
4144
  if (bridgeVerified)
4061
4145
  console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
@@ -4065,65 +4149,125 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
4065
4149
 
4066
4150
  const SIGNAL_SELFHOST_DOC = "https://github.com/snomiao/rechrome/blob/main/docs/signal-selfhost.md";
4067
4151
 
4068
- /** The menu of setupShareStep: existing listeners first, then the new kinds. */
4069
- export function shareMenu(listeners: Listener[], choices: ListenChoice[]): { key: string; label: string; listener?: string; kind?: "lan" | "tailscale" | "webrtc" | "webrtc-own" }[] {
4070
- const menu: ReturnType<typeof shareMenu> = [{ key: "1", label: "Don't add sharing (local access is always on; existing shares stay: rech share ls)" }];
4152
+ /** One entry of the share picker: an existing listener (`via`) or a new one to create (`kind`). */
4153
+ export type TransportItem = { key: string; label: string; via?: string; kind?: "lan" | "tailscale" | "webrtc" };
4154
+
4155
+ /**
4156
+ * The share picker's transports: existing scoped listeners, the WebRTC listeners (the built-in
4157
+ * default included), then new ones for the networks detected here. "local" is always on, so it is
4158
+ * shown above the list, not offered in it. `tailscaleHost`: the machine's MagicDNS name.
4159
+ */
4160
+ export function transportMenu(listeners: Listener[], rtc: RtcListener[], choices: ListenChoice[], tailscaleHost?: string): TransportItem[] {
4161
+ const menu: TransportItem[] = [];
4162
+ const add = (item: Omit<TransportItem, "key">) => menu.push({ key: String(menu.length + 1), ...item });
4071
4163
  for (const l of listeners.filter(l => l.profiles !== "*"))
4072
- menu.push({ key: String(menu.length + 1), label: `Existing listener "${l.name}" (${listenerAddress(l)}${l.publicUrl ? `, ${l.publicUrl}` : ""})`, listener: l.name });
4073
- const has = (k: string) => choices.some(c => c.kind === k);
4074
- if (has("lan")) menu.push({ key: String(menu.length + 1), label: "New: LAN (machines on this network)", kind: "lan" });
4075
- if (has("tailscale")) menu.push({ key: String(menu.length + 1), label: "New: Tailscale only (your tailnet)", kind: "tailscale" });
4076
- menu.push({ key: String(menu.length + 1), label: "New: WebRTC via rechrome.pages.dev (works across networks, nothing to run)", kind: "webrtc" });
4077
- menu.push({ key: String(menu.length + 1), label: "New: WebRTC via your own signal server (and TURN)", kind: "webrtc-own" });
4164
+ add({ label: `${l.name} (${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`})`, via: l.name });
4165
+ for (const l of rtc)
4166
+ add({ label: `${l.name} (WebRTC via ${new URL(l.signal).host}${l.builtin ? `; ${DEFAULT_RTC_NOTE.split(".")[0]}` : ""})`, via: l.name });
4167
+ const lan = choices.find(c => c.kind === "lan");
4168
+ if (lan) add({ label: `+ new LAN listener (http://${lan.address}, machines on this network)`, kind: "lan" });
4169
+ const ts = choices.find(c => c.kind === "tailscale");
4170
+ if (ts) add({ label: `+ new tailscale listener (${tailscaleHost ?? ts.address}, your tailnet)`, kind: "tailscale" });
4171
+ add({ label: "+ new WebRTC listener… (your own signal server, optional TURN)", kind: "webrtc" });
4078
4172
  return menu;
4079
4173
  }
4080
4174
 
4081
- /** setup's last step: once the profile works here, offer to share it with other machines. */
4082
- async function setupShareStep(ask: (q: string, def?: string) => Promise<string>, profile: string): Promise<void> {
4083
- const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
4175
+ /** The picker's answer: numbers separated by commas or spaces; empty picks nothing. */
4176
+ export function parseTransportPicks(answer: string, menu: TransportItem[]): TransportItem[] {
4177
+ const keys = answer.trim().split(/[\s,]+/).filter(Boolean);
4178
+ const picked: TransportItem[] = [];
4179
+ for (const key of keys) {
4180
+ const item = menu.find(m => m.key === key);
4181
+ if (!item) throw new Error(`Enter numbers from 1 to ${menu.length}, separated by commas or spaces (empty: share nothing).`);
4182
+ if (!picked.includes(item)) picked.push(item);
4183
+ }
4184
+ return picked;
4185
+ }
4186
+
4187
+ /** Ask one line in the terminal; with `hidden`, nothing typed is echoed (passwords). null on EOF. */
4188
+ async function askLine(question: string, hidden = false): Promise<string | null> {
4189
+ const { createInterface } = await import("readline");
4190
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: !!process.stdin.isTTY });
4191
+ if (hidden) (rl as any)._writeToOutput = (text: string) => { if (text.startsWith(question)) process.stderr.write(question); };
4192
+ return new Promise(resolve => {
4193
+ let answered = false;
4194
+ rl.question(question, answer => { answered = true; if (hidden) process.stderr.write("\n"); rl.close(); resolve(answer); });
4195
+ rl.once("close", () => { if (!answered) resolve(null); });
4196
+ });
4197
+ }
4198
+
4199
+ const NO_TRANSPORT_LINES = (profile: string) => [
4200
+ `Pick how other machines reach ${profile} (rech share has no default):`,
4201
+ ` rech share ${profile} --lan machines on this network`,
4202
+ ` rech share ${profile} --tailscale your tailnet`,
4203
+ ` rech share ${profile} --webrtc any network, via the public signal server (${DEFAULT_RTC_NOTE.split(".")[0]})`,
4204
+ ` rech share ${profile} --webrtc=<url> any network, via your own signal server`,
4205
+ ` rech share ${profile} --via <listener> an existing listener (rech listener ls)`,
4206
+ `Or run it in a terminal to pick from a list.`,
4207
+ ];
4208
+ export const noTransportError = (profile: string) => new Error(NO_TRANSPORT_LINES(JSON.stringify(profile)).join("\n"));
4209
+
4210
+ /**
4211
+ * The share picker (rech share in a terminal, and setup's last step): tick the listeners to
4212
+ * share over, creating new ones as asked. Returns the share's `via`; empty means share nothing.
4213
+ */
4214
+ async function pickShareTransports(profiles: string[], label: string): Promise<string[]> {
4215
+ const config = await requireListeners();
4084
4216
  const choices = await detectListenChoices();
4085
- const menu = shareMenu(listeners, choices);
4086
- console.log(`\n○ Share ${profile} with other machines?`);
4087
- for (const m of menu) console.log(` ${m.key}. ${m.label}`);
4088
- let pick: (typeof menu)[number] | undefined;
4089
- while (!pick) {
4090
- const answer = (await ask(" Choice [1]: ", "1")).trim();
4091
- pick = menu.find(m => m.key === answer);
4092
- if (!pick) console.log(" Enter one of the numbers shown above.");
4217
+ const tailscaleHost = choices.some(c => c.kind === "tailscale")
4218
+ ? (JSON.parse(await runTailscale(["status", "--json"]) ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") : undefined;
4219
+ const menu = transportMenu(config.listeners, rtcListeners(config), choices, tailscaleHost);
4220
+ console.error(`\nShare ${profiles.join(", ")} over (numbers, comma or space separated; empty: nothing):`);
4221
+ console.error(` [x] local this machine, always on`);
4222
+ for (const m of menu) console.error(` [ ] ${m.key.padStart(2)}. ${m.label}`);
4223
+ let picks: TransportItem[] | undefined;
4224
+ while (!picks) {
4225
+ const answer = await askLine("Share over: ");
4226
+ if (answer === null) return [];
4227
+ try { picks = parseTransportPicks(answer, menu); } catch (error) { console.error(error instanceof Error ? error.message : error); }
4093
4228
  }
4094
- try {
4095
- if (pick.key === "1") { console.log("✓ Not shared. Later: rech share new " + JSON.stringify(profile)); return; }
4096
- if (pick.listener) {
4097
- const config = await requireListeners();
4098
- allowProfiles(config, pick.listener, [profile]);
4099
- await writeListeners(config);
4100
- await printProfileUri(profile, pick.listener);
4101
- return;
4102
- }
4103
- if (pick.kind === "tailscale") {
4104
- const dns = (JSON.parse(await runTailscale(["status", "--json"]) ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "");
4105
- if (!dns?.endsWith(".js.net")) console.log(` ! Tailscale's hostname is ${dns ? `"${dns}", not under ts.net` : "unknown (is tailscale up?)"}; the link uses the tailnet IP.`);
4106
- else console.log(` Tailscale hostname: ${dns}`);
4107
- }
4108
- let signal: string | undefined;
4109
- if (pick.kind === "webrtc-own") {
4110
- console.log(`\n Run a signal server, then paste its URL here:`);
4111
- console.log(` 1. Read ${SIGNAL_SELFHOST_DOC}`);
4112
- console.log(` (A: Cloudflare Workers is the easiest; C: Tailscale Funnel needs no domain).`);
4113
- console.log(` 2. Deploy it and open its URL in a browser to check that it answers.`);
4114
- console.log(` 3. For TURN (strict NATs), set CF_TURN_KEY_ID and CF_TURN_API_TOKEN in the daemon's env (see "TURN" in that doc).`);
4115
- while (!signal) {
4116
- const raw = (await ask(" Signal server URL (empty: cancel): ")).trim();
4117
- if (!raw) { console.log(" Not shared. Later: rech share new <profile> --signal <url>"); return; }
4118
- try { signal = normalizeSignal(raw); } catch (error) { console.log(` ${error instanceof Error ? error.message : error}`); continue; }
4119
- const ok = await fetch(signal, { signal: AbortSignal.timeout(5000) }).then(() => true).catch(() => false);
4120
- if (!ok) console.log(` ! ${signal} did not answer; the share is made anyway, check the server before sending the link.`);
4121
- }
4229
+ const via: string[] = [];
4230
+ for (const pick of picks) {
4231
+ if (pick.via) via.push(pick.via);
4232
+ else if (pick.kind === "lan") via.push(await shareTransportListener("lan", profiles, label));
4233
+ else if (pick.kind === "tailscale") {
4234
+ if (!tailscaleHost?.endsWith(".js.net")) console.error(`! Tailscale's hostname is ${tailscaleHost ? `"${tailscaleHost}", not under ts.net` : "unknown (is tailscale up?)"}; the link uses the tailnet IP.`);
4235
+ via.push(await shareTransportListener("tailscale", profiles, label));
4236
+ } else {
4237
+ const rtc = await askNewRtcListener();
4238
+ if (rtc) via.push(rtc);
4122
4239
  }
4123
- const via = pick.kind === "webrtc" || pick.kind === "webrtc-own" ? WEBRTC : pick.kind!;
4124
- await shareNew([profile], { via: [via], ...(signal ? { signal } : {}) });
4125
- } catch (error) {
4126
- console.error(` Sharing failed: ${error instanceof Error ? error.message : error}. Setup itself is saved; retry with rech share new ${JSON.stringify(profile)}.`);
4240
+ }
4241
+ return [...new Set(via)];
4242
+ }
4243
+
4244
+ /** "+ new WebRTC listener…": a signal URL (the public one by default) and an optional TURN URL. */
4245
+ async function askNewRtcListener(): Promise<string | null> {
4246
+ console.error(` A signal server introduces the two machines; the browser traffic itself is end-to-end encrypted.`);
4247
+ console.error(` Default: ${DEFAULT_SIGNAL} (${DEFAULT_RTC_NOTE}). Self-hosting: ${SIGNAL_SELFHOST_DOC}`);
4248
+ let signal: string | undefined;
4249
+ while (!signal) {
4250
+ const raw = await askLine(` Signal server URL [${DEFAULT_SIGNAL}]: `);
4251
+ if (raw === null) return null;
4252
+ try { signal = normalizeSignal(raw.trim() || DEFAULT_SIGNAL); } catch (error) { console.error(` ${error instanceof Error ? error.message : error}`); continue; }
4253
+ const ok = await fetch(signal, { signal: AbortSignal.timeout(5000) }).then(() => true).catch(() => false);
4254
+ if (!ok) console.error(` ! ${signal} did not answer; the listener is saved anyway, check the server before sending the link.`);
4255
+ }
4256
+ const ice: IceServer[] = [];
4257
+ while (true) {
4258
+ const raw = await askLine(` TURN server, e.g. turns://user:pass@turn.example.com:5349 (hidden; empty: none, STUN only): `, true);
4259
+ if (!raw?.trim()) break;
4260
+ try { ice.push(parseIceUrl(raw)); break; } catch (error) { console.error(` ${error instanceof Error ? error.message : error}`); }
4261
+ }
4262
+ return ensureRtcListener(signal, ice);
4263
+ }
4264
+
4265
+ /** setup's last step: once the profile works here, offer to share it with other machines. */
4266
+ async function setupShareStep(profile: string): Promise<void> {
4267
+ console.log(`\n○ Share ${profile} with other machines? (local access is always on; existing shares stay: rech share ls)`);
4268
+ try { await shareNew([profile], {}); }
4269
+ catch (error) {
4270
+ console.error(` Sharing failed: ${error instanceof Error ? error.message : error}. Setup itself is saved; retry with rech share ${JSON.stringify(profile)}.`);
4127
4271
  }
4128
4272
  }
4129
4273
 
@@ -4137,7 +4281,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
4137
4281
  const config = await requireListeners();
4138
4282
  const named = config.listeners.find(l => l.name === selector);
4139
4283
  if (named) {
4140
- if (named.profiles === "*") throw new Error(`"${selector}" is the local management listener; share over lan, tailscale or webrtc instead.`);
4284
+ if (named.profiles === "*") throw new Error(`"${selector}" is the local management listener; share over --lan, --tailscale or --webrtc instead.`);
4141
4285
  return named.name;
4142
4286
  }
4143
4287
  const address = chooseListenAddress(await detectListenChoices(), selector);
@@ -4151,29 +4295,58 @@ async function shareTransportListener(selector: string, profiles: string[], labe
4151
4295
  }
4152
4296
 
4153
4297
  /** Where a share's invite can be used, as full links (each holds the secret). */
4154
- async function shareLinks(share: Share): Promise<{ via: string; link: string }[]> {
4155
- const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
4298
+ async function shareLinks(share: Share): Promise<{ via: string; link: string; signal?: string }[]> {
4299
+ const config = await readListeners().catch(() => null);
4156
4300
  return share.via.map(via => {
4157
- if (via === WEBRTC) return { via: "webrtc", link: inviteLink(share, share.signal ?? DEFAULT_SIGNAL) };
4158
- const l = listeners.find(x => x.name === via);
4301
+ const rtc = shareRtcListeners({ via: [via], signal: share.signal }, config)[0];
4302
+ if (rtc) return { via: rtc.name, link: inviteLink(share, rtc.signal), signal: rtc.signal };
4303
+ const l = config?.listeners.find(x => x.name === via);
4159
4304
  if (!l) return { via, link: "(listener removed)" };
4160
4305
  return { via: l.name, link: inviteLink(share, l.publicUrl ?? `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`) };
4161
4306
  });
4162
4307
  }
4163
4308
 
4164
- function printInvite(share: Share, links: { via: string; link: string }[]): void {
4309
+ function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
4165
4310
  const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
4166
4311
  console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
4167
4312
  console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
4168
- for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(10)} ` : ""}${link}`);
4169
- const rtc = links.find(l => l.via === "webrtc");
4170
- if (rtc && share.signal === DEFAULT_SIGNAL) console.log(`\n or in a terminal: rech connect ${share.invite}`);
4171
- console.log(`\nThe other machine runs: rech connect '<link>'`);
4313
+ const width = Math.max(...links.map(l => l.via.length));
4314
+ for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
4315
+ if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
4316
+ console.log(`\n or in a terminal: rech connect ${share.invite}`);
4317
+ console.log(` ${new URL(DEFAULT_SIGNAL).host} is ${DEFAULT_RTC_NOTE}`);
4318
+ }
4319
+ console.log(`\nThe other machine runs: rech connect '<link>'${links.length > 1 ? " (any one of them)" : ""}`);
4172
4320
  if (share.approve) console.log(`Each new device waits for you: rech share approve ${share.label}`);
4173
4321
  console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
4174
4322
  }
4175
4323
 
4176
- export type ShareNewOptions = { name?: string; via?: string[]; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
4324
+ export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
4325
+
4326
+ /**
4327
+ * The transports named on the command line, in order: --via entries (comma-separated), then
4328
+ * --lan, --tailscale and each --webrtc. A bare --webrtc is "webrtc" (resolved to the built-in
4329
+ * listener, or to --signal / RECH_SIGNAL); --webrtc=<url> is that signal server's listener.
4330
+ */
4331
+ export function transportTokens(opts: Pick<ShareNewOptions, "via" | "webrtc" | "lan" | "tailscale" | "signal">): string[] {
4332
+ const tokens = (opts.via ?? []).flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
4333
+ if (opts.lan) tokens.push("lan");
4334
+ if (opts.tailscale) tokens.push("tailscale");
4335
+ for (const w of opts.webrtc ?? []) tokens.push(w.trim() ? `webrtc=${w.trim()}` : WEBRTC);
4336
+ // --signal on its own names a WebRTC transport too (on that server).
4337
+ if (opts.signal && !tokens.length) tokens.push(WEBRTC);
4338
+ return [...new Set(tokens)];
4339
+ }
4340
+
4341
+ /** One transport token to a share `via` entry, creating the listener it needs. */
4342
+ async function resolveTransport(token: string, profiles: string[], label: string, signal?: string): Promise<string> {
4343
+ if (token === WEBRTC) return signal ? ensureRtcListener(signal) : DEFAULT_RTC;
4344
+ if (token.startsWith("webrtc=")) return ensureRtcListener(token.slice("webrtc=".length));
4345
+ if (/^https?:\/\//i.test(token)) return ensureRtcListener(token);
4346
+ const config = await readListeners().catch(() => null);
4347
+ if (rtcListeners(config).some(l => l.name === token)) return token;
4348
+ return shareTransportListener(token, profiles, label);
4349
+ }
4177
4350
 
4178
4351
  async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<void> {
4179
4352
  if (!selectors.length) throw new Error("Which profiles? rech share new <profile..> (see rech profile)");
@@ -4186,19 +4359,29 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
4186
4359
  // Fail on the name before binding any listener for it.
4187
4360
  if (taken.has(label)) throw new Error(`A share named "${label}" already exists. Pick another --name, or: rech share rotate ${label}`);
4188
4361
  if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(label)) throw new Error(`Share names use a-z, 0-9, "-" and "_" (up to 40), e.g. alice or ci-bot; got "${label}"`);
4189
- const wanted = (opts.via?.length ? opts.via : [WEBRTC]).flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
4190
- const via: string[] = [];
4191
- for (const v of wanted) via.push(v === WEBRTC ? WEBRTC : await shareTransportListener(v, profiles, label));
4192
- const expires = opts.expires ?? (via.includes(WEBRTC) ? "30d" : "never");
4362
+ // No default transport: name one, or pick in a terminal.
4363
+ const tokens = transportTokens(opts);
4364
+ let via: string[];
4365
+ if (tokens.length) {
4366
+ via = [];
4367
+ for (const t of tokens) via.push(await resolveTransport(t, profiles, label, opts.signal ?? process.env.RECH_SIGNAL));
4368
+ via = [...new Set(via)];
4369
+ } else {
4370
+ if (!isInteractive()) throw noTransportError(profiles.length === 1 ? profiles[0]! : "<profile>");
4371
+ via = await pickShareTransports(profiles, label);
4372
+ if (!via.length) { console.error(`Nothing shared. Later: rech share ${JSON.stringify(profiles[0])}`); return; }
4373
+ }
4374
+ const rtc = shareRtcListeners({ via }, await readListeners().catch(() => null));
4375
+ const expires = opts.expires ?? (rtc.length ? "30d" : "never");
4193
4376
  const expiresMs = parseDuration(expires);
4194
4377
  const share = await updateShares(config => createShare(config, {
4195
- label, profiles, via, signal: opts.signal ?? process.env.RECH_SIGNAL,
4378
+ label, profiles, via,
4196
4379
  expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
4197
4380
  inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
4198
4381
  maxDevices: opts.devices ?? 1, approve: opts.approve,
4199
4382
  }));
4200
4383
  // The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
4201
- if (via.includes(WEBRTC)) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
4384
+ if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
4202
4385
  printInvite(share, await shareLinks(share));
4203
4386
  }
4204
4387
 
@@ -4423,7 +4606,7 @@ async function sessionCommand(action: string, name?: string): Promise<void> {
4423
4606
  async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
4424
4607
  const server = startSignalRelay({ host: opts.host ?? "127.0.0.1", port: opts.port ?? 8787, log });
4425
4608
  console.log(`rech signal server (rech-signal-1) on http://${server.hostname}:${server.port}/`);
4426
- console.log(`Put HTTPS in front of it, then share through it: rech share new <profile> --signal https://<your-host>/`);
4609
+ console.log(`Put HTTPS in front of it, then share through it: rech share <profile> --webrtc=https://<your-host>/`);
4427
4610
  }
4428
4611
 
4429
4612
  async function status(): Promise<void> {
@@ -4509,6 +4692,7 @@ export type RechHandlers = {
4509
4692
  status(): Promise<void>;
4510
4693
  listListeners(): Promise<void>;
4511
4694
  addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
4695
+ addWebrtcListener(name: string, opts: { webrtc: string; turn?: string[]; turnEnv?: string[] }): Promise<void>;
4512
4696
  removeListener(name: string): Promise<void>;
4513
4697
  listProfiles(): Promise<void>;
4514
4698
  removeProfile(selector: string, opts: { yes?: boolean; close?: boolean }): Promise<void>;
@@ -4520,7 +4704,7 @@ export type RechHandlers = {
4520
4704
  allowListener(name: string, profiles: string[]): Promise<void>;
4521
4705
  denyListener(name: string, profiles: string[]): Promise<void>;
4522
4706
  rotateKey(name: string): Promise<void>;
4523
- setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void>;
4707
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean }): Promise<void>;
4524
4708
  setup(opts: SetupOptions): Promise<void>;
4525
4709
  tray(action?: string): Promise<void>;
4526
4710
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -4573,7 +4757,8 @@ Examples:
4573
4757
  rech setup --profile you@example.com set up Chrome on this machine
4574
4758
  rech open https://example.com open a page in this project's session
4575
4759
  rech screenshot saved to <project>/.rechrome/output/
4576
- rech share new you@example.com invite link for another machine (WebRTC, no open port)
4760
+ rech share pick a profile and how to share it (in a terminal)
4761
+ rech share you@example.com --webrtc invite link for another machine (WebRTC, no open port)
4577
4762
  rech connect '<link>' use that link in this project
4578
4763
  rech share ls shares, devices, expiry
4579
4764
 
@@ -4614,11 +4799,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4614
4799
  throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
4615
4800
  })
4616
4801
  // Share with and connect from other machines
4617
- .command("share [profiles..]", "Share profiles: `share new <profile..>` makes an invite link (WebRTC by default); `share ls` lists all", y => y
4802
+ .command("share [profiles..]", "Share profiles: `share <profile> --lan|--tailscale|--webrtc[=url]|--via <listener>` makes an invite link (no default: a terminal asks); `share ls` lists all", y => y
4618
4803
  .positional("profiles", { type: "string", array: true, describe: "new <profile..> | ls | devices|approve|allow|deny|rotate|renew|revoke|rm <share> […]; or a profile for its legacy listener link" })
4619
4804
  .option("name", { type: "string", requiresArg: true, describe: "With new: the share's name (default: from the first profile)" })
4620
- .option("via", { type: "string", array: true, requiresArg: true, describe: "With new: webrtc (default) | lan | tailscale | <IP> | <listener>; repeat or comma-separate for several" })
4621
- .option("signal", { type: "string", requiresArg: true, describe: `With new: signal server for webrtc (default ${DEFAULT_SIGNAL}, or RECH_SIGNAL)` })
4805
+ .option("via", { type: "string", array: true, requiresArg: true, describe: "Share over these: lan | tailscale | webrtc | <IP> | <listener> (rech listener ls); repeat or comma-separate for several" })
4806
+ .option("lan", { type: "boolean", describe: "Share over this network (a LAN listener, made if needed)" })
4807
+ .option("tailscale", { type: "boolean", describe: "Share over your tailnet (a tailscale listener, made if needed)" })
4808
+ .option("webrtc", { type: "string", describe: `Share over WebRTC: bare = ${DEFAULT_RTC} (${DEFAULT_SIGNAL}, best effort); --webrtc=<signal-url> = that server's listener (made if needed); repeatable` })
4809
+ .option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
4622
4810
  .option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
4623
4811
  .option("invite-ttl", { type: "string", requiresArg: true, describe: "With new: an unused invite stops working after this (default 24h)" })
4624
4812
  .option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
@@ -4631,7 +4819,16 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4631
4819
  a => {
4632
4820
  const profiles = (a.profiles ?? []) as string[];
4633
4821
  const [action, target, ...rest] = profiles;
4634
- if (action === "new") return handlers.shareNew(profiles.slice(1), { name: a.name, via: a.via as string[] | undefined, signal: a.signal, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve });
4822
+ const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
4823
+ const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
4824
+ const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve };
4825
+ if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
4826
+ // Transport flags make a share (an invite link) for the named profiles.
4827
+ if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
4828
+ if (a.all || a.listener) throw new Error("--lan/--tailscale/--webrtc/--via make a new share; --all and --listener print an existing listener's link. Use one or the other.");
4829
+ if (!profiles.length) throw new Error("Which profiles? rech share <profile..> --lan|--tailscale|--webrtc|--via <listener> (see rech profile)");
4830
+ return handlers.shareNew(profiles, newOpts);
4831
+ }
4635
4832
  if (action && SHARE_ACTIONS.has(action)) {
4636
4833
  if (!target) throw new Error(`Which share? rech share ${action} <share> (see rech share ls)`);
4637
4834
  if (action === "devices") return handlers.shareDevices(target, a.rm);
@@ -4655,13 +4852,23 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4655
4852
  a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
4656
4853
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
4657
4854
  .command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
4658
- .command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
4855
+ .command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP), or add a WebRTC signal server (--webrtc)", y => y
4659
4856
  .positional("name", { type: "string", demandOption: true })
4660
- .option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
4661
- .option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
4857
+ .option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
4858
+ .option("profile", { type: "string", array: true, requiresArg: true, describe: "Allowed profile; repeat for several" })
4662
4859
  .option("port", portOption)
4663
- .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
4664
- a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
4860
+ .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" })
4861
+ .option("webrtc", { type: "string", requiresArg: true, describe: "A WebRTC listener on this signal server, e.g. https://signal.example.com/ (shares name it with --via)" })
4862
+ .option("turn", { type: "string", array: true, requiresArg: true, describe: "With --webrtc: a STUN/TURN server, e.g. turns://user:pass@turn.example.com:5349; repeatable" })
4863
+ .option("turn-env", { type: "string", array: true, requiresArg: true, describe: "With --webrtc: read a TURN URL from this environment variable (keeps the password out of shell history); repeatable" })
4864
+ .check(a => a.webrtc !== undefined
4865
+ ? (a.listen || a.profile || a.port || a.prefix ? "--webrtc takes no --listen/--profile/--port/--prefix" : true)
4866
+ : a.turn || a.turnEnv ? "--turn/--turn-env go with --webrtc <signal-url>"
4867
+ : a.listen && a.profile?.length ? true : "Pass --listen <where> --profile <profile>, or --webrtc <signal-url>")
4868
+ .example("rech listener add home --webrtc=https://signal.example.com/ --turn-env MY_TURN_URL", ""),
4869
+ a => a.webrtc !== undefined
4870
+ ? handlers.addWebrtcListener(a.name, { webrtc: a.webrtc, turn: a.turn as string[] | undefined, turnEnv: a.turnEnv as string[] | undefined })
4871
+ : handlers.addListener(a.name, { listen: a.listen!, profile: a.profile as string[], port: a.port, prefix: a.prefix }))
4665
4872
  .command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
4666
4873
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
4667
4874
  a => handlers.allowListener(a.name, a.profiles))
@@ -4676,9 +4883,10 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4676
4883
  .option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
4677
4884
  .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
4678
4885
  .option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
4886
+ .option("keep-downloads", { type: "boolean", describe: "Off by default: browser downloads go to the client, then are deleted here. --keep-downloads also keeps them on this host" })
4679
4887
  .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4680
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
4681
- a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}) }))
4888
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined || a.keepDownloads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, --host-upload-dir/--no-host-uploads, --read-secrets/--read-cookies, or --keep-downloads"),
4889
+ a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}), ...(a.keepDownloads !== undefined ? { keepDownloads: a.keepDownloads } : {}) }))
4682
4890
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4683
4891
  a => handlers.listenerPort(a.name))
4684
4892
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
@@ -4729,7 +4937,7 @@ if (import.meta.main) {
4729
4937
  const handlers: RechHandlers = {
4730
4938
  serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
4731
4939
  status,
4732
- listListeners, addListener, removeListener, printProfileUri,
4940
+ listListeners, addListener, addWebrtcListener, removeListener, printProfileUri,
4733
4941
  listProfiles: async () => {
4734
4942
  const raw = process.env[ENV_KEY];
4735
4943
  const url = raw ? await transportUrl(raw) : raw;