rechrome 1.28.2 → 1.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,13 +88,14 @@ On the host (the machine with Chrome):
88
88
  rech listener add share --listen local --prefix=rechrome --port 13776 --profile you@example.com
89
89
  tailscale serve --bg --set-path=/rechrome 13776
90
90
  rech listener set share --public-url https://host.example.ts.net/rechrome/
91
- rech url you@example.com --listener share # prints the URL to share — it contains a secret key
91
+ rech share you@example.com # prints the URL to share — it contains a secret key
92
92
  ```
93
93
 
94
94
  `rech listener add` prints these follow-up lines with your port filled in. For scripts,
95
95
  `rech listener port share` prints the port (`$(rech listener port share)` in bash or PowerShell).
96
96
 
97
- On the other machine, inside the project that should use it:
97
+ On the other machine, inside the project that should use it (or just open the link in a
98
+ browser: it shows these commands, per shell, with a Copy button):
98
99
 
99
100
  ```bash
100
101
  rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'
@@ -106,13 +107,13 @@ rech open https://example.com
106
107
  interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
107
108
 
108
109
  On a trusted LAN without a proxy, `rech setup --listen lan --profile you@example.com` binds the
109
- profile to your LAN address directly (plain HTTP); share the result of `rech url`.
110
+ profile to your LAN address directly (plain HTTP); share the result of `rech share`.
110
111
 
111
112
  ### 5. Manage access
112
113
 
113
114
  ```bash
114
- rech url ls # every listener × profile, local and public URLs (keys hidden)
115
- rech url you@example.com --listener share # print one URL again (add --save to use it in this project)
115
+ rech share ls # everything shared: listener × profile, local and public URLs (keys hidden)
116
+ rech share you@example.com # print one URL again (add --save to use it in this project)
116
117
  rech listener allow share teammate@example.com
117
118
  rech listener deny share teammate@example.com
118
119
  rech listener rotate-key share # revoke: every URL for this listener stops working
@@ -193,7 +194,7 @@ Connection parameters also accept URL fragments:
193
194
  RECHROME_URL='https://your-host.ts.net/rechrome/?profile=qa#key=DAEMON_KEY' rech status
194
195
  ```
195
196
 
196
- `rech setup` prints and saves this URI format. Retrieve it later with `rech url qa` (alias: `rech profile qa --print-uri`), or omit `qa` to use the configured profile. `profiles` remains an alias. The command prints only the URI to stdout, using the configured `RECHROME_URL` endpoint; `--listener local` selects a local listener instead. For example: `rech profile qa --print-uri --listener local`. The output contains a secret daemon key.
197
+ `rech setup` prints and saves this URI format. Retrieve it later with `rech share qa` (alias: `rech profile qa --print-uri`), or omit `qa` to use the configured profile. `profiles` remains an alias. The command prints only the URI to stdout, using the configured `RECHROME_URL` endpoint; `--listener local` selects a local listener instead. For example: `rech profile qa --print-uri --listener local`. The output contains a secret daemon key.
197
198
 
198
199
  Direct connections use the root path, such as `http://127.0.0.1:13775/?profile=qa#key=DAEMON_KEY`. A prefix is optional and only added when explicitly configured with `--prefix`, for example for a proxy mounted at `/rechrome/`. Tailscale can also serve at the root without a prefix.
199
200
 
@@ -207,7 +208,7 @@ cp .env.example .env.local
207
208
 
208
209
  | Variable | Description | Default |
209
210
  | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
210
- | `RECHROME_URL` | Connection URL, saved by `rech setup` / `rech connect` / `rech url --save`. Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
211
+ | `RECHROME_URL` | Connection URL, saved by `rech setup` / `rech connect` / `rech share --save`. Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
211
212
  | `PLAYWRIGHT_CLI` | Override the playwright-cli command/path (defaults to the bundled `@playwright/cli`; set this only for a custom or forked CLI) | bundled `@playwright/cli` |
212
213
  | `RECH_HOST` | Legacy bind address, used only before listeners.json is configured | `127.0.0.1` |
213
214
  | `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.28.2",
3
+ "version": "1.29.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -671,6 +671,40 @@ export function validateChromeProfileSelector(selector: string): void {
671
671
  );
672
672
  }
673
673
 
674
+ export type ProfileCandidate = { id: string; label: string; fields: string[]; localPart?: string };
675
+
676
+ /** Several profiles match; an interactive caller can offer them as a choice. */
677
+ export class AmbiguousProfileError extends Error {
678
+ constructor(message: string, readonly candidates: ProfileCandidate[]) { super(message); }
679
+ }
680
+
681
+ /** share can't pick a listener on its own; an interactive caller can ask. */
682
+ export class ShareListenerError extends Error {
683
+ constructor(message: string, readonly kind: "several" | "none", readonly listeners: Listener[]) { super(message); }
684
+ }
685
+
686
+ /**
687
+ * Looser profile matching, tried after the exact rules: the email's part before "@", then the
688
+ * start (3+ characters) of an email/name/folder. No substring matching: "h" must not pick a
689
+ * profile because its email happens to contain an h. Each stage counts only when it picks out
690
+ * exactly one profile; if a stage matches several, stop and name them rather than guess.
691
+ */
692
+ export function matchProfileLoosely(value: string, candidates: ProfileCandidate[]): ProfileCandidate | null {
693
+ const needle = value.trim().toLowerCase();
694
+ if (!needle) return null;
695
+ const stages: Array<[string, (c: ProfileCandidate) => boolean]> = [
696
+ ["email name", c => c.localPart?.toLowerCase() === needle],
697
+ ["prefix", c => needle.length >= 3 && c.fields.some(f => f.toLowerCase().startsWith(needle))],
698
+ ];
699
+ for (const [, test] of stages) {
700
+ const hits = candidates.filter(test);
701
+ if (hits.length === 1) return hits[0];
702
+ if (hits.length > 1)
703
+ throw new AmbiguousProfileError(`Profile "${value}" matches several profiles: ${hits.map(c => c.label).join(", ")}. Use one of those, or see \`rech profile\`.`, hits);
704
+ }
705
+ return null;
706
+ }
707
+
674
708
  export async function resolveGlobalProfile(
675
709
  registry: Record<string, TokenEntry>,
676
710
  chromeProfiles: Record<string, ChromeProfileInfo> | null,
@@ -690,18 +724,32 @@ export async function resolveGlobalProfile(
690
724
  }
691
725
 
692
726
  const profiles = Object.entries(chromeProfiles);
693
- let match: [string, ChromeProfileInfo] | null;
694
- try {
695
- match = resolveChromeProfileSelector(profiles, value);
696
- } catch (err) {
697
- throw err;
698
- }
727
+ let match = resolveChromeProfileSelector(profiles, value);
699
728
 
700
729
  if (!match) {
701
- throw new Error(
702
- `--profile "${value}" does not match any Chrome profile. ` +
703
- `See available profiles with \`rech profile\`.`,
704
- );
730
+ // No exact match: accept a looser one only when it is unique, and say which profile it chose.
731
+ const chromeIds = new Set(profiles.flatMap(([dir, info]) => [dir, info.user_name ?? ""]));
732
+ for (const [key, entry] of Object.entries(registry)) if (profiles.some(([dir]) => dir === entry.profileDir)) chromeIds.add(key);
733
+ const candidates: ProfileCandidate[] = [
734
+ ...profiles.map(([dir, info]) => ({
735
+ id: `chrome:${dir}`,
736
+ label: info.user_name ? `${info.user_name} (${info.name ?? dir})` : `${info.name ?? dir} [${dir}]`,
737
+ fields: [info.user_name ?? "", info.name ?? "", dir].filter(Boolean),
738
+ localPart: info.user_name?.split("@")[0],
739
+ })),
740
+ // Registered profiles Chrome doesn't list (managed test profiles).
741
+ ...Object.keys(registry).filter(k => !chromeIds.has(k)).map(k => ({ id: `registry:${k}`, label: k, fields: [k], localPart: k.includes("@") ? k.split("@")[0] : undefined })),
742
+ ];
743
+ const loose = matchProfileLoosely(value, candidates);
744
+ if (!loose) {
745
+ throw new Error(`Profile "${value}" does not match any Chrome profile. See available profiles with \`rech profile\`.`);
746
+ }
747
+ console.error(`[rech] profile "${value}" → ${loose.label}`);
748
+ if (loose.id.startsWith("registry:")) {
749
+ const key = loose.id.slice("registry:".length);
750
+ return { email: key, entry: registry[key] };
751
+ }
752
+ match = profiles.find(([dir]) => `chrome:${dir}` === loose.id)!;
705
753
  }
706
754
 
707
755
  const [dir, info] = match;
@@ -883,6 +931,12 @@ async function listProfiles(): Promise<void> {
883
931
  export function profileConnectionUri(profile: string, configuredUrl: string | undefined, listeners: Listener[], listenerName?: string): string {
884
932
  let url = configuredUrl;
885
933
  if (listenerName || !url) {
934
+ if (listenerName) {
935
+ const named = listeners.find(l => l.name === listenerName);
936
+ if (!named) throw new Error(`Unknown listener "${listenerName}". See rech listener ls.`);
937
+ if (named.profiles !== "*" && !named.profiles.includes(profile))
938
+ throw new Error(`Listener "${listenerName}" does not allow "${profile}". Allow it with: rech listener allow ${listenerName} ${JSON.stringify(profile)}`);
939
+ }
886
940
  const candidates = listeners.filter(l => (l.profiles === "*" || l.profiles.includes(profile)) && (!listenerName || l.name === listenerName));
887
941
  if (candidates.length !== 1) throw new Error("Choose a listener with --listener <name>, or set RECHROME_URL to the desired endpoint.");
888
942
  const listener = candidates[0];
@@ -898,16 +952,129 @@ export function profileConnectionUri(profile: string, configuredUrl: string | un
898
952
  return result.toString();
899
953
  }
900
954
 
955
+ /** Prompts only when a person is at the terminal; scripts and agents get errors, never a hang. */
956
+ export const isInteractive = () => !!process.stdin.isTTY && !!process.stderr.isTTY;
957
+
958
+ /**
959
+ * Numbered choice on `output` (stderr, so stdout stays clean for piping). Enter takes the
960
+ * default; q or end of input cancels (null). Invalid answers ask again.
961
+ */
962
+ export async function promptChoice<T>(
963
+ question: string, options: { label: string; value: T }[], defaultIndex = 0,
964
+ io: { input: NodeJS.ReadableStream; output: NodeJS.WritableStream } = { input: process.stdin, output: process.stderr },
965
+ ): Promise<T | null> {
966
+ const { createInterface } = await import("readline");
967
+ const rl = createInterface({ input: io.input, output: io.output, terminal: false });
968
+ const lines = rl[Symbol.asyncIterator]();
969
+ try {
970
+ io.output.write(`${question}\n`);
971
+ options.forEach((o, i) => io.output.write(` ${String(i + 1).padStart(2)}. ${o.label}${i === defaultIndex ? " (default)" : ""}\n`));
972
+ while (true) {
973
+ io.output.write(`Choice [${defaultIndex + 1}, q to cancel]: `);
974
+ const next = await lines.next();
975
+ if (next.done) return null;
976
+ const answer = String(next.value).trim().toLowerCase();
977
+ if (answer === "q") return null;
978
+ if (answer === "") return options[defaultIndex]?.value ?? null;
979
+ const index = Number(answer) - 1;
980
+ if (Number.isInteger(index) && options[index]) return options[index].value;
981
+ io.output.write(`Enter a number from 1 to ${options.length}.\n`);
982
+ }
983
+ } finally { rl.close(); }
984
+ }
985
+
986
+ /** Registered profiles as choices, one per Chrome profile folder (the registry may alias one folder twice). */
987
+ function registeredProfileChoices(registry: Record<string, TokenEntry>): { label: string; value: string }[] {
988
+ const byDir = new Map<string, string>();
989
+ for (const key of Object.keys(registry)) {
990
+ const dir = registry[key].profileDir;
991
+ const kept = byDir.get(dir);
992
+ if (!kept || (!kept.includes("@") && key.includes("@"))) byDir.set(dir, key);
993
+ }
994
+ return [...byDir.entries()].map(([dir, key]) => ({ label: key === dir ? key : `${key} [${dir}]`, value: key }));
995
+ }
996
+
997
+ /**
998
+ * Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
999
+ * profile, preferring one with a public URL. Never the management listener, whose key gives
1000
+ * full access to every profile.
1001
+ */
1002
+ export function chooseShareListener(profile: string, listeners: Listener[]): string {
1003
+ const scoped = listeners.filter(l => l.profiles !== "*");
1004
+ const allowing = scoped.filter(l => (l.profiles as string[]).includes(profile));
1005
+ const pick = allowing.length === 1 ? allowing : allowing.filter(l => l.publicUrl);
1006
+ if (pick.length === 1) return pick[0].name;
1007
+ if (allowing.length > 1)
1008
+ throw new ShareListenerError(`"${profile}" is shared on several listeners (${allowing.map(l => l.name).join(", ")}). Pick one with --listener <name>.`, "several", allowing);
1009
+ throw new ShareListenerError([
1010
+ `"${profile}" isn't shared on any listener yet.`,
1011
+ scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
1012
+ ` Or create one: rech listener add share --listen local --prefix=rechrome --port 13776 --profile ${JSON.stringify(profile)}`,
1013
+ ].filter(Boolean).join("\n"), "none", scoped);
1014
+ }
1015
+
901
1016
  async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
902
1017
  const url = process.env[ENV_KEY];
903
- selector ??= url ? parseUrl(url).profileDirectory : undefined;
904
- if (!selector) throw new Error("Specify a profile: rech url <profile>");
1018
+ const interactive = isInteractive();
905
1019
  const registry = await readTokenRegistry();
906
1020
  const cache = await readChromeProfileCache();
1021
+ const cancelled = () => new Error("Cancelled; nothing shared.");
1022
+ const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
1023
+ const pickProfile = async (question: string, choices = registeredProfileChoices(registry)) => {
1024
+ const def = Math.max(0, choices.findIndex(c => c.value === current || registry[c.value]?.profileDir === current));
1025
+ return (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
1026
+ };
1027
+ if (!selector) {
1028
+ // No profile given: ask, defaulting to the current one (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1029
+ if (interactive && Object.keys(registry).length) selector = await pickProfile("Share which profile?");
1030
+ else {
1031
+ selector = current;
1032
+ if (!selector) throw new Error("No current profile to share. Name one: rech share <profile> (see rech profile; rech share ls lists what is shared)");
1033
+ console.error(`[rech] sharing the current profile: ${selector}`);
1034
+ }
1035
+ }
907
1036
  // A configured remote profile may not exist in this machine's local registry.
908
- const profile = url && parseUrl(url).profileDirectory === selector && !listener
909
- ? selector : (await resolveGlobalProfile(registry, cache, selector)).email;
910
- const listeners = (await readListeners())?.listeners ?? [];
1037
+ let profile: string;
1038
+ if (url && parseUrl(url).profileDirectory === selector && !listener) profile = selector;
1039
+ else {
1040
+ try { profile = (await resolveGlobalProfile(registry, cache, selector)).email; }
1041
+ catch (error) {
1042
+ if (!interactive || !(error instanceof Error)) throw error;
1043
+ if (error instanceof AmbiguousProfileError) {
1044
+ const choice = await pickProfile(`"${selector}" matches several profiles. Which one?`,
1045
+ error.candidates.map(c => ({ label: c.label, value: c.id.replace(/^(chrome|registry):/, "") })));
1046
+ profile = (await resolveGlobalProfile(registry, cache, choice)).email;
1047
+ } else if (/does not match/.test(error.message)) {
1048
+ profile = (await resolveGlobalProfile(registry, cache, await pickProfile(`No profile matches "${selector}". Share which one?`))).email;
1049
+ } else throw error;
1050
+ }
1051
+ }
1052
+ const config = await readListeners();
1053
+ const listeners = config?.listeners ?? [];
1054
+ // On a host, share through a scoped listener by default, never the management key.
1055
+ if (!listener && config) {
1056
+ try { listener = chooseShareListener(profile, listeners); }
1057
+ catch (error) {
1058
+ if (!interactive || !(error instanceof ShareListenerError)) throw error;
1059
+ const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
1060
+ if (error.kind === "several") {
1061
+ listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
1062
+ if (!listener) throw cancelled();
1063
+ } else {
1064
+ if (!error.listeners.length) throw error; // nothing to allow it on: the error names `rech listener add`
1065
+ // Allowing is a config change, so the default is to cancel.
1066
+ const target = await promptChoice(`"${profile}" isn't shared on any listener yet. Allow it on:`,
1067
+ [...error.listeners.map(l => ({ label: describe(l), value: l.name as string | null })), { label: "Cancel (change nothing)", value: null }], error.listeners.length);
1068
+ if (!target) throw cancelled();
1069
+ allowProfiles(config, target, [profile]);
1070
+ await writeListeners(config);
1071
+ console.error(`[rech] allowed "${profile}" on ${target}`);
1072
+ listener = target;
1073
+ }
1074
+ }
1075
+ }
1076
+ if (listeners.find(l => l.name === listener)?.profiles === "*")
1077
+ console.error(`[rech] "${listener}" is the local management listener: its key controls every profile. Don't share this URL.`);
911
1078
  let uri = profileConnectionUri(profile, url, listeners, listener);
912
1079
  // Prefer where a proxy exposes the listener, when it has been recorded.
913
1080
  const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
@@ -1091,7 +1258,7 @@ export function notConnectedMessage(): string {
1091
1258
  return [
1092
1259
  `rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
1093
1260
  ` On the machine with Chrome: rech setup`,
1094
- ` On another machine: rech connect '<URL printed by \`rech url\` on that machine>'`,
1261
+ ` On another machine: rech connect '<URL printed by \`rech share\` on that machine>'`,
1095
1262
  ].join("\n");
1096
1263
  }
1097
1264
 
@@ -1109,7 +1276,7 @@ export function unknownCommandHint(output: string, rechCommands: Iterable<string
1109
1276
  const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
1110
1277
  return [
1111
1278
  `rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
1112
- ` rech --help rechrome commands (setup, status, profile, url, connect, listener…)`,
1279
+ ` rech --help rechrome commands (setup, status, profile, share, connect, listener…)`,
1113
1280
  ` rech pw --help browser commands (open, click, screenshot…)`,
1114
1281
  ].join("\n");
1115
1282
  }
@@ -2026,7 +2193,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
2026
2193
  ` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
2027
2194
  `Then record where it is reachable and print the URL to share:`,
2028
2195
  ` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
2029
- ` rech url ${profile ? JSON.stringify(profile) : "<profile>"} --listener ${listener.name}`,
2196
+ ` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
2030
2197
  ];
2031
2198
  }
2032
2199
 
@@ -2078,7 +2245,7 @@ async function rotateKey(name: string): Promise<void> {
2078
2245
  const config = await requireListeners();
2079
2246
  rotateListenerKey(config, name);
2080
2247
  await writeListeners(config);
2081
- console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech url <profile> --listener ${name}`);
2248
+ console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
2082
2249
  }
2083
2250
 
2084
2251
  async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
@@ -2098,7 +2265,7 @@ async function urlList(): Promise<void> {
2098
2265
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
2099
2266
  }
2100
2267
  printTable(rows);
2101
- console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
2268
+ console.log(`\nPrint a full URL (contains the secret key): rech share <profile>`);
2102
2269
  }
2103
2270
 
2104
2271
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
@@ -2116,7 +2283,7 @@ async function saveProjectUrl(url: string): Promise<string> {
2116
2283
 
2117
2284
  async function connect(url: string): Promise<void> {
2118
2285
  const parsed = parseUrl(url);
2119
- if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech url <profile>");
2286
+ if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
2120
2287
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
2121
2288
  .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
2122
2289
  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).");
@@ -2546,7 +2713,7 @@ async function status(): Promise<void> {
2546
2713
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
2547
2714
  console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
2548
2715
  if (pingResponse?.status === 401)
2549
- console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech url <profile>\`), then \`rech connect '<url>'\``);
2716
+ console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
2550
2717
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
2551
2718
  // ReferenceError that took down the whole of `rech status`, so the one command
2552
2719
  // that reports "the relay is wedged" died exactly when the relay was wedged,
@@ -2567,7 +2734,7 @@ async function status(): Promise<void> {
2567
2734
  const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
2568
2735
  const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
2569
2736
  console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
2570
- if (isHost) console.log(`\nMore: rech profile (profiles) · rech url ls (who can connect, and where)`);
2737
+ if (isHost) console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
2571
2738
  }
2572
2739
 
2573
2740
 
@@ -2594,7 +2761,7 @@ export type RechHandlers = {
2594
2761
  };
2595
2762
 
2596
2763
  /** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
2597
- export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "url", "urls", "connect", "setup", "tray", "provision-profile", "uninstall"]);
2764
+ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "share", "connect", "setup", "tray", "provision-profile", "uninstall"]);
2598
2765
 
2599
2766
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
2600
2767
 
@@ -2621,7 +2788,7 @@ Examples:
2621
2788
  rech setup --profile you@example.com set up Chrome on this machine
2622
2789
  rech open https://example.com open a page in this project's session
2623
2790
  rech screenshot saved to <project>/.rechrome/output/
2624
- rech url you@example.com --listener share URL to give another machine (secret)
2791
+ rech share you@example.com URL to give another machine (secret)
2625
2792
  rech connect '<url>' use that URL in this project
2626
2793
 
2627
2794
  Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
@@ -2645,24 +2812,24 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2645
2812
  .command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
2646
2813
  .command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
2647
2814
  .positional("name", { type: "string", describe: "ls/list lists all (the default)" })
2648
- .option("print-uri", { type: "boolean", describe: "Same as `rech url <name>`" })
2815
+ .option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
2649
2816
  .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
2650
2817
  a => {
2651
- if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
2818
+ if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech share`
2652
2819
  if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2653
- throw new Error(`To print "${a.name}"'s connection URL: rech url ${JSON.stringify(a.name)}. To list profiles: rech profile`);
2820
+ throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
2654
2821
  })
2655
2822
  // Share with and connect from other machines
2656
- .command(["url [profile]", "urls [profile]"], "Print a connection URL to share (contains a secret key); `url ls` lists all", y => y
2657
- .positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists every listener's URLs" })
2658
- .option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
2823
+ .command("share [profile]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
2824
+ .positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
2825
+ .option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
2659
2826
  .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2660
2827
  .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2661
2828
  a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2662
2829
  ? handlers.urlList()
2663
2830
  : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2664
2831
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2665
- .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech url <profile>` on the machine with Chrome. Quote it: it contains #" })
2832
+ .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
2666
2833
  .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
2667
2834
  a => handlers.connect(a.url))
2668
2835
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y