rechrome 1.28.1 → 1.29.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
@@ -13,7 +13,7 @@ import { pathToFileURL } from "url";
13
13
  import { createRequire } from "node:module";
14
14
  import { spawn as cpSpawn } from "child_process";
15
15
  import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
16
- import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
16
+ import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
17
17
 
18
18
  export const ENV_KEY = "RECHROME_URL";
19
19
  export const DEFAULT_PORT = 13775;
@@ -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;
@@ -977,15 +1144,45 @@ async function callServe(
977
1144
  console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
978
1145
  process.exit(1);
979
1146
  }
980
- return res.json();
1147
+ const text = await res.text();
1148
+ try {
1149
+ return JSON.parse(text);
1150
+ } catch {
1151
+ // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
1152
+ const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1153
+ if (throwOnFailure) throw new Error(detail);
1154
+ console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1155
+ process.exit(1);
1156
+ }
981
1157
  }
982
1158
 
1159
+ const BOOLEAN_OPEN_FLAGS = new Set(["--headed", "--persistent", "--in-memory", "--extension"]);
1160
+
983
1161
  export function normalizeCommandArgs(args: string[]): string[] {
984
1162
  const normalized = [...args];
985
1163
  if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
1164
+ // `rech open hello.com`: profile-scoped listeners accept only HTTP(S)/about:blank targets, so
1165
+ // give a bare host an https:// scheme the way a browser address bar would.
1166
+ if (["open", "goto", "tab-new"].includes(normalized[0])) {
1167
+ // The target is the first positional. A token after a `--flag` without `=` is that flag's
1168
+ // value (`open --profile my-profile url`), unless the flag is a known boolean.
1169
+ const takesValue = (flag: string) => flag.startsWith("-") && !flag.includes("=") && !BOOLEAN_OPEN_FLAGS.has(flag);
1170
+ const i = normalized.findIndex((a, idx) => idx > 0 && !a.startsWith("-") && !takesValue(normalized[idx - 1]!));
1171
+ if (i > 0) normalized[i] = withDefaultScheme(normalized[i]!);
1172
+ }
986
1173
  return normalized;
987
1174
  }
988
1175
 
1176
+ /** `hello.com` -> `https://hello.com`, `localhost:3000` -> `http://localhost:3000`; URLs with a scheme and paths are unchanged. */
1177
+ export function withDefaultScheme(target: string): string {
1178
+ if (/^[./\\~]/.test(target) || /^[a-z]:[\\/]/i.test(target)) return target; // a file path, not a host
1179
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !/^[^/:]+:\d+(\/|$|[?#])/.test(target)) return target;
1180
+ // Loopback by exact hostname: `localhost.example.com` / `127.example.com` are public hosts.
1181
+ const host = (target.match(/^(\[[^\]]*\]|[^/:?#]*)/)?.[1] ?? "").toLowerCase();
1182
+ const loopback = host === "localhost" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
1183
+ return loopback ? `http://${target}` : `https://${target}`;
1184
+ }
1185
+
989
1186
  // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
990
1187
  // Only flags before the first positional (the playwright subcommand) are rech globals — a
991
1188
  // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
@@ -1061,7 +1258,7 @@ export function notConnectedMessage(): string {
1061
1258
  return [
1062
1259
  `rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
1063
1260
  ` On the machine with Chrome: rech setup`,
1064
- ` 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>'`,
1065
1262
  ].join("\n");
1066
1263
  }
1067
1264
 
@@ -1079,7 +1276,7 @@ export function unknownCommandHint(output: string, rechCommands: Iterable<string
1079
1276
  const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
1080
1277
  return [
1081
1278
  `rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
1082
- ` rech --help rechrome commands (setup, status, profile, url, connect, listener…)`,
1279
+ ` rech --help rechrome commands (setup, status, profile, share, connect, listener…)`,
1083
1280
  ` rech pw --help browser commands (open, click, screenshot…)`,
1084
1281
  ].join("\n");
1085
1282
  }
@@ -1116,7 +1313,9 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1116
1313
  }
1117
1314
  if (stderr) {
1118
1315
  if (stderr.includes('Extension connection timeout')) {
1119
- const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
1316
+ // The daemon resolves registered profiles' bridge tokens itself and then asks for a reload;
1317
+ // only blame a missing install when neither side had credentials.
1318
+ const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] || /reload the .*extension/i.test(stderr);
1120
1319
  const last = hasToken
1121
1320
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
1122
1321
  : ` -> extension[not installed] (run: rech setup)`;
@@ -1405,6 +1604,8 @@ function daemonManager(): DaemonManager {
1405
1604
  isWindows: IS_WINDOWS,
1406
1605
  override: process.env.RECH_DAEMON_MANAGER,
1407
1606
  });
1607
+ if (isDeprecatedPm2Fallback(_daemonMgr, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER }))
1608
+ console.error(`[rech] warning: using pm2 because oxmgr is not on PATH. ${PM2_DEPRECATION}`);
1408
1609
  return _daemonMgr;
1409
1610
  }
1410
1611
 
@@ -1441,7 +1642,7 @@ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
1441
1642
  }
1442
1643
 
1443
1644
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
1444
- // Both render the process name verbatim, so callers can substring-match it.
1645
+ // Match a name in it with listsProcess, not a substring test.
1445
1646
  async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
1446
1647
  const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1447
1648
  return await new Response(proc.stdout).text();
@@ -1489,18 +1690,22 @@ export function resolvePlaywrightCli(root: string = import.meta.dir): string {
1489
1690
  /**
1490
1691
  * Make sure a daemon process manager is available, offering to install oxmgr
1491
1692
  * (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
1492
- * since installing oxmgr would not satisfy it.
1693
+ * since installing oxmgr would not satisfy it. When only the deprecated pm2 fallback
1694
+ * is available (POSIX without oxmgr), the offer is made too, but declining keeps pm2.
1493
1695
  */
1494
1696
  async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
1697
+ let fallback: DaemonManager | undefined;
1495
1698
  try {
1496
- daemonManager();
1497
- return;
1699
+ fallback = daemonManager();
1700
+ if (!isDeprecatedPm2Fallback(fallback, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER })) return;
1498
1701
  } catch (error) {
1499
1702
  if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1500
1703
  }
1501
1704
  const command = oxmgrInstallCommand(process.env);
1502
- const answer = yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1705
+ const reason = fallback ? "Only the deprecated pm2 is available" : "oxmgr is missing";
1706
+ const answer = yes ? "yes" : (await ask(` ${reason}. Install oxmgr globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1503
1707
  if (!/^(y|yes)$/i.test(answer)) {
1708
+ if (fallback) return; // keep the working pm2 setup
1504
1709
  throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1505
1710
  }
1506
1711
  console.log(` Installing oxmgr: ${command.join(" ")}`);
@@ -1555,6 +1760,22 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1555
1760
 
1556
1761
  // Drop any prior registration (current + legacy names) before re-adding.
1557
1762
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1763
+ // Migrating from pm2 to oxmgr: a serve still registered in pm2 would hold the port and be
1764
+ // resurrected at login, fighting the oxmgr-managed one. Remove it from pm2 too.
1765
+ const pm2Bin = mgr.id === "oxmgr" ? Bun.which("pm2") : null;
1766
+ if (pm2Bin) {
1767
+ const pm2: DaemonManager = { id: "pm2", bin: pm2Bin };
1768
+ const listed = await pmList(pm2).catch(() => "");
1769
+ const stale = [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES].filter(name => listsProcess("pm2", listed, name));
1770
+ if (stale.length) {
1771
+ console.log(` Migrating from pm2: removing ${stale.join(", ")}`);
1772
+ for (const name of stale) await runPm(pm2, ["delete", name]);
1773
+ // --force: pm2 won't save an empty list otherwise, keeping the old dump that `pm2 resurrect`
1774
+ // would bring back at login to fight the oxmgr-managed serve over the port.
1775
+ if (await runPm(pm2, ["save", "--force"]) !== 0)
1776
+ console.warn(" pm2 save failed; run `pm2 save --force` so pm2 doesn't resurrect the old serve at login.");
1777
+ }
1778
+ }
1558
1779
 
1559
1780
  let startCode: number;
1560
1781
  if (mgr.id === "pm2") {
@@ -1593,7 +1814,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1593
1814
  async function daemonUninstall(): Promise<void> {
1594
1815
  const mgr = daemonManager();
1595
1816
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1596
- if (mgr.id === "pm2") await runPm(mgr, ["save"]);
1817
+ if (mgr.id === "pm2") await runPm(mgr, ["save", "--force"]); // an emptied list must still overwrite the dump
1597
1818
  else await runPm(mgr, ["service", "uninstall"]);
1598
1819
  console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
1599
1820
  }
@@ -1972,7 +2193,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
1972
2193
  ` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
1973
2194
  `Then record where it is reachable and print the URL to share:`,
1974
2195
  ` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
1975
- ` rech url ${profile ? JSON.stringify(profile) : "<profile>"} --listener ${listener.name}`,
2196
+ ` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
1976
2197
  ];
1977
2198
  }
1978
2199
 
@@ -2024,7 +2245,7 @@ async function rotateKey(name: string): Promise<void> {
2024
2245
  const config = await requireListeners();
2025
2246
  rotateListenerKey(config, name);
2026
2247
  await writeListeners(config);
2027
- 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}`);
2028
2249
  }
2029
2250
 
2030
2251
  async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
@@ -2044,7 +2265,7 @@ async function urlList(): Promise<void> {
2044
2265
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
2045
2266
  }
2046
2267
  printTable(rows);
2047
- 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>`);
2048
2269
  }
2049
2270
 
2050
2271
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
@@ -2062,7 +2283,7 @@ async function saveProjectUrl(url: string): Promise<string> {
2062
2283
 
2063
2284
  async function connect(url: string): Promise<void> {
2064
2285
  const parsed = parseUrl(url);
2065
- 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>");
2066
2287
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
2067
2288
  .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
2068
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).");
@@ -2492,25 +2713,28 @@ async function status(): Promise<void> {
2492
2713
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
2493
2714
  console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
2494
2715
  if (pingResponse?.status === 401)
2495
- 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>'\``);
2496
2717
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
2497
2718
  // ReferenceError that took down the whole of `rech status`, so the one command
2498
2719
  // that reports "the relay is wedged" died exactly when the relay was wedged,
2499
2720
  // printing a stack trace instead of the restart hint.
2500
- if (pingBody?.degraded)
2501
- console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${daemonManager().id} restart ${PM_PROCESS_NAME}\``);
2502
2721
  // The daemon line is about this machine; a client of a remote host has no local daemon to report.
2503
2722
  const isHost = !!(await readListeners().catch(() => null));
2723
+ // No oxmgr/pm2 on PATH must not take down `rech status`: report it instead of throwing.
2724
+ let mgr: DaemonManager | undefined;
2725
+ if (isHost) try { mgr = daemonManager(); } catch { /* reported below */ }
2726
+ if (pingBody?.degraded)
2727
+ console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; ${isHost ? `force it now with \`${mgr?.id ?? "oxmgr"} restart ${PM_PROCESS_NAME}\`` : "the daemon host can restart it"}`);
2504
2728
  if (isHost) {
2505
- const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
2506
- console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
2729
+ const daemonRegistered = mgr ? listsProcess(mgr.id, await pmList(mgr).catch(() => ""), PM_PROCESS_NAME) : false;
2730
+ console.log(`daemon: ${daemonRegistered ? `${mgr!.id} (${PM_PROCESS_NAME})` : mgr ? "not installed" : "not installed (no oxmgr or pm2 on PATH)"}`);
2507
2731
  }
2508
2732
  // Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
2509
2733
  const effective = resolveEffectiveProfile(parsed.profileDirectory);
2510
2734
  const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
2511
2735
  const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
2512
2736
  console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
2513
- 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)`);
2514
2738
  }
2515
2739
 
2516
2740
 
@@ -2537,7 +2761,7 @@ export type RechHandlers = {
2537
2761
  };
2538
2762
 
2539
2763
  /** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
2540
- 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"]);
2541
2765
 
2542
2766
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
2543
2767
 
@@ -2564,7 +2788,7 @@ Examples:
2564
2788
  rech setup --profile you@example.com set up Chrome on this machine
2565
2789
  rech open https://example.com open a page in this project's session
2566
2790
  rech screenshot saved to <project>/.rechrome/output/
2567
- 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)
2568
2792
  rech connect '<url>' use that URL in this project
2569
2793
 
2570
2794
  Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
@@ -2588,24 +2812,24 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2588
2812
  .command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
2589
2813
  .command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
2590
2814
  .positional("name", { type: "string", describe: "ls/list lists all (the default)" })
2591
- .option("print-uri", { type: "boolean", describe: "Same as `rech url <name>`" })
2815
+ .option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
2592
2816
  .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
2593
2817
  a => {
2594
- 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`
2595
2819
  if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2596
- 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`);
2597
2821
  })
2598
2822
  // Share with and connect from other machines
2599
- .command(["url [profile]", "urls [profile]"], "Print a connection URL to share (contains a secret key); `url ls` lists all", y => y
2600
- .positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists every listener's URLs" })
2601
- .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)" })
2602
2826
  .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2603
2827
  .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2604
2828
  a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2605
2829
  ? handlers.urlList()
2606
2830
  : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2607
2831
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2608
- .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 #" })
2609
2833
  .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
2610
2834
  a => handlers.connect(a.url))
2611
2835
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y