rechrome 1.45.0 → 1.47.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/README.md +24 -11
- package/listeners.js +124 -3
- package/listeners.ts +79 -1
- package/package.json +1 -1
- package/rechrome.js +348 -100
- package/rechrome.ts +348 -100
- package/serve.js +21 -8
- package/serve.ts +21 -8
- package/shares.js +5 -2
- package/shares.ts +8 -2
- package/webrtc.js +16 -8
- package/webrtc.ts +15 -10
package/rechrome.ts
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.ts";
|
|
3
|
-
import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.ts";
|
|
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.ts";
|
|
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
|
|
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
|
-
|
|
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;
|
|
@@ -2050,7 +2059,7 @@ export function withDefaultScheme(target: string): string {
|
|
|
2050
2059
|
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
2051
2060
|
// missing value; accepts multiple occurrences (last one wins).
|
|
2052
2061
|
/**
|
|
2053
|
-
* `rech [--profile X] [--isolate] pw <args>` forwards <args> to playwright-cli verbatim, so
|
|
2062
|
+
* `rech [--profile X] [--isolate[=<name>]] pw <args>` forwards <args> to playwright-cli verbatim, so
|
|
2054
2063
|
* `rech pw --version` or `rech pw status` reach playwright instead of rech. `--` works the
|
|
2055
2064
|
* same after a rech flag (`rech --profile X -- status`); a bare leading `--` cannot, because
|
|
2056
2065
|
* Bun consumes the `--` right after the script path. The separator counts only when
|
|
@@ -2062,12 +2071,32 @@ export function rechSeparatorIndex(args: string[]): number {
|
|
|
2062
2071
|
const a = args[i];
|
|
2063
2072
|
if (a === "--" || a === "pw" || a === "playwright") return i;
|
|
2064
2073
|
if (a === "--profile") { i++; continue; }
|
|
2065
|
-
if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated") continue;
|
|
2074
|
+
if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a)) continue;
|
|
2066
2075
|
return -1;
|
|
2067
2076
|
}
|
|
2068
2077
|
return -1;
|
|
2069
2078
|
}
|
|
2070
2079
|
|
|
2080
|
+
/** Prefix of a named isolated session (`--isolate=<name>` -> `-s=iso_<name>`); `_` keeps it out of the iso-<hex> reaper. */
|
|
2081
|
+
export const NAMED_ISO_PREFIX = "iso_";
|
|
2082
|
+
|
|
2083
|
+
/**
|
|
2084
|
+
* Turn rech's `--isolate` / `--isolate=<name>` into a session flag. Bare `--isolate` is a
|
|
2085
|
+
* throwaway `-s=iso-<random>` (reaped when idle); `--isolate=<name>` is the persistent
|
|
2086
|
+
* `-s=iso_<name>`, reused by later commands with the same name until `rech session close <name>`.
|
|
2087
|
+
*/
|
|
2088
|
+
export function applyIsolateArg(args: string[]): string[] {
|
|
2089
|
+
const idx = args.findIndex(a => a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a));
|
|
2090
|
+
if (idx === -1) return args;
|
|
2091
|
+
const flag = args[idx];
|
|
2092
|
+
const rest = args.filter((_, i) => i !== idx);
|
|
2093
|
+
if (!flag.includes("=")) return [...rest, `-s=iso-${randomBytes(8).toString("hex")}`];
|
|
2094
|
+
const name = flag.slice(flag.indexOf("=") + 1);
|
|
2095
|
+
if (!/^[a-zA-Z0-9_-]{1,60}$/.test(name)) throw new Error(`--isolate=<name> takes 1-60 letters, digits, '-' or '_' (got "${name}")`);
|
|
2096
|
+
if (rest.some(a => a.startsWith("-s="))) throw new Error("Pass --isolate=<name> or -s=<session>, not both.");
|
|
2097
|
+
return [...rest, `-s=${NAMED_ISO_PREFIX}${name}`];
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2071
2100
|
/** rechrome's own version, from the package.json shipped next to this file. */
|
|
2072
2101
|
export function rechromeVersion(): string {
|
|
2073
2102
|
try { return JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")).version ?? "unknown"; }
|
|
@@ -3320,14 +3349,86 @@ function printTable(rows: string[][]): void {
|
|
|
3320
3349
|
}
|
|
3321
3350
|
|
|
3322
3351
|
async function listListeners(): Promise<void> {
|
|
3352
|
+
const config = await requireListeners();
|
|
3323
3353
|
const rows = [["NAME", "ADDRESS", "PROFILES", "PUBLIC URL"]];
|
|
3324
|
-
for (const l of
|
|
3354
|
+
for (const l of config.listeners)
|
|
3325
3355
|
rows.push([l.name, `${listenerAddress(l)}${normalizePrefix(l.prefix)}`, l.profiles === "*" ? "(all — local management)" : l.profiles.join(", "), l.publicUrl ?? "-"]);
|
|
3326
3356
|
printTable(rows);
|
|
3357
|
+
console.log("");
|
|
3358
|
+
printTable(rtcListenerRows(config));
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
/** `rech listener ls`'s WebRTC table. STUN/TURN servers show their username, never the password. */
|
|
3362
|
+
export function rtcListenerRows(config: ListenerConfig | null): string[][] {
|
|
3363
|
+
const rows = [["WEBRTC", "SIGNAL SERVER", "STUN/TURN"]];
|
|
3364
|
+
for (const l of rtcListeners(config))
|
|
3365
|
+
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"]);
|
|
3366
|
+
return rows;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
/** STUN/TURN servers for a WebRTC listener: --turn URLs, then the URLs held in --turn-env variables. */
|
|
3370
|
+
export function iceFromOptions(turn: string[] = [], turnEnv: string[] = [], env: Record<string, string | undefined> = process.env): IceServer[] {
|
|
3371
|
+
const urls = [...turn];
|
|
3372
|
+
for (const name of turnEnv) {
|
|
3373
|
+
const value = env[name];
|
|
3374
|
+
if (!value) throw new Error(`--turn-env ${name}: that environment variable is empty or unset`);
|
|
3375
|
+
urls.push(value);
|
|
3376
|
+
}
|
|
3377
|
+
return urls.map(parseIceUrl);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/** A new WebRTC listener named `name` (or one derived from the signal host), saved to listeners.json. */
|
|
3381
|
+
export function addRtcListenerTo(config: ListenerConfig, opts: { name?: string; signal: string; ice?: IceServer[] }): RtcListener {
|
|
3382
|
+
const signal = normalizeSignal(opts.signal);
|
|
3383
|
+
const taken = new Set([...config.listeners.map(l => l.name), ...rtcListeners(config).map(l => l.name), LEGACY_RTC]);
|
|
3384
|
+
let name = opts.name;
|
|
3385
|
+
if (name !== undefined) {
|
|
3386
|
+
if (taken.has(name)) throw new Error(`Listener "${name}" already exists (see rech listener ls)`);
|
|
3387
|
+
} else {
|
|
3388
|
+
const host = new URL(signal).hostname;
|
|
3389
|
+
const base = `webrtc-${(isIP(host.replace(/^\[|\]$/g, "")) ? host : host.split(".")[0]!).replace(/[^a-zA-Z0-9_]+/g, "-").replace(/^-|-$/g, "")}`.slice(0, 36);
|
|
3390
|
+
name = base;
|
|
3391
|
+
for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
|
|
3392
|
+
}
|
|
3393
|
+
const listener: RtcListener = { name, kind: "webrtc", signal, ...(opts.ice?.length ? { ice: opts.ice } : {}) };
|
|
3394
|
+
(config.rtcListeners ??= []).push(listener);
|
|
3395
|
+
return listener;
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
/** The WebRTC listener for a signal URL: the default one, an existing one on that server, or a new one. */
|
|
3399
|
+
async function ensureRtcListener(signalUrl: string, ice: IceServer[] = []): Promise<string> {
|
|
3400
|
+
const signal = normalizeSignal(signalUrl);
|
|
3401
|
+
const config = await requireListeners();
|
|
3402
|
+
if (!ice.length) {
|
|
3403
|
+
const found = rtcListeners(config).find(l => l.signal === signal);
|
|
3404
|
+
if (found) return found.name;
|
|
3405
|
+
}
|
|
3406
|
+
const listener = addRtcListenerTo(config, { signal, ice });
|
|
3407
|
+
await writeListeners(config);
|
|
3408
|
+
console.log(`Added WebRTC listener "${listener.name}" (${signal}${ice.length ? `; ${ice.map(iceLabel).join(", ")}` : ""}).`);
|
|
3409
|
+
return listener.name;
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
async function addWebrtcListener(name: string, opts: { webrtc: string; turn?: string[]; turnEnv?: string[] }): Promise<void> {
|
|
3413
|
+
const config = await requireListeners();
|
|
3414
|
+
const listener = addRtcListenerTo(config, { name, signal: opts.webrtc, ice: iceFromOptions(opts.turn, opts.turnEnv) });
|
|
3415
|
+
await writeListeners(config);
|
|
3416
|
+
console.log(`WebRTC listener "${listener.name}" saved (${listener.signal}${listener.ice?.length ? `; ${listener.ice.map(iceLabel).join(", ")}` : ""}).`);
|
|
3417
|
+
console.log(`Share over it: rech share <profile> --via ${listener.name}`);
|
|
3327
3418
|
}
|
|
3328
3419
|
|
|
3329
3420
|
async function removeListener(name: string): Promise<void> {
|
|
3330
3421
|
const config = await requireListeners();
|
|
3422
|
+
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>).`);
|
|
3423
|
+
const rtc = config.rtcListeners?.find(l => l.name === name);
|
|
3424
|
+
if (rtc) {
|
|
3425
|
+
const using = (await readShares()).shares.filter(s => !s.revokedAt && s.via.includes(name)).map(s => s.label);
|
|
3426
|
+
if (using.length) throw new Error(`Shares still use WebRTC listener "${name}": ${using.join(", ")}. Revoke them first (rech share revoke <share>).`);
|
|
3427
|
+
config.rtcListeners = config.rtcListeners!.filter(l => l !== rtc);
|
|
3428
|
+
await writeListeners(config);
|
|
3429
|
+
console.log("WebRTC listener removed; daemon reloads automatically.");
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3331
3432
|
const listener = config.listeners.find(l => l.name === name);
|
|
3332
3433
|
if (!listener) throw new Error("Unknown listener");
|
|
3333
3434
|
if (listener.profiles === "*") throw new Error("Keep the local management listener for setup and recovery");
|
|
@@ -4056,8 +4157,9 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
4056
4157
|
guide?.close();
|
|
4057
4158
|
console.log(` Saved token for ${extraDisplay}`);
|
|
4058
4159
|
}
|
|
4059
|
-
|
|
4160
|
+
// The share step asks with its own prompts (a hidden one for TURN passwords): free stdin first.
|
|
4060
4161
|
rl?.close();
|
|
4162
|
+
if (bridgeVerified && isTTY && !opts.listen && !opts.skipShare) await setupShareStep(profileEmail);
|
|
4061
4163
|
envWatcher?.close();
|
|
4062
4164
|
if (bridgeVerified)
|
|
4063
4165
|
console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
|
|
@@ -4067,65 +4169,125 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
4067
4169
|
|
|
4068
4170
|
const SIGNAL_SELFHOST_DOC = "https://github.com/snomiao/rechrome/blob/main/docs/signal-selfhost.md";
|
|
4069
4171
|
|
|
4070
|
-
/**
|
|
4071
|
-
export
|
|
4072
|
-
|
|
4172
|
+
/** One entry of the share picker: an existing listener (`via`) or a new one to create (`kind`). */
|
|
4173
|
+
export type TransportItem = { key: string; label: string; via?: string; kind?: "lan" | "tailscale" | "webrtc" };
|
|
4174
|
+
|
|
4175
|
+
/**
|
|
4176
|
+
* The share picker's transports: existing scoped listeners, the WebRTC listeners (the built-in
|
|
4177
|
+
* default included), then new ones for the networks detected here. "local" is always on, so it is
|
|
4178
|
+
* shown above the list, not offered in it. `tailscaleHost`: the machine's MagicDNS name.
|
|
4179
|
+
*/
|
|
4180
|
+
export function transportMenu(listeners: Listener[], rtc: RtcListener[], choices: ListenChoice[], tailscaleHost?: string): TransportItem[] {
|
|
4181
|
+
const menu: TransportItem[] = [];
|
|
4182
|
+
const add = (item: Omit<TransportItem, "key">) => menu.push({ key: String(menu.length + 1), ...item });
|
|
4073
4183
|
for (const l of listeners.filter(l => l.profiles !== "*"))
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4184
|
+
add({ label: `${l.name} (${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`})`, via: l.name });
|
|
4185
|
+
for (const l of rtc)
|
|
4186
|
+
add({ label: `${l.name} (WebRTC via ${new URL(l.signal).host}${l.builtin ? `; ${DEFAULT_RTC_NOTE.split(".")[0]}` : ""})`, via: l.name });
|
|
4187
|
+
const lan = choices.find(c => c.kind === "lan");
|
|
4188
|
+
if (lan) add({ label: `+ new LAN listener (http://${lan.address}, machines on this network)`, kind: "lan" });
|
|
4189
|
+
const ts = choices.find(c => c.kind === "tailscale");
|
|
4190
|
+
if (ts) add({ label: `+ new tailscale listener (${tailscaleHost ?? ts.address}, your tailnet)`, kind: "tailscale" });
|
|
4191
|
+
add({ label: "+ new WebRTC listener… (your own signal server, optional TURN)", kind: "webrtc" });
|
|
4080
4192
|
return menu;
|
|
4081
4193
|
}
|
|
4082
4194
|
|
|
4083
|
-
/**
|
|
4084
|
-
|
|
4085
|
-
const
|
|
4195
|
+
/** The picker's answer: numbers separated by commas or spaces; empty picks nothing. */
|
|
4196
|
+
export function parseTransportPicks(answer: string, menu: TransportItem[]): TransportItem[] {
|
|
4197
|
+
const keys = answer.trim().split(/[\s,]+/).filter(Boolean);
|
|
4198
|
+
const picked: TransportItem[] = [];
|
|
4199
|
+
for (const key of keys) {
|
|
4200
|
+
const item = menu.find(m => m.key === key);
|
|
4201
|
+
if (!item) throw new Error(`Enter numbers from 1 to ${menu.length}, separated by commas or spaces (empty: share nothing).`);
|
|
4202
|
+
if (!picked.includes(item)) picked.push(item);
|
|
4203
|
+
}
|
|
4204
|
+
return picked;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
/** Ask one line in the terminal; with `hidden`, nothing typed is echoed (passwords). null on EOF. */
|
|
4208
|
+
async function askLine(question: string, hidden = false): Promise<string | null> {
|
|
4209
|
+
const { createInterface } = await import("readline");
|
|
4210
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: !!process.stdin.isTTY });
|
|
4211
|
+
if (hidden) (rl as any)._writeToOutput = (text: string) => { if (text.startsWith(question)) process.stderr.write(question); };
|
|
4212
|
+
return new Promise(resolve => {
|
|
4213
|
+
let answered = false;
|
|
4214
|
+
rl.question(question, answer => { answered = true; if (hidden) process.stderr.write("\n"); rl.close(); resolve(answer); });
|
|
4215
|
+
rl.once("close", () => { if (!answered) resolve(null); });
|
|
4216
|
+
});
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
const NO_TRANSPORT_LINES = (profile: string) => [
|
|
4220
|
+
`Pick how other machines reach ${profile} (rech share has no default):`,
|
|
4221
|
+
` rech share ${profile} --lan machines on this network`,
|
|
4222
|
+
` rech share ${profile} --tailscale your tailnet`,
|
|
4223
|
+
` rech share ${profile} --webrtc any network, via the public signal server (${DEFAULT_RTC_NOTE.split(".")[0]})`,
|
|
4224
|
+
` rech share ${profile} --webrtc=<url> any network, via your own signal server`,
|
|
4225
|
+
` rech share ${profile} --via <listener> an existing listener (rech listener ls)`,
|
|
4226
|
+
`Or run it in a terminal to pick from a list.`,
|
|
4227
|
+
];
|
|
4228
|
+
export const noTransportError = (profile: string) => new Error(NO_TRANSPORT_LINES(JSON.stringify(profile)).join("\n"));
|
|
4229
|
+
|
|
4230
|
+
/**
|
|
4231
|
+
* The share picker (rech share in a terminal, and setup's last step): tick the listeners to
|
|
4232
|
+
* share over, creating new ones as asked. Returns the share's `via`; empty means share nothing.
|
|
4233
|
+
*/
|
|
4234
|
+
async function pickShareTransports(profiles: string[], label: string): Promise<string[]> {
|
|
4235
|
+
const config = await requireListeners();
|
|
4086
4236
|
const choices = await detectListenChoices();
|
|
4087
|
-
const
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4237
|
+
const tailscaleHost = choices.some(c => c.kind === "tailscale")
|
|
4238
|
+
? (JSON.parse(await runTailscale(["status", "--json"]) ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") : undefined;
|
|
4239
|
+
const menu = transportMenu(config.listeners, rtcListeners(config), choices, tailscaleHost);
|
|
4240
|
+
console.error(`\nShare ${profiles.join(", ")} over (numbers, comma or space separated; empty: nothing):`);
|
|
4241
|
+
console.error(` [x] local this machine, always on`);
|
|
4242
|
+
for (const m of menu) console.error(` [ ] ${m.key.padStart(2)}. ${m.label}`);
|
|
4243
|
+
let picks: TransportItem[] | undefined;
|
|
4244
|
+
while (!picks) {
|
|
4245
|
+
const answer = await askLine("Share over: ");
|
|
4246
|
+
if (answer === null) return [];
|
|
4247
|
+
try { picks = parseTransportPicks(answer, menu); } catch (error) { console.error(error instanceof Error ? error.message : error); }
|
|
4095
4248
|
}
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
if (pick.
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
await
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
const dns = (JSON.parse(await runTailscale(["status", "--json"]) ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "");
|
|
4107
|
-
if (!dns?.endsWith(".ts.net")) console.log(` ! Tailscale's hostname is ${dns ? `"${dns}", not under ts.net` : "unknown (is tailscale up?)"}; the link uses the tailnet IP.`);
|
|
4108
|
-
else console.log(` Tailscale hostname: ${dns}`);
|
|
4109
|
-
}
|
|
4110
|
-
let signal: string | undefined;
|
|
4111
|
-
if (pick.kind === "webrtc-own") {
|
|
4112
|
-
console.log(`\n Run a signal server, then paste its URL here:`);
|
|
4113
|
-
console.log(` 1. Read ${SIGNAL_SELFHOST_DOC}`);
|
|
4114
|
-
console.log(` (A: Cloudflare Workers is the easiest; C: Tailscale Funnel needs no domain).`);
|
|
4115
|
-
console.log(` 2. Deploy it and open its URL in a browser to check that it answers.`);
|
|
4116
|
-
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).`);
|
|
4117
|
-
while (!signal) {
|
|
4118
|
-
const raw = (await ask(" Signal server URL (empty: cancel): ")).trim();
|
|
4119
|
-
if (!raw) { console.log(" Not shared. Later: rech share new <profile> --signal <url>"); return; }
|
|
4120
|
-
try { signal = normalizeSignal(raw); } catch (error) { console.log(` ${error instanceof Error ? error.message : error}`); continue; }
|
|
4121
|
-
const ok = await fetch(signal, { signal: AbortSignal.timeout(5000) }).then(() => true).catch(() => false);
|
|
4122
|
-
if (!ok) console.log(` ! ${signal} did not answer; the share is made anyway, check the server before sending the link.`);
|
|
4123
|
-
}
|
|
4249
|
+
const via: string[] = [];
|
|
4250
|
+
for (const pick of picks) {
|
|
4251
|
+
if (pick.via) via.push(pick.via);
|
|
4252
|
+
else if (pick.kind === "lan") via.push(await shareTransportListener("lan", profiles, label));
|
|
4253
|
+
else if (pick.kind === "tailscale") {
|
|
4254
|
+
if (!tailscaleHost?.endsWith(".ts.net")) console.error(`! Tailscale's hostname is ${tailscaleHost ? `"${tailscaleHost}", not under ts.net` : "unknown (is tailscale up?)"}; the link uses the tailnet IP.`);
|
|
4255
|
+
via.push(await shareTransportListener("tailscale", profiles, label));
|
|
4256
|
+
} else {
|
|
4257
|
+
const rtc = await askNewRtcListener();
|
|
4258
|
+
if (rtc) via.push(rtc);
|
|
4124
4259
|
}
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4260
|
+
}
|
|
4261
|
+
return [...new Set(via)];
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
/** "+ new WebRTC listener…": a signal URL (the public one by default) and an optional TURN URL. */
|
|
4265
|
+
async function askNewRtcListener(): Promise<string | null> {
|
|
4266
|
+
console.error(` A signal server introduces the two machines; the browser traffic itself is end-to-end encrypted.`);
|
|
4267
|
+
console.error(` Default: ${DEFAULT_SIGNAL} (${DEFAULT_RTC_NOTE}). Self-hosting: ${SIGNAL_SELFHOST_DOC}`);
|
|
4268
|
+
let signal: string | undefined;
|
|
4269
|
+
while (!signal) {
|
|
4270
|
+
const raw = await askLine(` Signal server URL [${DEFAULT_SIGNAL}]: `);
|
|
4271
|
+
if (raw === null) return null;
|
|
4272
|
+
try { signal = normalizeSignal(raw.trim() || DEFAULT_SIGNAL); } catch (error) { console.error(` ${error instanceof Error ? error.message : error}`); continue; }
|
|
4273
|
+
const ok = await fetch(signal, { signal: AbortSignal.timeout(5000) }).then(() => true).catch(() => false);
|
|
4274
|
+
if (!ok) console.error(` ! ${signal} did not answer; the listener is saved anyway, check the server before sending the link.`);
|
|
4275
|
+
}
|
|
4276
|
+
const ice: IceServer[] = [];
|
|
4277
|
+
while (true) {
|
|
4278
|
+
const raw = await askLine(` TURN server, e.g. turns://user:pass@turn.example.com:5349 (hidden; empty: none, STUN only): `, true);
|
|
4279
|
+
if (!raw?.trim()) break;
|
|
4280
|
+
try { ice.push(parseIceUrl(raw)); break; } catch (error) { console.error(` ${error instanceof Error ? error.message : error}`); }
|
|
4281
|
+
}
|
|
4282
|
+
return ensureRtcListener(signal, ice);
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
/** setup's last step: once the profile works here, offer to share it with other machines. */
|
|
4286
|
+
async function setupShareStep(profile: string): Promise<void> {
|
|
4287
|
+
console.log(`\n○ Share ${profile} with other machines? (local access is always on; existing shares stay: rech share ls)`);
|
|
4288
|
+
try { await shareNew([profile], {}); }
|
|
4289
|
+
catch (error) {
|
|
4290
|
+
console.error(` Sharing failed: ${error instanceof Error ? error.message : error}. Setup itself is saved; retry with rech share ${JSON.stringify(profile)}.`);
|
|
4129
4291
|
}
|
|
4130
4292
|
}
|
|
4131
4293
|
|
|
@@ -4139,7 +4301,7 @@ async function shareTransportListener(selector: string, profiles: string[], labe
|
|
|
4139
4301
|
const config = await requireListeners();
|
|
4140
4302
|
const named = config.listeners.find(l => l.name === selector);
|
|
4141
4303
|
if (named) {
|
|
4142
|
-
if (named.profiles === "*") throw new Error(`"${selector}" is the local management listener; share over lan, tailscale or webrtc instead.`);
|
|
4304
|
+
if (named.profiles === "*") throw new Error(`"${selector}" is the local management listener; share over --lan, --tailscale or --webrtc instead.`);
|
|
4143
4305
|
return named.name;
|
|
4144
4306
|
}
|
|
4145
4307
|
const address = chooseListenAddress(await detectListenChoices(), selector);
|
|
@@ -4153,29 +4315,58 @@ async function shareTransportListener(selector: string, profiles: string[], labe
|
|
|
4153
4315
|
}
|
|
4154
4316
|
|
|
4155
4317
|
/** Where a share's invite can be used, as full links (each holds the secret). */
|
|
4156
|
-
async function shareLinks(share: Share): Promise<{ via: string; link: string }[]> {
|
|
4157
|
-
const
|
|
4318
|
+
async function shareLinks(share: Share): Promise<{ via: string; link: string; signal?: string }[]> {
|
|
4319
|
+
const config = await readListeners().catch(() => null);
|
|
4158
4320
|
return share.via.map(via => {
|
|
4159
|
-
|
|
4160
|
-
|
|
4321
|
+
const rtc = shareRtcListeners({ via: [via], signal: share.signal }, config)[0];
|
|
4322
|
+
if (rtc) return { via: rtc.name, link: inviteLink(share, rtc.signal), signal: rtc.signal };
|
|
4323
|
+
const l = config?.listeners.find(x => x.name === via);
|
|
4161
4324
|
if (!l) return { via, link: "(listener removed)" };
|
|
4162
4325
|
return { via: l.name, link: inviteLink(share, l.publicUrl ?? `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`) };
|
|
4163
4326
|
});
|
|
4164
4327
|
}
|
|
4165
4328
|
|
|
4166
|
-
function printInvite(share: Share, links: { via: string; link: string }[]): void {
|
|
4329
|
+
function printInvite(share: Share, links: { via: string; link: string; signal?: string }[]): void {
|
|
4167
4330
|
const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
|
|
4168
4331
|
console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
|
|
4169
4332
|
console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
|
|
4170
|
-
|
|
4171
|
-
const
|
|
4172
|
-
if (
|
|
4173
|
-
|
|
4333
|
+
const width = Math.max(...links.map(l => l.via.length));
|
|
4334
|
+
for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(width)} ` : ""}${link}`);
|
|
4335
|
+
if (links.some(l => l.signal === DEFAULT_SIGNAL)) {
|
|
4336
|
+
console.log(`\n or in a terminal: rech connect ${share.invite}`);
|
|
4337
|
+
console.log(` ${new URL(DEFAULT_SIGNAL).host} is ${DEFAULT_RTC_NOTE}`);
|
|
4338
|
+
}
|
|
4339
|
+
console.log(`\nThe other machine runs: rech connect '<link>'${links.length > 1 ? " (any one of them)" : ""}`);
|
|
4174
4340
|
if (share.approve) console.log(`Each new device waits for you: rech share approve ${share.label}`);
|
|
4175
4341
|
console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
|
|
4176
4342
|
}
|
|
4177
4343
|
|
|
4178
|
-
export type ShareNewOptions = { name?: string; via?: string[]; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
|
|
4344
|
+
export type ShareNewOptions = { name?: string; via?: string[]; webrtc?: string[]; lan?: boolean; tailscale?: boolean; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
|
|
4345
|
+
|
|
4346
|
+
/**
|
|
4347
|
+
* The transports named on the command line, in order: --via entries (comma-separated), then
|
|
4348
|
+
* --lan, --tailscale and each --webrtc. A bare --webrtc is "webrtc" (resolved to the built-in
|
|
4349
|
+
* listener, or to --signal / RECH_SIGNAL); --webrtc=<url> is that signal server's listener.
|
|
4350
|
+
*/
|
|
4351
|
+
export function transportTokens(opts: Pick<ShareNewOptions, "via" | "webrtc" | "lan" | "tailscale" | "signal">): string[] {
|
|
4352
|
+
const tokens = (opts.via ?? []).flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
|
|
4353
|
+
if (opts.lan) tokens.push("lan");
|
|
4354
|
+
if (opts.tailscale) tokens.push("tailscale");
|
|
4355
|
+
for (const w of opts.webrtc ?? []) tokens.push(w.trim() ? `webrtc=${w.trim()}` : WEBRTC);
|
|
4356
|
+
// --signal on its own names a WebRTC transport too (on that server).
|
|
4357
|
+
if (opts.signal && !tokens.length) tokens.push(WEBRTC);
|
|
4358
|
+
return [...new Set(tokens)];
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
/** One transport token to a share `via` entry, creating the listener it needs. */
|
|
4362
|
+
async function resolveTransport(token: string, profiles: string[], label: string, signal?: string): Promise<string> {
|
|
4363
|
+
if (token === WEBRTC) return signal ? ensureRtcListener(signal) : DEFAULT_RTC;
|
|
4364
|
+
if (token.startsWith("webrtc=")) return ensureRtcListener(token.slice("webrtc=".length));
|
|
4365
|
+
if (/^https?:\/\//i.test(token)) return ensureRtcListener(token);
|
|
4366
|
+
const config = await readListeners().catch(() => null);
|
|
4367
|
+
if (rtcListeners(config).some(l => l.name === token)) return token;
|
|
4368
|
+
return shareTransportListener(token, profiles, label);
|
|
4369
|
+
}
|
|
4179
4370
|
|
|
4180
4371
|
async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<void> {
|
|
4181
4372
|
if (!selectors.length) throw new Error("Which profiles? rech share new <profile..> (see rech profile)");
|
|
@@ -4188,19 +4379,29 @@ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<voi
|
|
|
4188
4379
|
// Fail on the name before binding any listener for it.
|
|
4189
4380
|
if (taken.has(label)) throw new Error(`A share named "${label}" already exists. Pick another --name, or: rech share rotate ${label}`);
|
|
4190
4381
|
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}"`);
|
|
4191
|
-
|
|
4192
|
-
const
|
|
4193
|
-
|
|
4194
|
-
|
|
4382
|
+
// No default transport: name one, or pick in a terminal.
|
|
4383
|
+
const tokens = transportTokens(opts);
|
|
4384
|
+
let via: string[];
|
|
4385
|
+
if (tokens.length) {
|
|
4386
|
+
via = [];
|
|
4387
|
+
for (const t of tokens) via.push(await resolveTransport(t, profiles, label, opts.signal ?? process.env.RECH_SIGNAL));
|
|
4388
|
+
via = [...new Set(via)];
|
|
4389
|
+
} else {
|
|
4390
|
+
if (!isInteractive()) throw noTransportError(profiles.length === 1 ? profiles[0]! : "<profile>");
|
|
4391
|
+
via = await pickShareTransports(profiles, label);
|
|
4392
|
+
if (!via.length) { console.error(`Nothing shared. Later: rech share ${JSON.stringify(profiles[0])}`); return; }
|
|
4393
|
+
}
|
|
4394
|
+
const rtc = shareRtcListeners({ via }, await readListeners().catch(() => null));
|
|
4395
|
+
const expires = opts.expires ?? (rtc.length ? "30d" : "never");
|
|
4195
4396
|
const expiresMs = parseDuration(expires);
|
|
4196
4397
|
const share = await updateShares(config => createShare(config, {
|
|
4197
|
-
label, profiles, via,
|
|
4398
|
+
label, profiles, via,
|
|
4198
4399
|
expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
|
|
4199
4400
|
inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
|
|
4200
4401
|
maxDevices: opts.devices ?? 1, approve: opts.approve,
|
|
4201
4402
|
}));
|
|
4202
4403
|
// The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
|
|
4203
|
-
if (
|
|
4404
|
+
if (rtc.length) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
|
|
4204
4405
|
printInvite(share, await shareLinks(share));
|
|
4205
4406
|
}
|
|
4206
4407
|
|
|
@@ -4404,28 +4605,46 @@ async function bridgeCommand(): Promise<void> {
|
|
|
4404
4605
|
|
|
4405
4606
|
// ---- sessions (client view) -----------------------------------------------------------------
|
|
4406
4607
|
|
|
4608
|
+
type SessionRow = { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean };
|
|
4609
|
+
|
|
4610
|
+
async function fetchSessions(url: string): Promise<{ res: Response; body: { sessions?: SessionRow[]; stderr?: string } | null }> {
|
|
4611
|
+
const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
|
|
4612
|
+
return { res, body: await res.json().catch(() => null) as { sessions?: SessionRow[]; stderr?: string } | null };
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
/** `name` as given, unless only the named isolated session `iso_<name>` exists. */
|
|
4616
|
+
export function pickSessionName(name: string, known: string[]): string {
|
|
4617
|
+
if (name.startsWith(NAMED_ISO_PREFIX) || known.includes(name)) return name;
|
|
4618
|
+
return known.includes(NAMED_ISO_PREFIX + name) ? NAMED_ISO_PREFIX + name : name;
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
async function resolveSessionName(url: string, name: string): Promise<string> {
|
|
4622
|
+
const known = await fetchSessions(url).then(({ body }) => (body?.sessions ?? []).map(s => s.session)).catch(() => [] as string[]);
|
|
4623
|
+
return pickSessionName(name, known);
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4407
4626
|
async function sessionCommand(action: string, name?: string): Promise<void> {
|
|
4408
4627
|
const raw = process.env[ENV_KEY];
|
|
4409
4628
|
if (!raw) throw new Error(notConnectedMessage());
|
|
4410
4629
|
const url = await transportUrl(raw);
|
|
4411
4630
|
if (action === "close") {
|
|
4412
4631
|
if (!name) throw new Error("Which session? rech session close <name> (see rech session ls)");
|
|
4413
|
-
|
|
4632
|
+
// A named isolated session closes by its --isolate name too (`login` for iso_login).
|
|
4633
|
+
return run(url, ["close", `-s=${await resolveSessionName(url, name)}`]);
|
|
4414
4634
|
}
|
|
4415
|
-
const res
|
|
4416
|
-
const body = await res.json().catch(() => null) as { sessions?: { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean }[]; stderr?: string } | null;
|
|
4635
|
+
const { res, body } = await fetchSessions(url);
|
|
4417
4636
|
if (!res.ok || !body?.sessions) throw new Error(body?.stderr?.trim() || `The host does not list sessions (HTTP ${res.status}); it may run an older rech.`);
|
|
4418
4637
|
if (!body.sessions.length) { console.log("No sessions yet (since the host's daemon started)."); return; }
|
|
4419
4638
|
const rows = [["SESSION", "PROFILE", "CLIENT", "LAST USED", "STATE"]];
|
|
4420
4639
|
for (const s of body.sessions) rows.push([s.session, s.profile ?? "-", s.client ?? "-", fmtDate(s.lastUsed), s.live ? "open" : "closed"]);
|
|
4421
4640
|
printTable(rows);
|
|
4422
|
-
console.log(`\nUse one: rech -s=<session> open https://example.com · close it: rech session close <session>`);
|
|
4641
|
+
console.log(`\nUse one: rech -s=<session> open https://example.com (an ${NAMED_ISO_PREFIX}<name> session: rech --isolate=<name> …) · close it: rech session close <session>`);
|
|
4423
4642
|
}
|
|
4424
4643
|
|
|
4425
4644
|
async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
|
|
4426
4645
|
const server = startSignalRelay({ host: opts.host ?? "127.0.0.1", port: opts.port ?? 8787, log });
|
|
4427
4646
|
console.log(`rech signal server (rech-signal-1) on http://${server.hostname}:${server.port}/`);
|
|
4428
|
-
console.log(`Put HTTPS in front of it, then share through it: rech share
|
|
4647
|
+
console.log(`Put HTTPS in front of it, then share through it: rech share <profile> --webrtc=https://<your-host>/`);
|
|
4429
4648
|
}
|
|
4430
4649
|
|
|
4431
4650
|
async function status(): Promise<void> {
|
|
@@ -4511,6 +4730,7 @@ export type RechHandlers = {
|
|
|
4511
4730
|
status(): Promise<void>;
|
|
4512
4731
|
listListeners(): Promise<void>;
|
|
4513
4732
|
addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
|
|
4733
|
+
addWebrtcListener(name: string, opts: { webrtc: string; turn?: string[]; turnEnv?: string[] }): Promise<void>;
|
|
4514
4734
|
removeListener(name: string): Promise<void>;
|
|
4515
4735
|
listProfiles(): Promise<void>;
|
|
4516
4736
|
removeProfile(selector: string, opts: { yes?: boolean; close?: boolean }): Promise<void>;
|
|
@@ -4549,10 +4769,12 @@ const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from s
|
|
|
4549
4769
|
Usage: rech <command> [options] · rech <browser-command> [args]`;
|
|
4550
4770
|
|
|
4551
4771
|
const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
4552
|
-
rech [--profile <p>] [--isolate] <browser-command> [args]
|
|
4772
|
+
rech [--profile <p>] [--isolate[=<name>]] <browser-command> [args]
|
|
4553
4773
|
open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
|
|
4554
4774
|
--profile <p> as another registered profile (email, name or folder); put it first
|
|
4555
|
-
--isolate in a throwaway session
|
|
4775
|
+
--isolate in a throwaway session (a new one per command)
|
|
4776
|
+
--isolate=<n> in the named isolated session <n>: made on first use, reused by later
|
|
4777
|
+
commands (same tabs, same page) until \`rech session close <n>\`
|
|
4556
4778
|
rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
|
|
4557
4779
|
type a password / TOTP code without it reaching argv, logs or output
|
|
4558
4780
|
rech read-secret <ref> --allow-domain <host> | <vault command>
|
|
@@ -4575,7 +4797,8 @@ Examples:
|
|
|
4575
4797
|
rech setup --profile you@example.com set up Chrome on this machine
|
|
4576
4798
|
rech open https://example.com open a page in this project's session
|
|
4577
4799
|
rech screenshot saved to <project>/.rechrome/output/
|
|
4578
|
-
rech share
|
|
4800
|
+
rech share pick a profile and how to share it (in a terminal)
|
|
4801
|
+
rech share you@example.com --webrtc invite link for another machine (WebRTC, no open port)
|
|
4579
4802
|
rech connect '<link>' use that link in this project
|
|
4580
4803
|
rech share ls shares, devices, expiry
|
|
4581
4804
|
|
|
@@ -4616,11 +4839,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4616
4839
|
throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
|
|
4617
4840
|
})
|
|
4618
4841
|
// Share with and connect from other machines
|
|
4619
|
-
.command("share [profiles..]", "Share profiles: `share
|
|
4842
|
+
.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
|
|
4620
4843
|
.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" })
|
|
4621
4844
|
.option("name", { type: "string", requiresArg: true, describe: "With new: the share's name (default: from the first profile)" })
|
|
4622
|
-
.option("via", { type: "string", array: true, requiresArg: true, describe: "
|
|
4623
|
-
.option("
|
|
4845
|
+
.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" })
|
|
4846
|
+
.option("lan", { type: "boolean", describe: "Share over this network (a LAN listener, made if needed)" })
|
|
4847
|
+
.option("tailscale", { type: "boolean", describe: "Share over your tailnet (a tailscale listener, made if needed)" })
|
|
4848
|
+
.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` })
|
|
4849
|
+
.option("signal", { type: "string", requiresArg: true, describe: `Signal server for a bare --webrtc / --via webrtc (else RECH_SIGNAL, else ${DEFAULT_SIGNAL})` })
|
|
4624
4850
|
.option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
|
|
4625
4851
|
.option("invite-ttl", { type: "string", requiresArg: true, describe: "With new: an unused invite stops working after this (default 24h)" })
|
|
4626
4852
|
.option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
|
|
@@ -4633,7 +4859,16 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4633
4859
|
a => {
|
|
4634
4860
|
const profiles = (a.profiles ?? []) as string[];
|
|
4635
4861
|
const [action, target, ...rest] = profiles;
|
|
4636
|
-
|
|
4862
|
+
const webrtc = a.webrtc === undefined ? undefined : ([a.webrtc].flat() as string[]).map(String);
|
|
4863
|
+
const transport = { via: a.via as string[] | undefined, webrtc, lan: a.lan, tailscale: a.tailscale, signal: a.signal };
|
|
4864
|
+
const newOpts = { name: a.name, ...transport, expires: a.expires, inviteTtl: a.inviteTtl, devices: a.devices, approve: a.approve };
|
|
4865
|
+
if (action === "new") return handlers.shareNew(profiles.slice(1), newOpts);
|
|
4866
|
+
// Transport flags make a share (an invite link) for the named profiles.
|
|
4867
|
+
if (transportTokens(transport).length && !(action && SHARE_ACTIONS.has(action)) && !(profiles.length === 1 && ["ls", "list"].includes(profiles[0]))) {
|
|
4868
|
+
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.");
|
|
4869
|
+
if (!profiles.length) throw new Error("Which profiles? rech share <profile..> --lan|--tailscale|--webrtc|--via <listener> (see rech profile)");
|
|
4870
|
+
return handlers.shareNew(profiles, newOpts);
|
|
4871
|
+
}
|
|
4637
4872
|
if (action && SHARE_ACTIONS.has(action)) {
|
|
4638
4873
|
if (!target) throw new Error(`Which share? rech share ${action} <share> (see rech share ls)`);
|
|
4639
4874
|
if (action === "devices") return handlers.shareDevices(target, a.rm);
|
|
@@ -4657,13 +4892,23 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4657
4892
|
a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
|
|
4658
4893
|
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
4659
4894
|
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
4660
|
-
.command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
|
|
4895
|
+
.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
|
|
4661
4896
|
.positional("name", { type: "string", demandOption: true })
|
|
4662
|
-
.option("listen", { type: "string", requiresArg: true,
|
|
4663
|
-
.option("profile", { type: "string", array: true, requiresArg: true,
|
|
4897
|
+
.option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
4898
|
+
.option("profile", { type: "string", array: true, requiresArg: true, describe: "Allowed profile; repeat for several" })
|
|
4664
4899
|
.option("port", portOption)
|
|
4665
|
-
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" })
|
|
4666
|
-
|
|
4900
|
+
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" })
|
|
4901
|
+
.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)" })
|
|
4902
|
+
.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" })
|
|
4903
|
+
.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" })
|
|
4904
|
+
.check(a => a.webrtc !== undefined
|
|
4905
|
+
? (a.listen || a.profile || a.port || a.prefix ? "--webrtc takes no --listen/--profile/--port/--prefix" : true)
|
|
4906
|
+
: a.turn || a.turnEnv ? "--turn/--turn-env go with --webrtc <signal-url>"
|
|
4907
|
+
: a.listen && a.profile?.length ? true : "Pass --listen <where> --profile <profile>, or --webrtc <signal-url>")
|
|
4908
|
+
.example("rech listener add home --webrtc=https://signal.example.com/ --turn-env MY_TURN_URL", ""),
|
|
4909
|
+
a => a.webrtc !== undefined
|
|
4910
|
+
? handlers.addWebrtcListener(a.name, { webrtc: a.webrtc, turn: a.turn as string[] | undefined, turnEnv: a.turnEnv as string[] | undefined })
|
|
4911
|
+
: handlers.addListener(a.name, { listen: a.listen!, profile: a.profile as string[], port: a.port, prefix: a.prefix }))
|
|
4667
4912
|
.command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
|
|
4668
4913
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
4669
4914
|
a => handlers.allowListener(a.name, a.profiles))
|
|
@@ -4689,7 +4934,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4689
4934
|
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
4690
4935
|
a => handlers.removeListener(a.name))
|
|
4691
4936
|
.demandCommand(1).strict())
|
|
4692
|
-
.command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name>", y => y
|
|
4937
|
+
.command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name> (also an --isolate=<name> session)", y => y
|
|
4693
4938
|
.positional("action", { type: "string", choices: ["ls", "list", "close"], default: "ls" })
|
|
4694
4939
|
.positional("name", { type: "string", describe: "With close: the session (as `rech session ls` shows it)" }),
|
|
4695
4940
|
a => handlers.session(a.action === "list" ? "ls" : a.action!, a.name))
|
|
@@ -4732,7 +4977,7 @@ if (import.meta.main) {
|
|
|
4732
4977
|
const handlers: RechHandlers = {
|
|
4733
4978
|
serve: async () => { const { serve } = await import("./serve.ts"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
4734
4979
|
status,
|
|
4735
|
-
listListeners, addListener, removeListener, printProfileUri,
|
|
4980
|
+
listListeners, addListener, addWebrtcListener, removeListener, printProfileUri,
|
|
4736
4981
|
listProfiles: async () => {
|
|
4737
4982
|
const raw = process.env[ENV_KEY];
|
|
4738
4983
|
const url = raw ? await transportUrl(raw) : raw;
|
|
@@ -4836,10 +5081,13 @@ if (import.meta.main) {
|
|
|
4836
5081
|
// flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
|
|
4837
5082
|
// marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
|
|
4838
5083
|
// OAuth drive can't leak an orphaned browser context.
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
5084
|
+
// --isolate=<name>: a named isolated session (-s=iso_<name>), kept across commands until
|
|
5085
|
+
// `rech session close <name>`, for multi-step flows (email, password, TOTP).
|
|
5086
|
+
try { args = applyIsolateArg(args); }
|
|
5087
|
+
catch (err) {
|
|
5088
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
5089
|
+
envWatcher?.close();
|
|
5090
|
+
process.exit(1);
|
|
4843
5091
|
}
|
|
4844
5092
|
args = [...forwarded, ...args];
|
|
4845
5093
|
await run(url, args, overrideEnv, { verbatim: separator !== -1 });
|