rechrome 1.36.1 → 1.37.0

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