rechrome 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rechrome.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env bun
2
- import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, type Listener } from "./listeners.js";
2
+ import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, type Listener } from "./listeners.js";
3
3
 
4
4
  import { file } from "bun";
5
5
  import yargs from "yargs";
6
6
  import { readExtensionTokenFromProfile } from "./extension-token.js";
7
7
  import { randomBytes } from "crypto";
8
- import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
8
+ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
9
9
  import { hostname, homedir, networkInterfaces } from "os";
10
10
  import { isIPv4 } from "net";
11
11
  import { join, basename, dirname } from "path";
@@ -16,14 +16,16 @@ import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./da
16
16
 
17
17
  export const ENV_KEY = "RECHROME_URL";
18
18
  export const DEFAULT_PORT = 13775;
19
- export const RECH_DIR = join(import.meta.dir, ".rech");
20
- export const LOG_DIR = join(RECH_DIR, "logs");
21
-
22
19
  // Home dir: HOME on POSIX, USERPROFILE on Windows (handled by os.homedir()).
23
20
  export const HOME = homedir();
24
21
 
25
- const RECH_HOME_DIR = join(HOME, ".rechrome");
26
- const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.yaml");
22
+ // All rechrome state (registry, listeners, extension, logs, daemon output) lives in
23
+ // ~/.rechrome, never next to the code: a bunx/npx install dir is a disposable cache.
24
+ export const RECH_DIR = join(HOME, ".rechrome");
25
+ export const LOG_DIR = join(RECH_DIR, "logs");
26
+ // Before the rename, logs and daemon output lived in <install dir>/.rech.
27
+ export const LEGACY_RECH_DIR = join(import.meta.dir, ".rech");
28
+ const TOKENS_FILE = join(RECH_DIR, "profiles.yaml");
27
29
 
28
30
  type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
29
31
 
@@ -38,7 +40,7 @@ function validateTokenRegistry(value: unknown): Record<string, TokenEntry> {
38
40
  return value as Record<string, TokenEntry>;
39
41
  }
40
42
 
41
- export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_HOME_DIR): Promise<void> {
43
+ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_DIR): Promise<void> {
42
44
  validateTokenRegistry(registry);
43
45
  await mkdir(directory, { recursive: true });
44
46
  const path = join(directory, "profiles.yaml");
@@ -51,7 +53,7 @@ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, d
51
53
  }
52
54
  }
53
55
 
54
- export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Record<string, TokenEntry>> {
56
+ export async function readTokenRegistry(directory = RECH_DIR): Promise<Record<string, TokenEntry>> {
55
57
  const path = join(directory, "profiles.yaml");
56
58
  let raw: string;
57
59
  try { raw = await readFile(path, "utf8"); }
@@ -74,7 +76,7 @@ export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Reco
74
76
  }
75
77
 
76
78
  async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
77
- mkdirSync(RECH_HOME_DIR, { recursive: true });
79
+ mkdirSync(RECH_DIR, { recursive: true });
78
80
  const registry = await readTokenRegistry();
79
81
  registry[profileEmail] = entry;
80
82
  await writeTokenRegistry(registry);
@@ -241,6 +243,31 @@ export function log(msg: string) {
241
243
  appendFileSync(logFile, line);
242
244
  }
243
245
 
246
+ /**
247
+ * Move logs and daemon output from the legacy <install dir>/.rech into RECH_DIR, entry by
248
+ * entry. An entry already present in RECH_DIR is kept and the legacy copy left in place,
249
+ * so nothing is overwritten. Best effort: a cross-device rename just leaves the old copy.
250
+ */
251
+ export function migrateLegacyDataDir(legacy = LEGACY_RECH_DIR, target = RECH_DIR): string[] {
252
+ const moved: string[] = [];
253
+ if (!existsSync(legacy)) return moved;
254
+ mkdirSync(target, { recursive: true });
255
+ if (realpathSync(legacy) === realpathSync(target)) return moved;
256
+ for (const sub of ["logs", "output"]) {
257
+ const from = join(legacy, sub);
258
+ if (!existsSync(from)) continue;
259
+ mkdirSync(join(target, sub), { recursive: true });
260
+ for (const entry of readdirSync(from)) {
261
+ const to = join(target, sub, entry);
262
+ if (existsSync(to)) continue;
263
+ try { renameSync(join(from, entry), to); moved.push(`${sub}/${entry}`); } catch { /* keep the legacy copy */ }
264
+ }
265
+ }
266
+ // Remove only directories left empty (the unused tls/ included); rmdir refuses non-empty ones.
267
+ for (const dir of ["logs", "output", "tls", ""]) { try { rmdirSync(join(legacy, dir)); } catch { /* not empty */ } }
268
+ return moved;
269
+ }
270
+
244
271
  export function parseUrl(raw: string) {
245
272
  const u = new URL(raw);
246
273
  const fragment = new URLSearchParams(u.hash.slice(1).replace(/^\?/, ""));
@@ -368,8 +395,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
368
395
  return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
369
396
  }
370
397
 
371
- /** Remote connection URL for a Serve route, carrying the listener key and profile like the local one. */
372
- export function tailscaleConnectionUrl(routeUrl: string, localUrl: string): string {
398
+ /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
399
+ export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
373
400
  const local = new URL(localUrl);
374
401
  const remote = new URL(routeUrl);
375
402
  remote.username = parseUrl(localUrl).key;
@@ -482,7 +509,15 @@ export function deriveIdentity(opts: {
482
509
  return { key, label };
483
510
  }
484
511
 
485
- async function getClientIdentity(): Promise<{ key: string; label: string; profile?: string }> {
512
+ /**
513
+ * A project's own rechrome folder: <root>/.rechrome, where root is the same one the session
514
+ * key uses (the worktree root, submodules rolled up), or cwd in `cwd` mode / outside git.
515
+ */
516
+ export function projectDataDir(opts: { mode: string; cwd: string; root?: string | null }): string {
517
+ return join(opts.mode === "cwd" ? opts.cwd : opts.root || opts.cwd, ".rechrome");
518
+ }
519
+
520
+ async function getClientIdentity(): Promise<{ key: string; label: string; dataDir: string; profile?: string }> {
486
521
  const cwd = realpathSafe(process.cwd());
487
522
  const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
488
523
  let root: string | null = null;
@@ -508,7 +543,7 @@ async function getClientIdentity(): Promise<{ key: string; label: string; profil
508
543
  if (remoteUrl) remote = normalizeRemote(remoteUrl);
509
544
  }
510
545
 
511
- return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
546
+ return { ...deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch }), dataDir: projectDataDir({ mode, cwd, root }) };
512
547
  }
513
548
 
514
549
  // Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
@@ -862,16 +897,22 @@ export function profileConnectionUri(profile: string, configuredUrl: string | un
862
897
  return result.toString();
863
898
  }
864
899
 
865
- async function printProfileUri(selector?: string, listener?: string): Promise<void> {
900
+ async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
866
901
  const url = process.env[ENV_KEY];
867
902
  selector ??= url ? parseUrl(url).profileDirectory : undefined;
868
- if (!selector) throw new Error("Specify a profile: rech profile <name> --print-uri");
903
+ if (!selector) throw new Error("Specify a profile: rech url <profile>");
869
904
  const registry = await readTokenRegistry();
870
905
  const cache = await readChromeProfileCache();
871
906
  // A configured remote profile may not exist in this machine's local registry.
872
907
  const profile = url && parseUrl(url).profileDirectory === selector && !listener
873
908
  ? selector : (await resolveGlobalProfile(registry, cache, selector)).email;
874
- console.log(profileConnectionUri(profile, url, (await readListeners())?.listeners ?? [], listener));
909
+ const listeners = (await readListeners())?.listeners ?? [];
910
+ let uri = profileConnectionUri(profile, url, listeners, listener);
911
+ // Prefer where a proxy exposes the listener, when it has been recorded.
912
+ const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
913
+ if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
914
+ console.log(uri);
915
+ if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
875
916
  }
876
917
 
877
918
  export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
@@ -902,7 +943,8 @@ async function callServe(
902
943
  const res = await fetch(serviceUrl(url, "run"), {
903
944
  method: "POST",
904
945
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
905
- body: JSON.stringify({ args, identity, env }),
946
+ // dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
947
+ body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env }),
906
948
  signal: AbortSignal.timeout(70_000),
907
949
  }).catch(async (e) => {
908
950
  if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
@@ -948,6 +990,31 @@ export function normalizeCommandArgs(args: string[]): string[] {
948
990
  // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
949
991
  // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
950
992
  // missing value; accepts multiple occurrences (last one wins).
993
+ /**
994
+ * `rech [--profile X] [--isolate] pw <args>` forwards <args> to playwright-cli verbatim, so
995
+ * `rech pw --version` or `rech pw status` reach playwright instead of rech. `--` works the
996
+ * same after a rech flag (`rech --profile X -- status`); a bare leading `--` cannot, because
997
+ * Bun consumes the `--` right after the script path. The separator counts only when
998
+ * everything before it is a rech global flag; otherwise it belongs to the playwright command
999
+ * (`rech open -- x`). Returns its index, or -1.
1000
+ */
1001
+ export function rechSeparatorIndex(args: string[]): number {
1002
+ for (let i = 0; i < args.length; i++) {
1003
+ const a = args[i];
1004
+ if (a === "--" || a === "pw" || a === "playwright") return i;
1005
+ if (a === "--profile") { i++; continue; }
1006
+ if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated") continue;
1007
+ return -1;
1008
+ }
1009
+ return -1;
1010
+ }
1011
+
1012
+ /** rechrome's own version, from the package.json shipped next to this file. */
1013
+ export function rechromeVersion(): string {
1014
+ try { return JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")).version ?? "unknown"; }
1015
+ catch { return "unknown"; }
1016
+ }
1017
+
951
1018
  export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
952
1019
  const rest = [...args];
953
1020
  let selector: string | undefined;
@@ -1018,9 +1085,11 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1018
1085
  if (stdout) process.stdout.write(stdout);
1019
1086
 
1020
1087
  if (files?.length) {
1021
- const dlDir = join(process.cwd(), ".playwright-cli-multi-tab");
1088
+ // Saved files belong to the project: <project>/.rechrome/output. The folder also holds
1089
+ // .env.local (secrets), so it is git-ignored as a whole when first created.
1090
+ const dlDir = join(identity.dataDir, "output");
1022
1091
  mkdirSync(dlDir, { recursive: true });
1023
- const gitignorePath = join(dlDir, ".gitignore");
1092
+ const gitignorePath = join(identity.dataDir, ".gitignore");
1024
1093
  if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
1025
1094
  for (const name of files) {
1026
1095
  const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
@@ -1467,7 +1536,7 @@ async function daemonUninstall(): Promise<void> {
1467
1536
  // locate the binary and launch it detached (singleton via a pidfile).
1468
1537
  // `rech tray hide` / the menu "Hide" item both kill the process;
1469
1538
  // `rech tray show` starts a fresh one.
1470
- const TRAY_PID_FILE = join(RECH_HOME_DIR, "tray.pid");
1539
+ const TRAY_PID_FILE = join(RECH_DIR, "tray.pid");
1471
1540
 
1472
1541
  // A desktop GUI must be present. Linux needs an X11/Wayland display; a headless
1473
1542
  // box (SSH, CI, container) has neither, so the tray is skipped. macOS/Windows
@@ -1729,7 +1798,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1729
1798
  process.exit(1);
1730
1799
  }
1731
1800
  const dist = await ensureExtensionDistInstalled();
1732
- const userDataDir = join(RECH_HOME_DIR, "profiles", name);
1801
+ const userDataDir = join(RECH_DIR, "profiles", name);
1733
1802
  const token = randomBytes(32).toString("base64url");
1734
1803
 
1735
1804
  console.log(`\n[1/3] Provisioning managed profile "${name}"`);
@@ -1811,17 +1880,123 @@ async function removeListener(name: string): Promise<void> {
1811
1880
  console.log("Listener removed from configuration; daemon reloads automatically.");
1812
1881
  }
1813
1882
 
1814
- async function addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void> {
1815
- const config = await requireListeners();
1883
+ async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
1816
1884
  const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
1817
1885
  const profiles: string[] = [];
1818
- for (const selector of opts.profile) profiles.push((await resolveGlobalProfile(registry, cache, selector)).email);
1886
+ for (const selector of selectors) profiles.push((await resolveGlobalProfile(registry, cache, selector)).email);
1887
+ return [...new Set(profiles)];
1888
+ }
1889
+
1890
+ /** Next steps after exposing a listener: proxy it, record where, share. Plain text, so any shell works. */
1891
+ export function listenerNextSteps(listener: Listener, profile?: string): string[] {
1892
+ const prefix = normalizePrefix(listener.prefix);
1893
+ const mount = prefix === "/" ? "" : prefix.slice(0, -1);
1894
+ return [
1895
+ `Expose it through any reverse proxy on port ${listener.port}${mount ? ` (mount ${mount}; stripping it is fine)` : ""}, e.g.:`,
1896
+ ` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
1897
+ `Then record where it is reachable and print the URL to share:`,
1898
+ ` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
1899
+ ` rech url ${profile ? JSON.stringify(profile) : "<profile>"} --listener ${listener.name}`,
1900
+ ];
1901
+ }
1902
+
1903
+ async function addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void> {
1904
+ const config = await requireListeners();
1905
+ const profiles = await resolveProfileKeys(opts.profile);
1819
1906
  const host = chooseListenAddress(await detectListenChoices(), opts.listen);
1820
1907
  const port = opts.port ?? DEFAULT_PORT;
1821
- if (config.listeners.some(l => l.name === name)) throw new Error("Listener name already exists; edit listeners.json to change its allowlist");
1822
- config.listeners.push({ name, host, port, prefix: normalizePrefix(opts.prefix), key: randomBytes(24).toString("base64url"), profiles: [...new Set(profiles)] });
1908
+ if (config.listeners.some(l => l.name === name)) throw new Error(`Listener "${name}" already exists; change its profiles with rech listener allow|deny ${name} <profile>`);
1909
+ const listener: Listener = { name, host, port, prefix: normalizePrefix(opts.prefix), key: randomBytes(24).toString("base64url"), profiles };
1910
+ config.listeners.push(listener);
1823
1911
  await writeListeners(config);
1824
1912
  console.log(`Listener saved for ${host}:${port}; daemon reloads automatically. Credentials are in ~/.rechrome/listeners.json (keep private).`);
1913
+ if (isLoopback(host)) for (const line of listenerNextSteps(listener, profiles[0])) console.log(line);
1914
+ }
1915
+
1916
+ function findListener(config: { listeners: Listener[] }, name?: string): Listener {
1917
+ if (name) {
1918
+ const listener = config.listeners.find(l => l.name === name);
1919
+ if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1920
+ return listener;
1921
+ }
1922
+ const scoped = config.listeners.filter(l => l.profiles !== "*");
1923
+ if (scoped.length !== 1) throw new Error(`Name a listener: ${config.listeners.map(l => l.name).join(", ")}`);
1924
+ return scoped[0];
1925
+ }
1926
+
1927
+ async function listenerPort(name?: string): Promise<void> {
1928
+ console.log(findListener(await requireListeners(), name).port);
1929
+ }
1930
+
1931
+ async function allowListener(name: string, selectors: string[]): Promise<void> {
1932
+ const config = await requireListeners();
1933
+ const added = allowProfiles(config, name, await resolveProfileKeys(selectors));
1934
+ await writeListeners(config);
1935
+ console.log(added.length ? `Allowed on ${name}: ${added.join(", ")}` : `Already allowed on ${name}; nothing changed.`);
1936
+ }
1937
+
1938
+ async function denyListener(name: string, selectors: string[]): Promise<void> {
1939
+ const config = await requireListeners();
1940
+ // Accept raw registry keys too, so a profile that no longer resolves can still be removed.
1941
+ const keys = [...selectors, ...await resolveProfileKeys(selectors).catch(() => [] as string[])];
1942
+ const removed = denyProfiles(config, name, keys);
1943
+ await writeListeners(config);
1944
+ console.log(removed.length ? `Removed from ${name}: ${removed.join(", ")}` : `Not on ${name}; nothing changed.`);
1945
+ }
1946
+
1947
+ async function rotateKey(name: string): Promise<void> {
1948
+ const config = await requireListeners();
1949
+ rotateListenerKey(config, name);
1950
+ await writeListeners(config);
1951
+ console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech url <profile> --listener ${name}`);
1952
+ }
1953
+
1954
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
1955
+ const config = await requireListeners();
1956
+ const listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
1957
+ await writeListeners(config);
1958
+ console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
1959
+ }
1960
+
1961
+ const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
1962
+
1963
+ async function urlList(): Promise<void> {
1964
+ const config = await requireListeners();
1965
+ const rows = [["LISTENER", "PROFILE", "LOCAL", "PUBLIC"]];
1966
+ for (const l of config.listeners) {
1967
+ const local = `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`;
1968
+ for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
1969
+ }
1970
+ const widths = rows[0].map((_, i) => Math.max(...rows.map(r => r[i].length)));
1971
+ for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
1972
+ console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
1973
+ }
1974
+
1975
+ /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
1976
+ async function saveProjectUrl(url: string): Promise<string> {
1977
+ const { dataDir } = await getClientIdentity();
1978
+ mkdirSync(dataDir, { recursive: true });
1979
+ const gitignore = join(dataDir, ".gitignore");
1980
+ if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
1981
+ const envPath = join(dataDir, ".env.local");
1982
+ const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
1983
+ await writeFile(envPath, [...lines, `${ENV_KEY}=${url}`, ""].join("\n"), { mode: 0o600 });
1984
+ await chmod(envPath, 0o600);
1985
+ return envPath;
1986
+ }
1987
+
1988
+ async function connect(url: string): Promise<void> {
1989
+ const parsed = parseUrl(url);
1990
+ if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech url <profile>");
1991
+ const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
1992
+ .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
1993
+ if (response.status === 401) throw new Error("The daemon rejected this key; ask the host for a fresh URL (keys change on rech listener rotate-key).");
1994
+ if (!response.ok) throw new Error(`${serviceUrl(url, "ping")} answered ${response.status}; is the proxy pointing at the listener?`);
1995
+ const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
1996
+ if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
1997
+ throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
1998
+ const saved = await saveProjectUrl(url);
1999
+ console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
1825
2000
  }
1826
2001
 
1827
2002
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -2141,10 +2316,10 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
2141
2316
  const serve = await detectTailscaleServe(exposure.port, prefix);
2142
2317
  if (serve.routeUrl) {
2143
2318
  console.log(` Tailscale Serve route found: ${serve.routeUrl} → ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
2144
- console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${tailscaleConnectionUrl(serve.routeUrl, rechUrl.toString())}`);
2319
+ console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${rebaseConnectionUrl(serve.routeUrl, rechUrl.toString())}`);
2145
2320
  } else {
2146
2321
  console.log(` Tailscale Serve command (run separately): tailscale serve --bg --https=443 --set-path=${prefix.slice(0, -1)} ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
2147
- if (serve.dnsName) console.log(` Then remote clients can use:\n ${tailscaleConnectionUrl(`https://${serve.dnsName}${prefix}`, rechUrl.toString())}`);
2322
+ if (serve.dnsName) console.log(` Then remote clients can use:\n ${rebaseConnectionUrl(`https://${serve.dnsName}${prefix}`, rechUrl.toString())}`);
2148
2323
  else console.log(` For remote clients, use https://<your-machine-tailnet-name>${prefix} with this listener's bearer key and profile query.`);
2149
2324
  }
2150
2325
  console.log(` The URL saved below is the local loopback one.`);
@@ -2228,7 +2403,6 @@ async function status(): Promise<void> {
2228
2403
  console.log(`serve: not configured (run \`rech setup\`)`);
2229
2404
  return;
2230
2405
  }
2231
- const { host, port, protocol } = parseUrl(url);
2232
2406
  const parsed = parseUrl(url);
2233
2407
  const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
2234
2408
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
@@ -2237,11 +2411,11 @@ async function status(): Promise<void> {
2237
2411
  ? await fetch(serviceUrl(url, "ping"), {
2238
2412
  headers: { Authorization: `Bearer ${parsed.key}` },
2239
2413
  signal: AbortSignal.timeout(2000),
2240
- }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
2414
+ }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; listener?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
2241
2415
  : null;
2242
- const bind = pingBody?.bind;
2243
- const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
2244
- console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
2416
+ // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
2417
+ const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
2418
+ console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : "not running"}`);
2245
2419
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
2246
2420
  // ReferenceError that took down the whole of `rech status`, so the one command
2247
2421
  // that reports "the relay is wedged" died exactly when the relay was wedged,
@@ -2305,12 +2479,26 @@ Usage:
2305
2479
  rech uninstall Remove the serve daemon and clear config
2306
2480
  rech serve Start the serve server manually (foreground)
2307
2481
  rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
2482
+ rech listener allow|deny <name> <profile...>
2483
+ Add or remove profiles on an existing listener
2484
+ rech listener port [name] Print a listener's port, for a reverse-proxy command
2485
+ rech listener set <name> --public-url <url>
2486
+ Record where a proxy exposes the listener (rech url uses it)
2487
+ rech listener rotate-key <name>
2488
+ New key for a listener; URLs with the old key stop working
2308
2489
  rech profile [ls|list]
2309
2490
  List Chrome + managed test profiles and connection status
2310
- rech profile [name] --print-uri [--listener <name>]
2311
- Print a connection URI (includes the daemon key).
2312
- Uses RECHROME_URL, or an explicitly selected local listener.
2491
+ rech url [profile] [--listener <name>] [--local] [--save]
2492
+ Print a connection URL (includes the secret key): the public
2493
+ URL when one is set, else the listener address. --save also
2494
+ writes it to this project's .rechrome/.env.local.
2495
+ \`rech profile [name] --print-uri\` is an alias.
2496
+ rech url ls List every listener × profile URL (keys hidden)
2497
+ rech connect <url> Check a shared URL answers, then save it for this project
2313
2498
  rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
2499
+ rech pw <playwright-args...> Forward verbatim to playwright-cli, even when a name clashes
2500
+ with rech's own (rech pw --version, rech pw status)
2501
+ rech --version rechrome's version
2314
2502
  rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
2315
2503
  fragile single-shot flow (OAuth/login) never shares tabs
2316
2504
  with the worktree's default session
@@ -2340,7 +2528,14 @@ export type RechHandlers = {
2340
2528
  addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
2341
2529
  removeListener(name: string): Promise<void>;
2342
2530
  listProfiles(): Promise<void>;
2343
- printProfileUri(selector?: string, listener?: string): Promise<void>;
2531
+ printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean }): Promise<void>;
2532
+ urlList(): Promise<void>;
2533
+ connect(url: string): Promise<void>;
2534
+ listenerPort(name?: string): Promise<void>;
2535
+ allowListener(name: string, profiles: string[]): Promise<void>;
2536
+ denyListener(name: string, profiles: string[]): Promise<void>;
2537
+ rotateKey(name: string): Promise<void>;
2538
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
2344
2539
  setup(opts: SetupOptions): Promise<void>;
2345
2540
  tray(action?: string): Promise<void>;
2346
2541
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -2348,7 +2543,7 @@ export type RechHandlers = {
2348
2543
  };
2349
2544
 
2350
2545
  /** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
2351
- export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "setup", "tray", "provision-profile", "uninstall"]);
2546
+ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "url", "urls", "connect", "setup", "tray", "provision-profile", "uninstall"]);
2352
2547
 
2353
2548
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
2354
2549
 
@@ -2369,13 +2564,40 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2369
2564
  a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
2370
2565
  .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
2371
2566
  a => handlers.removeListener(a.name))
2567
+ .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
2568
+ a => handlers.listenerPort(a.name))
2569
+ .command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
2570
+ .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
2571
+ a => handlers.allowListener(a.name, a.profiles))
2572
+ .command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
2573
+ .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
2574
+ a => handlers.denyListener(a.name, a.profiles))
2575
+ .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
2576
+ a => handlers.rotateKey(a.name))
2577
+ .command("set <name>", "Record where a reverse proxy exposes a listener", y => y
2578
+ .positional("name", { type: "string", demandOption: true })
2579
+ .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
2580
+ .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
2581
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
2582
+ a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
2372
2583
  .demandCommand(1).strict())
2584
+ .command(["url [profile]", "urls [profile]"], "Print a profile's connection URL (contains a secret key); `url ls` lists them", y => y
2585
+ .positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists all listeners' URLs" })
2586
+ .option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
2587
+ .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2588
+ .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2589
+ a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2590
+ ? handlers.urlList()
2591
+ : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2592
+ .command("connect <url>", "Check a shared connection URL and save it for this project", y => y
2593
+ .positional("url", { type: "string", demandOption: true }),
2594
+ a => handlers.connect(a.url))
2373
2595
  .command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
2374
2596
  .positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
2375
2597
  .option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
2376
2598
  .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
2377
2599
  a => {
2378
- if (a.printUri) return handlers.printProfileUri(a.name, a.listener);
2600
+ if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
2379
2601
  if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2380
2602
  throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
2381
2603
  })
@@ -2412,6 +2634,7 @@ if (import.meta.main) {
2412
2634
  serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
2413
2635
  status,
2414
2636
  listListeners, addListener, removeListener, listProfiles, printProfileUri,
2637
+ urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2415
2638
  setup: async (opts) => {
2416
2639
  await setup(opts); // setup closes envWatcher itself before printing Done
2417
2640
  // Auto-start the tray (best-effort, silent on headless / missing binary).
@@ -2440,6 +2663,9 @@ if (import.meta.main) {
2440
2663
  } finally {
2441
2664
  if (cmd !== "serve") envWatcher?.close();
2442
2665
  }
2666
+ } else if (cmd === "--version") {
2667
+ console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
2668
+ envWatcher?.close();
2443
2669
  } else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
2444
2670
  printHelp();
2445
2671
  envWatcher?.close();
@@ -2454,6 +2680,10 @@ if (import.meta.main) {
2454
2680
  // the leading-flags-only rule that protects the forwarded CLI's own --profile).
2455
2681
  let profileSelector: string | undefined;
2456
2682
  let overrideEnv: Record<string, string> | undefined;
2683
+ // Everything after rech's `pw` (or `--`) goes to playwright-cli untouched; only the flags before it are rech's.
2684
+ const separator = rechSeparatorIndex(args);
2685
+ const forwarded = separator === -1 ? [] : args.slice(separator + 1);
2686
+ if (separator !== -1) args = args.slice(0, separator);
2457
2687
  try {
2458
2688
  const extracted = extractGlobalProfileArg(args);
2459
2689
  profileSelector = extracted.selector;
@@ -2493,6 +2723,7 @@ if (import.meta.main) {
2493
2723
  args.splice(isolateIdx, 1);
2494
2724
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
2495
2725
  }
2726
+ args = [...forwarded, ...args];
2496
2727
  await run(url, args, overrideEnv);
2497
2728
  envWatcher?.close();
2498
2729
  }