rechrome 1.36.1 → 1.37.1

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
@@ -13,8 +13,12 @@ import { join, basename, dirname, resolve } from "path";
13
13
  import { pathToFileURL } from "url";
14
14
  import { createRequire } from "node:module";
15
15
  import { spawn as cpSpawn } from "child_process";
16
- import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
16
+ import { readFile, writeFile, rename, chmod, mkdir, stat } from "node:fs/promises";
17
17
  import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
18
+ import { DEFAULT_SIGNAL, WEBRTC, createShare, findDevice, findShare, inviteLink, inviteProblem, parseDuration, readShares, updateShares, type Share } from "./shares.js";
19
+ import { bearerOf, looksLikeToken, newToken, parseToken, redactSecrets } from "./secret.js";
20
+ import { WebrtcClient, loadRTC, runBridge, spawnBridge } from "./webrtc.js";
21
+ import { startSignalRelay } from "./signal-relay.js";
18
22
 
19
23
  export const ENV_KEY = "RECHROME_URL";
20
24
  export const DEFAULT_PORT = 13775;
@@ -322,8 +326,11 @@ export function parseUrl(raw: string) {
322
326
  const scheme = u.protocol.replace(":", "");
323
327
  const protocol = scheme === "https" ? "https" : "http";
324
328
  const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
329
+ // A rech1_ share link authenticates with the bearer derived from its token (secret.js).
330
+ const shareToken = u.hash.slice(1).split("&")[0];
331
+ const shareBearer = looksLikeToken(shareToken) ? (() => { try { return bearerOf(shareToken); } catch { return undefined; } })() : undefined;
325
332
  return {
326
- key: fragment.get("key") ?? u.username,
333
+ key: shareBearer ?? fragment.get("key") ?? u.username,
327
334
  prefix: normalizePrefix(u.pathname),
328
335
  host: u.hostname,
329
336
  port: parseInt(u.port) || defaultPort,
@@ -1066,13 +1073,16 @@ async function findInstalledExtension(
1066
1073
  // Read each profile's settings once so we can prioritize stable-ID matches over path fallbacks.
1067
1074
  const perProfile: Array<{ prof: string; settings: Record<string, any> }> = [];
1068
1075
  for (const prof of profiles) {
1069
- const prefsPath = join(userDataDir, prof, "Secure Preferences");
1070
- const f = file(prefsPath);
1071
- if (!(await f.exists())) continue;
1072
- try {
1073
- const data = JSON.parse(await f.text());
1074
- perProfile.push({ prof, settings: (data?.extensions?.settings ?? {}) as Record<string, any> });
1075
- } catch {}
1076
+ const settings: Record<string, any> = {};
1077
+ // Chrome may store unpacked extensions in either preferences file.
1078
+ // Secure Preferences wins when both contain the same extension.
1079
+ for (const name of ["Preferences", "Secure Preferences"]) {
1080
+ try {
1081
+ const data = await file(join(userDataDir, prof, name)).json();
1082
+ Object.assign(settings, data?.extensions?.settings ?? {});
1083
+ } catch {}
1084
+ }
1085
+ perProfile.push({ prof, settings });
1076
1086
  }
1077
1087
  // Pass 1: stable ID match (manifest `key` set, path-independent). This must win over any path
1078
1088
  // fallback so a stale legacy install sitting on a known path can't shadow the current extension.
@@ -1329,6 +1339,16 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
1329
1339
 
1330
1340
  /** On a client of a remote host, `rech profile` lists what that host's link shares. */
1331
1341
  async function listRemoteProfiles(url: string): Promise<void> {
1342
+ // Newer hosts list names too (/profiles); older ones only /ping.
1343
+ const detailed = await fetch(serviceUrl(url, "profiles"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) })
1344
+ .then(r => r.ok ? r.json() : null).catch(() => null) as { share?: string; profiles?: { key: string; name: string }[] } | null;
1345
+ if (detailed?.profiles) {
1346
+ const current = resolveEffectiveProfile(parseUrl(url).profileDirectory);
1347
+ console.log(`Profiles ${detailed.share ? `share "${detailed.share}" gives you` : `shared by ${serviceUrl(url)}`}:`);
1348
+ for (const p of detailed.profiles) console.log(` ${p.key}${p.name ? ` (${p.name})` : ""}${p.key === current ? " ← current" : ""}`);
1349
+ console.log(`\nUse one: rech --profile <name> open https://example.com`);
1350
+ return;
1351
+ }
1332
1352
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
1333
1353
  .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
1334
1354
  if (response.status === 401) throw new Error("The host rejected this link's key; ask for a fresh one (rech share on the host), then rech connect '<url>'.");
@@ -1559,7 +1579,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
1559
1579
  if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1560
1580
  console.log(uri);
1561
1581
  console.error(snapshot.length === 1 ? `On the other machine: rech connect '<this link>'` : `On the other machine: rech connect '<this link>', then rech --profile <name> open <url>`);
1562
- if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1582
+ if (opts.save) { const saved = await saveProjectUrl(uri); console.error(`Saved RECHROME_URL to ${saved}`); await warnSecretFile(saved); }
1563
1583
  }
1564
1584
 
1565
1585
  /** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
@@ -1807,7 +1827,7 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1807
1827
  const publicUrl = pending?.publicUrl;
1808
1828
  if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
1809
1829
  console.log(uri);
1810
- if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1830
+ if (opts.save) { const saved = await saveProjectUrl(uri); console.error(`Saved RECHROME_URL to ${saved}`); await warnSecretFile(saved); }
1811
1831
  }
1812
1832
 
1813
1833
  export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
@@ -1823,6 +1843,8 @@ export function sandboxConnectionWarning(env: Record<string, string | undefined>
1823
1843
  * profiles itself, and must never receive this machine's own extension tokens.
1824
1844
  */
1825
1845
  export async function isLocalDaemon(url: string): Promise<boolean> {
1846
+ // A WebRTC bridge is on loopback but always leads to another machine.
1847
+ if (bridgedUrls.has(url)) return false;
1826
1848
  const { key, host } = parseUrl(url);
1827
1849
  const config = await readListeners().catch(() => null);
1828
1850
  // Before listeners.json existed, a local install pointed at loopback and had its own registry;
@@ -3170,6 +3192,38 @@ async function urlList(): Promise<void> {
3170
3192
  console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
3171
3193
  }
3172
3194
 
3195
+ /**
3196
+ * Ways an env file holding a connection secret could leak: tracked or committed in git, not
3197
+ * git-ignored (one `git add .` away), or readable by other users. Warnings only — rech keeps
3198
+ * working; the caller prints them. Empty when the file looks safe or doesn't exist.
3199
+ */
3200
+ export async function secretFileWarnings(path: string, platform = process.platform): Promise<string[]> {
3201
+ const warnings: string[] = [];
3202
+ const st = await stat(path).catch(() => null);
3203
+ if (!st?.isFile()) return warnings;
3204
+ const dir = dirname(path), name = basename(path);
3205
+ if (await gitOutput(["rev-parse", "--is-inside-work-tree"], dir) === "true") {
3206
+ const untrack = `git rm --cached ${JSON.stringify(name)} (in ${dir}) and add it to .gitignore`;
3207
+ // Only a currently tracked file counts: an old commit of a since-untracked file held an older key.
3208
+ if (await gitOutput(["ls-files", "--", name], dir)) {
3209
+ if (await gitOutput(["log", "-1", "--format=%h", "--", name], dir))
3210
+ warnings.push(`${path} is committed to git, so its key is in the repo history. Ask the host for a fresh link (they rotate the key), then ${untrack}.`);
3211
+ else warnings.push(`${path} is staged in git; committing it would publish its key. Run ${untrack}.`);
3212
+ }
3213
+ else if (!(await gitOutput(["check-ignore", "--", name], dir)))
3214
+ warnings.push(`${path} is not git-ignored; a \`git add .\` would commit its key. Add it to .gitignore.`);
3215
+ }
3216
+ if (platform !== "win32" && (st.mode & 0o077))
3217
+ warnings.push(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}). Run: chmod 600 ${JSON.stringify(path)}`);
3218
+ return warnings;
3219
+ }
3220
+
3221
+ /** Print secretFileWarnings for a file that holds RECHROME_URL. Never throws. */
3222
+ async function warnSecretFile(path: string | undefined): Promise<void> {
3223
+ if (!path || path === "environment") return;
3224
+ for (const w of await secretFileWarnings(path).catch(() => [])) console.error(`[rech] ⚠ ${w}`);
3225
+ }
3226
+
3173
3227
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
3174
3228
  async function saveProjectUrl(url: string): Promise<string> {
3175
3229
  const { dataDir } = await getClientIdentity();
@@ -3210,8 +3264,15 @@ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GL
3210
3264
  }
3211
3265
  }
3212
3266
 
3213
- async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
3267
+ type ConnectedShare = { share?: string; device?: { id: string; name: string; approved: boolean }; profiles: string[]; webrtc: boolean };
3268
+
3269
+ async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}, shared?: ConnectedShare): Promise<void> {
3214
3270
  if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
3271
+ // A bare rech1_ token means the default signal server.
3272
+ if (looksLikeToken(url)) url = `${DEFAULT_SIGNAL}#${url.trim()}`;
3273
+ const share = shareLinkParts(url);
3274
+ if (share && !shared) return connectShare(url, share, opts);
3275
+ if (share && shared) return saveConnection(url, opts, shared);
3215
3276
  const parsed = parseUrl(url);
3216
3277
  if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
3217
3278
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
@@ -3237,6 +3298,7 @@ async function connect(url: string, opts: { global?: boolean; project?: boolean
3237
3298
  console.error(`[rech] warning: this machine runs its own rech daemon. Projects without their own RECHROME_URL will now use ${serviceUrl(url)}, not this machine's Chrome. Undo: remove ${ENV_KEY} from ${GLOBAL_ENV_FILE}`);
3238
3299
  const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
3239
3300
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
3301
+ await warnSecretFile(saved);
3240
3302
  const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
3241
3303
  if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
3242
3304
  if (Array.isArray(body.profiles) && body.profiles.length > 1) {
@@ -3246,6 +3308,32 @@ async function connect(url: string, opts: { global?: boolean; project?: boolean
3246
3308
  }
3247
3309
  }
3248
3310
 
3311
+ /** Save a share device's link (after connectShare redeemed or checked it) and say what it gives. */
3312
+ async function saveConnection(url: string, opts: { global?: boolean; project?: boolean }, shared: ConnectedShare): Promise<void> {
3313
+ let global = !!opts.global;
3314
+ if (!opts.global && !opts.project && isInteractive()) {
3315
+ const { dataDir } = await getClientIdentity();
3316
+ const choice = await promptChoice("Use this link for:", [
3317
+ { label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
3318
+ { label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
3319
+ ], 0);
3320
+ if (!choice) throw new Error("Cancelled; nothing saved.");
3321
+ global = choice === "global";
3322
+ }
3323
+ const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
3324
+ await warnSecretFile(saved);
3325
+ const via = shared.webrtc ? `WebRTC via ${new URL(url).host}` : serviceUrl(url);
3326
+ console.log(`Connected to share "${shared.share ?? "?"}" (${via}) as device ${shared.device?.name ?? "?"} (${shared.device?.id ?? "?"}).`);
3327
+ console.log(`Saved this device's own link ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
3328
+ console.log(`The invite you used no longer works for anyone else.`);
3329
+ if (shared.device && !shared.device.approved) console.log(`\nThe host must approve this device before it can drive the browser: rech share approve ${shared.share} ${shared.device.id}`);
3330
+ if (shared.profiles.length > 1) {
3331
+ console.log(`\nThis share has ${shared.profiles.length} profiles:`);
3332
+ for (const p of shared.profiles) console.log(` ${p}`);
3333
+ console.log(`Pick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
3334
+ } else if (shared.profiles.length === 1) console.log(`Profile: ${shared.profiles[0]}. Try: rech open https://example.com`);
3335
+ }
3336
+
3249
3337
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
3250
3338
  // These are hints, not proof of identity or permission to automate the desktop.
3251
3339
  if (env.RECH_SETUP_AGENT === "none") return null;
@@ -3641,30 +3729,326 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3641
3729
  console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
3642
3730
  }
3643
3731
 
3732
+ // ---- shares: invite links, devices, transports (see shares.js) ----------------------------
3733
+
3734
+ const fmtDate = (ms?: number) => ms ? new Date(ms).toISOString().slice(0, 16).replace("T", " ") : "-";
3735
+ const fmtDay = (ms?: number) => ms ? new Date(ms).toISOString().slice(0, 10) : "never";
3736
+
3737
+ /** A listener to carry a share over lan/tailscale/<IP>/<listener name>; created when missing. */
3738
+ async function shareTransportListener(selector: string, profiles: string[], label: string): Promise<string> {
3739
+ const config = await requireListeners();
3740
+ const named = config.listeners.find(l => l.name === selector);
3741
+ if (named) {
3742
+ if (named.profiles === "*") throw new Error(`"${selector}" is the local management listener; share over lan, tailscale or webrtc instead.`);
3743
+ return named.name;
3744
+ }
3745
+ const address = chooseListenAddress(await detectListenChoices(), selector);
3746
+ const existing = config.listeners.find(l => l.host === address && l.profiles !== "*" && normalizePrefix(l.prefix) === "/");
3747
+ if (existing) return existing.name;
3748
+ const listener: Listener = { name: `share-${label}`.slice(0, 40), host: address, port: freeListenerPort(config.listeners), key: randomBytes(24).toString("base64url"), profiles };
3749
+ config.listeners.push(listener);
3750
+ await writeListeners(config);
3751
+ if (!(await waitForListener(listener))) console.error(`[rech] warning: the daemon has not opened ${listenerAddress(listener)} yet; is it running? (rech status)`);
3752
+ return listener.name;
3753
+ }
3754
+
3755
+ /** Where a share's invite can be used, as full links (each holds the secret). */
3756
+ async function shareLinks(share: Share): Promise<{ via: string; link: string }[]> {
3757
+ const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
3758
+ return share.via.map(via => {
3759
+ if (via === WEBRTC) return { via: "webrtc", link: inviteLink(share, share.signal ?? DEFAULT_SIGNAL) };
3760
+ const l = listeners.find(x => x.name === via);
3761
+ if (!l) return { via, link: "(listener removed)" };
3762
+ return { via: l.name, link: inviteLink(share, l.publicUrl ?? `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`) };
3763
+ });
3764
+ }
3765
+
3766
+ function printInvite(share: Share, links: { via: string; link: string }[]): void {
3767
+ const expiry = share.inviteExpiresAt ? `unused, it expires ${fmtDate(share.inviteExpiresAt)}` : "it never expires unused";
3768
+ console.log(`Share "${share.label}": ${share.profiles.join(", ")}`);
3769
+ console.log(`Invite for ${share.maxDevices === 1 ? "one device" : `${share.maxDevices} devices`} (${expiry}; secret: send it privately):\n`);
3770
+ for (const { via, link } of links) console.log(` ${links.length > 1 ? `${via.padEnd(10)} ` : ""}${link}`);
3771
+ const rtc = links.find(l => l.via === "webrtc");
3772
+ if (rtc && share.signal === DEFAULT_SIGNAL) console.log(`\n or in a terminal: rech connect ${share.invite}`);
3773
+ console.log(`\nThe other machine runs: rech connect '<link>'`);
3774
+ if (share.approve) console.log(`Each new device waits for you: rech share approve ${share.label}`);
3775
+ console.log(`Share expires: ${fmtDay(share.expiresAt)}${share.expiresAt ? ` (renew: rech share renew ${share.label})` : ""}`);
3776
+ }
3777
+
3778
+ export type ShareNewOptions = { name?: string; via?: string[]; signal?: string; expires?: string; inviteTtl?: string; devices?: number; approve?: boolean };
3779
+
3780
+ async function shareNew(selectors: string[], opts: ShareNewOptions): Promise<void> {
3781
+ if (!selectors.length) throw new Error("Which profiles? rech share new <profile..> (see rech profile)");
3782
+ const profiles = await resolveProfileKeys(selectors);
3783
+ // An explicit --name must be free; a derived one gets -2, -3… so a second share just works.
3784
+ const taken = new Set((await readShares()).shares.map(s => s.label));
3785
+ const base = profileSlug(profiles[0]).slice(0, 36);
3786
+ let label = (opts.name ?? base).toLowerCase();
3787
+ for (let i = 2; !opts.name && taken.has(label); i++) label = `${base}-${i}`;
3788
+ // Fail on the name before binding any listener for it.
3789
+ if (taken.has(label)) throw new Error(`A share named "${label}" already exists. Pick another --name, or: rech share rotate ${label}`);
3790
+ 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}"`);
3791
+ const wanted = (opts.via?.length ? opts.via : [WEBRTC]).flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean);
3792
+ const via: string[] = [];
3793
+ for (const v of wanted) via.push(v === WEBRTC ? WEBRTC : await shareTransportListener(v, profiles, label));
3794
+ const expires = opts.expires ?? (via.includes(WEBRTC) ? "30d" : "never");
3795
+ const expiresMs = parseDuration(expires);
3796
+ const share = await updateShares(config => createShare(config, {
3797
+ label, profiles, via, signal: opts.signal ?? process.env.RECH_SIGNAL,
3798
+ expiresAt: expiresMs ? Date.now() + expiresMs : undefined,
3799
+ inviteTtlMs: parseDuration(opts.inviteTtl ?? "24h"),
3800
+ maxDevices: opts.devices ?? 1, approve: opts.approve,
3801
+ }));
3802
+ // The daemon answers WebRTC peers with node-datachannel: install it now, not on the first peer.
3803
+ if (via.includes(WEBRTC)) await loadRTC().catch(error => console.error(`[rech] warning: ${error instanceof Error ? error.message : error}`));
3804
+ printInvite(share, await shareLinks(share));
3805
+ }
3806
+
3807
+ async function shareList(): Promise<void> {
3808
+ const { shares } = await readShares();
3809
+ if (shares.length) {
3810
+ const now = Date.now();
3811
+ const rows = [["SHARE", "PROFILES", "VIA", "DEVICES", "EXPIRES", "STATUS"]];
3812
+ for (const s of shares) {
3813
+ const pending = s.devices.filter(d => !d.approved).length;
3814
+ const status = s.revokedAt ? "revoked" : s.expiresAt && now >= s.expiresAt ? "expired"
3815
+ : inviteProblem(s, now) ? (pending ? `${pending} waiting for approval` : "active") : `invite open${pending ? `, ${pending} waiting` : ""}`;
3816
+ rows.push([s.label, s.profiles.join(", "), s.via.join(", "), `${s.devices.length}/${s.maxDevices}`, fmtDay(s.expiresAt), status]);
3817
+ }
3818
+ printTable(rows);
3819
+ console.log(`\nDevices: rech share devices <share> · invite link again: rech share rotate <share> · stop: rech share revoke <share>\n`);
3820
+ }
3821
+ const listeners = await readListeners().catch(() => null);
3822
+ if (listeners) {
3823
+ if (shares.length) console.log("Listener links (legacy #key=…):");
3824
+ await urlList();
3825
+ } else if (!shares.length) console.log("Nothing shared yet. Share a profile: rech share new <profile>");
3826
+ }
3827
+
3828
+ async function shareDevices(name: string, remove?: string): Promise<void> {
3829
+ if (remove) {
3830
+ const device = await updateShares(config => {
3831
+ const share = findShare(config, name);
3832
+ const d = findDevice(share, remove);
3833
+ share.devices = share.devices.filter(x => x !== d);
3834
+ return d;
3835
+ });
3836
+ console.log(`Removed ${device.name} (${device.id}) from "${name}"; its sessions close and its link stops working.`);
3837
+ return;
3838
+ }
3839
+ const share = findShare(await readShares(), name);
3840
+ if (!share.devices.length) { console.log(`No device has used "${share.label}" yet.${inviteProblem(share) ? "" : " Its invite is still open."}`); return; }
3841
+ const rows = [["DEVICE", "NAME", "APPROVED", "ADDED", "LAST USED"]];
3842
+ for (const d of share.devices) rows.push([d.id, d.name, d.approved ? "yes" : "waiting", fmtDate(d.addedAt), fmtDate(d.lastSeen)]);
3843
+ printTable(rows);
3844
+ }
3845
+
3846
+ async function shareEdit(action: string, name: string, args: string[], opts: { expires?: string } = {}): Promise<void> {
3847
+ if (action === "allow" || action === "deny") {
3848
+ if (!args.length) throw new Error(`Which profiles? rech share ${action} ${name} <profile..>`);
3849
+ const keys = await resolveProfileKeys(args);
3850
+ const share = await updateShares(config => {
3851
+ const s = findShare(config, name);
3852
+ const next = action === "allow" ? [...new Set([...s.profiles, ...keys])] : s.profiles.filter(p => !keys.includes(p));
3853
+ if (!next.length) throw new Error(`That would leave "${s.label}" with no profiles; revoke it instead: rech share revoke ${s.label}`);
3854
+ s.profiles = next;
3855
+ return s;
3856
+ });
3857
+ console.log(`"${share.label}" now shares: ${share.profiles.join(", ")} (connected devices see it on their next command; the link is unchanged).`);
3858
+ } else if (action === "rotate") {
3859
+ const share = await updateShares(config => {
3860
+ const s = findShare(config, name);
3861
+ s.invite = newToken();
3862
+ s.inviteExpiresAt = s.inviteTtlMs ? Date.now() + s.inviteTtlMs : undefined;
3863
+ if (s.devices.length >= s.maxDevices) s.maxDevices = s.devices.length + 1;
3864
+ return s;
3865
+ });
3866
+ console.log("New invite; the previous invite link no longer works (devices already connected keep working).\n");
3867
+ printInvite(share, await shareLinks(share));
3868
+ } else if (action === "revoke") {
3869
+ await updateShares(config => { findShare(config, name).revokedAt = Date.now(); });
3870
+ console.log(`Revoked "${name}": its links and devices stop working, and its sessions close. Remove it entirely: rech share rm ${name}`);
3871
+ } else if (action === "rm") {
3872
+ await updateShares(config => { const s = findShare(config, name); config.shares = config.shares.filter(x => x !== s); });
3873
+ console.log(`Removed "${name}".`);
3874
+ } else if (action === "renew") {
3875
+ const ms = parseDuration(opts.expires ?? args[0] ?? "30d");
3876
+ const share = await updateShares(config => {
3877
+ const s = findShare(config, name);
3878
+ if (s.revokedAt) throw new Error(`"${s.label}" was revoked; create a new share instead: rech share new ${s.profiles.join(" ")}`);
3879
+ s.expiresAt = ms ? Date.now() + ms : undefined;
3880
+ return s;
3881
+ });
3882
+ console.log(`"${share.label}" now expires: ${fmtDay(share.expiresAt)}`);
3883
+ } else if (action === "approve") {
3884
+ const approved = await updateShares(config => {
3885
+ const s = findShare(config, name);
3886
+ const targets = args.length ? args.map(a => findDevice(s, a)) : s.devices.filter(d => !d.approved);
3887
+ for (const d of targets) d.approved = true;
3888
+ return targets;
3889
+ });
3890
+ console.log(approved.length ? `Approved: ${approved.map(d => `${d.name} (${d.id})`).join(", ")}` : `No device of "${name}" is waiting for approval.`);
3891
+ }
3892
+ }
3893
+
3894
+ // ---- client side of a rech1_ link -----------------------------------------------------------
3895
+
3896
+ /** A rech1_ link's parts: the token, the origin it points at, and the transport once known. */
3897
+ export function shareLinkParts(url: string): { token: string; origin: string; webrtc: boolean; profile?: string } | null {
3898
+ let u: URL;
3899
+ try { u = new URL(url); } catch { return null; }
3900
+ const [token, ...rest] = u.hash.slice(1).split("&");
3901
+ if (!looksLikeToken(token)) return null;
3902
+ const params = new URLSearchParams(rest.join("&"));
3903
+ return { token, origin: `${u.protocol}//${u.host}${normalizePrefix(u.pathname)}`, webrtc: params.get("via") === WEBRTC, profile: u.searchParams.get("profile") ?? undefined };
3904
+ }
3905
+
3906
+ /** Call a share's HTTP API over whichever transport its link uses (one-shot, for connect). */
3907
+ async function shareRequest(origin: string, token: string, webrtc: boolean, method: string, path: string, body?: unknown): Promise<Response> {
3908
+ if (!webrtc) return fetch(serviceUrl(origin, path), {
3909
+ method, headers: { Authorization: `Bearer ${bearerOf(token)}`, ...(body ? { "Content-Type": "application/json" } : {}) },
3910
+ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10_000),
3911
+ });
3912
+ const client = new WebrtcClient(token, origin, m => console.error(m));
3913
+ await client.connect();
3914
+ try {
3915
+ const res = await client.request(method, `/${path}`, body ? JSON.stringify(body) : undefined, body ? "application/json" : undefined);
3916
+ return new Response(await res.arrayBuffer(), { status: res.status, headers: res.headers });
3917
+ } finally { client.close(); }
3918
+ }
3919
+
3920
+ /** `rech connect` for a rech1_ link: find the transport, redeem the invite, save this device's link. */
3921
+ async function connectShare(url: string, parts: { token: string; origin: string }, opts: { global?: boolean; project?: boolean }): Promise<void> {
3922
+ const parsed = parseToken(parts.token);
3923
+ if (!parsed.ok) throw new Error(parsed.reason === "checksum"
3924
+ ? "This rech1_ link is damaged (checksum mismatch): it was copied incompletely or mistyped. Ask for it again."
3925
+ : "That is not a complete rech1_ link.");
3926
+ // One unauthenticated probe tells a signal server (WebRTC) from a rech daemon (direct HTTP).
3927
+ // The bearer is a working HTTP credential, so it never goes to a signal server.
3928
+ const get = (auth: boolean) => fetch(serviceUrl(parts.origin, "ping"), { headers: auth ? { Authorization: `Bearer ${bearerOf(parts.token)}` } : {}, signal: AbortSignal.timeout(8000) })
3929
+ .catch(error => { throw new Error(`Could not reach ${parts.origin}: ${error instanceof Error ? error.message : error}`); });
3930
+ const read = async (r: Response) => { const text = await r.text().catch(() => ""); try { return { text, body: JSON.parse(text) }; } catch { return { text, body: null as any }; } };
3931
+ let probe = await get(false);
3932
+ let { text: probeText, body: probeBody } = await read(probe);
3933
+ if (probe.status === 503 && !probeBody && probeText.trim()) throw new Error(probeText.trim());
3934
+ const webrtc = !!probeBody?.["rech-signal"];
3935
+ if (!webrtc && probe.status === 401) ({ text: probeText, body: probeBody } = await read(probe = await get(true)));
3936
+ if (!webrtc && probe.status === 401) throw new Error("The host rejected this link. Ask for a fresh one (rech share rotate <share> on the host).");
3937
+ if (!webrtc && !probe.ok) throw new Error(probeBody?.error ?? `${serviceUrl(parts.origin, "ping")} answered ${probe.status}; is this a rech link?`);
3938
+ if (webrtc) console.error(`[rech] connecting to the host over WebRTC via ${new URL(parts.origin).host}…`);
3939
+ const ping = webrtc ? await (await shareRequest(parts.origin, parts.token, true, "GET", "ping")).json() as any : probeBody;
3940
+ if (ping?.error) throw new Error(ping.error);
3941
+ let token = parts.token, device = ping?.device, profiles: string[] = Array.isArray(ping?.profiles) ? ping.profiles : [];
3942
+ const share = ping?.share;
3943
+ if (ping?.kind === "invite") {
3944
+ const res = await shareRequest(parts.origin, parts.token, webrtc, "POST", "redeem", { name: hostname().replace(/\.local$/, "") });
3945
+ const body = await res.json().catch(() => ({})) as any;
3946
+ if (!res.ok || !body.token) throw new Error(body.error ?? `The host refused this invite (HTTP ${res.status}).`);
3947
+ token = body.token; device = body.device; profiles = body.profiles ?? profiles;
3948
+ }
3949
+ const saved = new URL(parts.origin);
3950
+ if (profiles.length === 1) saved.searchParams.set("profile", profiles[0]);
3951
+ saved.hash = `${token}${webrtc ? `&via=${WEBRTC}` : ""}`;
3952
+ await connect(saved.toString(), opts, { share, device, profiles, webrtc });
3953
+ }
3954
+
3955
+ // ---- WebRTC client bridge -------------------------------------------------------------------
3956
+
3957
+ /** The command that runs this rech (for spawning `rech bridge`). */
3958
+ const selfCommand = () => [process.execPath, Bun.main];
3959
+
3960
+ /**
3961
+ * For a WebRTC link, the loopback URL of a running bridge to it (started when missing); any other
3962
+ * URL comes back unchanged. Everything downstream then speaks plain HTTP.
3963
+ */
3964
+ const bridgedUrls = new Set<string>();
3965
+
3966
+ export async function transportUrl(url: string): Promise<string> {
3967
+ const parts = shareLinkParts(url);
3968
+ if (!parts?.webrtc) return url;
3969
+ const info = await spawnBridge(parts.token, parts.origin, selfCommand());
3970
+ const bridged = new URL(`http://127.0.0.1:${info.port}/`);
3971
+ new URL(url).searchParams.forEach((v, k) => bridged.searchParams.set(k, v));
3972
+ bridged.hash = `key=${info.key}`;
3973
+ bridgedUrls.add(bridged.toString());
3974
+ return bridged.toString();
3975
+ }
3976
+
3977
+ async function bridgeCommand(): Promise<void> {
3978
+ const token = process.env.RECH_BRIDGE_TOKEN, signal = process.env.RECH_BRIDGE_SIGNAL;
3979
+ if (!token || !signal) throw new Error("rech bridge is started by rech itself for WebRTC links.");
3980
+ delete process.env.RECH_BRIDGE_TOKEN;
3981
+ await runBridge(token, signal, { log: m => console.error(m), onStop: () => process.exit(0) });
3982
+ }
3983
+
3984
+ // ---- sessions (client view) -----------------------------------------------------------------
3985
+
3986
+ async function sessionCommand(action: string, name?: string): Promise<void> {
3987
+ const raw = process.env[ENV_KEY];
3988
+ if (!raw) throw new Error(notConnectedMessage());
3989
+ const url = await transportUrl(raw);
3990
+ if (action === "close") {
3991
+ if (!name) throw new Error("Which session? rech session close <name> (see rech session ls)");
3992
+ return run(url, ["close", `-s=${name}`]);
3993
+ }
3994
+ const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
3995
+ const body = await res.json().catch(() => null) as { sessions?: { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean }[]; stderr?: string } | null;
3996
+ 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.`);
3997
+ if (!body.sessions.length) { console.log("No sessions yet (since the host's daemon started)."); return; }
3998
+ const rows = [["SESSION", "PROFILE", "CLIENT", "LAST USED", "STATE"]];
3999
+ for (const s of body.sessions) rows.push([s.session, s.profile ?? "-", s.client ?? "-", fmtDate(s.lastUsed), s.live ? "open" : "closed"]);
4000
+ printTable(rows);
4001
+ console.log(`\nUse one: rech -s=<session> open https://example.com · close it: rech session close <session>`);
4002
+ }
4003
+
4004
+ async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
4005
+ const server = startSignalRelay({ host: opts.host ?? "127.0.0.1", port: opts.port ?? 8787, log });
4006
+ console.log(`rech signal server (rech-signal-1) on http://${server.hostname}:${server.port}/`);
4007
+ console.log(`Put HTTPS in front of it, then share through it: rech share new <profile> --signal https://<your-host>/`);
4008
+ }
4009
+
3644
4010
  async function status(): Promise<void> {
3645
- const url = process.env[ENV_KEY];
3646
- if (!url) {
4011
+ const rawUrl = process.env[ENV_KEY];
4012
+ if (!rawUrl) {
3647
4013
  console.log(`serve: not configured`);
3648
4014
  console.log(notConnectedMessage().split("\n").slice(1).join("\n"));
3649
4015
  return;
3650
4016
  }
4017
+ // A WebRTC share link is checked through its local bridge, like every command.
4018
+ const link = shareLinkParts(rawUrl);
4019
+ let url = rawUrl;
4020
+ if (link?.webrtc) {
4021
+ try {
4022
+ url = await transportUrl(rawUrl);
4023
+ console.log(`via: WebRTC through ${new URL(link.origin).host} (local bridge ${serviceUrl(url)})`);
4024
+ } catch (error) {
4025
+ console.log(`via: ✗ WebRTC through ${new URL(link.origin).host}: ${error instanceof Error ? error.message : error}`);
4026
+ if (rechromeUrlSource && rechromeUrlSource !== "environment") console.log(`config: ${rechromeUrlSource}`);
4027
+ return;
4028
+ }
4029
+ }
3651
4030
  const parsed = parseUrl(url);
3652
4031
  const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
3653
4032
  // The authenticated /ping reports which listener answered, its bind, and the profiles it allows.
3654
4033
  const pingResponse = ping
3655
4034
  ? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
3656
4035
  : null;
3657
- const pingBody = pingResponse?.ok
3658
- ? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number; chromeAccessProblem?: string } | null
4036
+ const pingBody = pingResponse?.ok || pingResponse?.status === 403
4037
+ ? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number; chromeAccessProblem?: string; share?: string; kind?: string; device?: { id: string; name: string; approved: boolean }; expiresAt?: number; error?: string } | null
3659
4038
  : null;
3660
4039
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
3661
4040
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
3662
4041
  // Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
3663
- const answered = pingResponse?.ok || pingResponse?.status === 401;
4042
+ const answered = pingResponse?.ok || pingResponse?.status === 401 || (pingResponse?.status === 403 && !!pingBody?.error);
3664
4043
  console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
3665
4044
  : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
3666
4045
  : `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
3667
4046
  if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
4047
+ if (rechromeUrlSource && rechromeUrlSource !== "environment")
4048
+ for (const w of await secretFileWarnings(rechromeUrlSource).catch(() => [])) console.log(`secret: ⚠ ${w}`);
4049
+ if (pingBody?.share)
4050
+ console.log(`share: ${pingBody.share}${pingBody.device ? `, device ${pingBody.device.name} (${pingBody.device.id})${pingBody.device.approved ? "" : " — ⚠ waiting for the host's approval"}` : pingBody.kind === "invite" ? " — an unused invite: run rech connect '<link>' to register this device" : ""}${pingBody.expiresAt ? `, expires ${fmtDay(pingBody.expiresAt)}` : ""}`);
4051
+ if (pingResponse?.status === 403 && pingBody?.error) console.log(`auth: ✗ ${pingBody.error}`);
3668
4052
  if (pingResponse?.status === 401)
3669
4053
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3670
4054
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3719,10 +4103,19 @@ export type RechHandlers = {
3719
4103
  tray(action?: string): Promise<void>;
3720
4104
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
3721
4105
  uninstall(): Promise<void>;
4106
+ shareNew(profiles: string[], opts: ShareNewOptions): Promise<void>;
4107
+ shareList(): Promise<void>;
4108
+ shareDevices(name: string, remove?: string): Promise<void>;
4109
+ shareEdit(action: string, name: string, args: string[], opts?: { expires?: string }): Promise<void>;
4110
+ session(action: string, name?: string): Promise<void>;
4111
+ signalServe(opts: { host?: string; port?: number }): Promise<void>;
4112
+ bridge(): Promise<void>;
3722
4113
  };
3723
4114
 
4115
+ const SHARE_ACTIONS = new Set(["new", "allow", "deny", "rotate", "revoke", "rm", "renew", "approve", "devices"]);
4116
+
3724
4117
  /** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
3725
- export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "share", "connect", "setup", "tray", "provision-profile", "uninstall"]);
4118
+ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "share", "connect", "session", "sessions", "signal", "bridge", "setup", "tray", "provision-profile", "uninstall"]);
3726
4119
 
3727
4120
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
3728
4121
 
@@ -3750,8 +4143,9 @@ Examples:
3750
4143
  rech setup --profile you@example.com set up Chrome on this machine
3751
4144
  rech open https://example.com open a page in this project's session
3752
4145
  rech screenshot saved to <project>/.rechrome/output/
3753
- rech share you@example.com URL to give another machine (secret)
3754
- rech connect '<url>' use that URL in this project
4146
+ rech share new you@example.com invite link for another machine (WebRTC, no open port)
4147
+ rech connect '<link>' use that link in this project
4148
+ rech share ls shares, devices, expiry
3755
4149
 
3756
4150
  Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
3757
4151
 
@@ -3790,15 +4184,30 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3790
4184
  throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
3791
4185
  })
3792
4186
  // Share with and connect from other machines
3793
- .command("share [profiles..]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
3794
- .positional("profiles", { type: "string", array: true, describe: "Profile(s): email, name, folder, or a unique part of one. Several = one link for all of them. ls/list lists everything shared" })
4187
+ .command("share [profiles..]", "Share profiles: `share new <profile..>` makes an invite link (WebRTC by default); `share ls` lists all", y => y
4188
+ .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" })
4189
+ .option("name", { type: "string", requiresArg: true, describe: "With new: the share's name (default: from the first profile)" })
4190
+ .option("via", { type: "string", array: true, requiresArg: true, describe: "With new: webrtc (default) | lan | tailscale | <IP> | <listener>; repeat or comma-separate for several" })
4191
+ .option("signal", { type: "string", requiresArg: true, describe: `With new: signal server for webrtc (default ${DEFAULT_SIGNAL}, or RECH_SIGNAL)` })
4192
+ .option("expires", { type: "string", requiresArg: true, describe: "With new/renew: 30d, 2w, 24h, never (new default: 30d over webrtc, else never)" })
4193
+ .option("invite-ttl", { type: "string", requiresArg: true, describe: "With new: an unused invite stops working after this (default 24h)" })
4194
+ .option("devices", { type: "number", requiresArg: true, describe: "With new: how many devices may redeem the invite (default 1)" })
4195
+ .option("approve", { type: "boolean", describe: "With new: each new device waits for `rech share approve`" })
4196
+ .option("rm", { type: "string", requiresArg: true, describe: "With devices: remove this device" })
3795
4197
  .option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
3796
4198
  .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
3797
4199
  .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
3798
4200
  .option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
3799
4201
  a => {
3800
4202
  const profiles = (a.profiles ?? []) as string[];
3801
- if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.urlList();
4203
+ const [action, target, ...rest] = profiles;
4204
+ 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 });
4205
+ if (action && SHARE_ACTIONS.has(action)) {
4206
+ if (!target) throw new Error(`Which share? rech share ${action} <share> (see rech share ls)`);
4207
+ if (action === "devices") return handlers.shareDevices(target, a.rm);
4208
+ return handlers.shareEdit(action, target, rest, { expires: a.expires });
4209
+ }
4210
+ if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.shareList();
3802
4211
  if (profiles.length > 1) {
3803
4212
  if (a.all) throw new Error("Pass profiles or --all, not both.");
3804
4213
  return handlers.shareProfiles(profiles, { listener: a.listener, local: a.local, save: a.save });
@@ -3806,11 +4215,12 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3806
4215
  return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
3807
4216
  })
3808
4217
  .command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
3809
- .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
4218
+ .positional("url", { type: "string", demandOption: true, describe: "The link from `rech share` on the machine with Chrome (or a bare rech1_ token). Quote it: it contains #" })
3810
4219
  .option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
3811
4220
  .option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
3812
4221
  .conflicts("project", "global")
3813
4222
  .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", "")
4223
+ .example("rech connect 'https://rechrome.pages.dev/#rech1_…'", "An invite: registers this device, then saves its own link")
3814
4224
  .example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
3815
4225
  a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
3816
4226
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
@@ -3841,6 +4251,16 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3841
4251
  .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
3842
4252
  a => handlers.removeListener(a.name))
3843
4253
  .demandCommand(1).strict())
4254
+ .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name>", y => y
4255
+ .positional("action", { type: "string", choices: ["ls", "list", "close"], default: "ls" })
4256
+ .positional("name", { type: "string", describe: "With close: the session (as `rech session ls` shows it)" }),
4257
+ a => handlers.session(a.action === "list" ? "ls" : a.action!, a.name))
4258
+ .command("signal [action]", "Run a WebRTC signal server for your own shares (see docs/signal-selfhost.md)", y => y
4259
+ .positional("action", { type: "string", choices: ["serve"], default: "serve" })
4260
+ .option("host", { type: "string", requiresArg: true, describe: "Address to bind (default 127.0.0.1; put HTTPS in front)" })
4261
+ .option("port", { ...portOption, describe: "Port (default 8787)" }),
4262
+ a => handlers.signalServe({ host: a.host, port: a.port }))
4263
+ .command("bridge", false, {}, () => handlers.bridge())
3844
4264
  // Daemon and extras
3845
4265
  .command("tray [action]", "Menu-bar icon for the daemon (starts after setup)", y => y
3846
4266
  .positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
@@ -3876,7 +4296,8 @@ if (import.meta.main) {
3876
4296
  status,
3877
4297
  listListeners, addListener, removeListener, printProfileUri,
3878
4298
  listProfiles: async () => {
3879
- const url = process.env[ENV_KEY];
4299
+ const raw = process.env[ENV_KEY];
4300
+ const url = raw ? await transportUrl(raw) : raw;
3880
4301
  return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
3881
4302
  },
3882
4303
  urlList, shareProfiles, removeProfile, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
@@ -3899,6 +4320,10 @@ if (import.meta.main) {
3899
4320
  await provisionProfile(name, { headed });
3900
4321
  },
3901
4322
  uninstall: daemonUninstall,
4323
+ shareNew, shareList, shareDevices, shareEdit,
4324
+ session: sessionCommand,
4325
+ signalServe: async opts => { await signalServe(opts); await new Promise(() => {}); },
4326
+ bridge: async () => { await bridgeCommand(); await new Promise(() => {}); },
3902
4327
  };
3903
4328
 
3904
4329
  if (cmd && RECH_COMMANDS.has(cmd)) {
@@ -3908,7 +4333,7 @@ if (import.meta.main) {
3908
4333
  console.error(error instanceof Error ? error.message : String(error));
3909
4334
  process.exitCode = 1;
3910
4335
  } finally {
3911
- if (cmd !== "serve") envWatcher?.close();
4336
+ if (!["serve", "signal", "bridge"].includes(cmd)) envWatcher?.close();
3912
4337
  }
3913
4338
  } else if (cmd === "--version") {
3914
4339
  console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
@@ -3917,11 +4342,16 @@ if (import.meta.main) {
3917
4342
  try { await rechCli(["--help"], handlers).parseAsync(); }
3918
4343
  finally { envWatcher?.close(); }
3919
4344
  } else {
3920
- const url = process.env[ENV_KEY];
3921
- if (!url) {
4345
+ const rawUrl = process.env[ENV_KEY];
4346
+ if (!rawUrl) {
3922
4347
  console.error(notConnectedMessage());
3923
4348
  process.exit(1);
3924
4349
  }
4350
+ // A WebRTC share link goes through a local bridge; from here on it is plain HTTP.
4351
+ const url = await transportUrl(rawUrl).catch(error => {
4352
+ console.error(`[rech] ${error instanceof Error ? error.message : error}`);
4353
+ process.exit(1);
4354
+ });
3925
4355
  // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
3926
4356
  // the leading-flags-only rule that protects the forwarded CLI's own --profile).
3927
4357
  let profileSelector: string | undefined;