rechrome 1.26.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,5 +1,5 @@
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";
@@ -395,8 +395,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
395
395
  return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
396
396
  }
397
397
 
398
- /** Remote connection URL for a Serve route, carrying the listener key and profile like the local one. */
399
- 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 {
400
400
  const local = new URL(localUrl);
401
401
  const remote = new URL(routeUrl);
402
402
  remote.username = parseUrl(localUrl).key;
@@ -897,16 +897,22 @@ export function profileConnectionUri(profile: string, configuredUrl: string | un
897
897
  return result.toString();
898
898
  }
899
899
 
900
- async function printProfileUri(selector?: string, listener?: string): Promise<void> {
900
+ async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
901
901
  const url = process.env[ENV_KEY];
902
902
  selector ??= url ? parseUrl(url).profileDirectory : undefined;
903
- 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>");
904
904
  const registry = await readTokenRegistry();
905
905
  const cache = await readChromeProfileCache();
906
906
  // A configured remote profile may not exist in this machine's local registry.
907
907
  const profile = url && parseUrl(url).profileDirectory === selector && !listener
908
908
  ? selector : (await resolveGlobalProfile(registry, cache, selector)).email;
909
- 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)}`);
910
916
  }
911
917
 
912
918
  export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
@@ -984,6 +990,31 @@ export function normalizeCommandArgs(args: string[]): string[] {
984
990
  // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
985
991
  // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
986
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
+
987
1018
  export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
988
1019
  const rest = [...args];
989
1020
  let selector: string | undefined;
@@ -1849,17 +1880,123 @@ async function removeListener(name: string): Promise<void> {
1849
1880
  console.log("Listener removed from configuration; daemon reloads automatically.");
1850
1881
  }
1851
1882
 
1852
- async function addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void> {
1853
- const config = await requireListeners();
1883
+ async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
1854
1884
  const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
1855
1885
  const profiles: string[] = [];
1856
- 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);
1857
1906
  const host = chooseListenAddress(await detectListenChoices(), opts.listen);
1858
1907
  const port = opts.port ?? DEFAULT_PORT;
1859
- if (config.listeners.some(l => l.name === name)) throw new Error("Listener name already exists; edit listeners.json to change its allowlist");
1860
- 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);
1861
1911
  await writeListeners(config);
1862
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}`);
1863
2000
  }
1864
2001
 
1865
2002
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -2179,10 +2316,10 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
2179
2316
  const serve = await detectTailscaleServe(exposure.port, prefix);
2180
2317
  if (serve.routeUrl) {
2181
2318
  console.log(` Tailscale Serve route found: ${serve.routeUrl} → ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
2182
- 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())}`);
2183
2320
  } else {
2184
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)}`);
2185
- 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())}`);
2186
2323
  else console.log(` For remote clients, use https://<your-machine-tailnet-name>${prefix} with this listener's bearer key and profile query.`);
2187
2324
  }
2188
2325
  console.log(` The URL saved below is the local loopback one.`);
@@ -2266,7 +2403,6 @@ async function status(): Promise<void> {
2266
2403
  console.log(`serve: not configured (run \`rech setup\`)`);
2267
2404
  return;
2268
2405
  }
2269
- const { host, port, protocol } = parseUrl(url);
2270
2406
  const parsed = parseUrl(url);
2271
2407
  const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
2272
2408
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
@@ -2275,11 +2411,11 @@ async function status(): Promise<void> {
2275
2411
  ? await fetch(serviceUrl(url, "ping"), {
2276
2412
  headers: { Authorization: `Bearer ${parsed.key}` },
2277
2413
  signal: AbortSignal.timeout(2000),
2278
- }).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
2279
2415
  : null;
2280
- const bind = pingBody?.bind;
2281
- const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
2282
- 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"}`);
2283
2419
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
2284
2420
  // ReferenceError that took down the whole of `rech status`, so the one command
2285
2421
  // that reports "the relay is wedged" died exactly when the relay was wedged,
@@ -2343,12 +2479,26 @@ Usage:
2343
2479
  rech uninstall Remove the serve daemon and clear config
2344
2480
  rech serve Start the serve server manually (foreground)
2345
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
2346
2489
  rech profile [ls|list]
2347
2490
  List Chrome + managed test profiles and connection status
2348
- rech profile [name] --print-uri [--listener <name>]
2349
- Print a connection URI (includes the daemon key).
2350
- 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
2351
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
2352
2502
  rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
2353
2503
  fragile single-shot flow (OAuth/login) never shares tabs
2354
2504
  with the worktree's default session
@@ -2378,7 +2528,14 @@ export type RechHandlers = {
2378
2528
  addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
2379
2529
  removeListener(name: string): Promise<void>;
2380
2530
  listProfiles(): Promise<void>;
2381
- 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>;
2382
2539
  setup(opts: SetupOptions): Promise<void>;
2383
2540
  tray(action?: string): Promise<void>;
2384
2541
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -2386,7 +2543,7 @@ export type RechHandlers = {
2386
2543
  };
2387
2544
 
2388
2545
  /** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
2389
- 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"]);
2390
2547
 
2391
2548
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
2392
2549
 
@@ -2407,13 +2564,40 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2407
2564
  a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
2408
2565
  .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
2409
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 }))
2410
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))
2411
2595
  .command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
2412
2596
  .positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
2413
2597
  .option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
2414
2598
  .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
2415
2599
  a => {
2416
- 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`
2417
2601
  if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2418
2602
  throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
2419
2603
  })
@@ -2450,6 +2634,7 @@ if (import.meta.main) {
2450
2634
  serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
2451
2635
  status,
2452
2636
  listListeners, addListener, removeListener, listProfiles, printProfileUri,
2637
+ urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2453
2638
  setup: async (opts) => {
2454
2639
  await setup(opts); // setup closes envWatcher itself before printing Done
2455
2640
  // Auto-start the tray (best-effort, silent on headless / missing binary).
@@ -2478,6 +2663,9 @@ if (import.meta.main) {
2478
2663
  } finally {
2479
2664
  if (cmd !== "serve") envWatcher?.close();
2480
2665
  }
2666
+ } else if (cmd === "--version") {
2667
+ console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
2668
+ envWatcher?.close();
2481
2669
  } else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
2482
2670
  printHelp();
2483
2671
  envWatcher?.close();
@@ -2492,6 +2680,10 @@ if (import.meta.main) {
2492
2680
  // the leading-flags-only rule that protects the forwarded CLI's own --profile).
2493
2681
  let profileSelector: string | undefined;
2494
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);
2495
2687
  try {
2496
2688
  const extracted = extractGlobalProfileArg(args);
2497
2689
  profileSelector = extracted.selector;
@@ -2531,6 +2723,7 @@ if (import.meta.main) {
2531
2723
  args.splice(isolateIdx, 1);
2532
2724
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
2533
2725
  }
2726
+ args = [...forwarded, ...args];
2534
2727
  await run(url, args, overrideEnv);
2535
2728
  envWatcher?.close();
2536
2729
  }