rechrome 1.28.1 → 1.28.2

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/daemon-manager.js CHANGED
@@ -24,13 +24,14 @@ function oxmgrHasWinfix(version) {
24
24
  function pickDaemonManager(opts) {
25
25
  const override = opts.override?.toLowerCase();
26
26
  if (override === "oxmgr" && !opts.oxmgrBin) {
27
- throw new Error("RECH_DAEMON_MANAGER=oxmgr, but oxmgr is not on PATH. Install oxmgr and add it to PATH, or install pm2 with `bun add -g pm2` and set RECH_DAEMON_MANAGER=pm2.");
27
+ throw new Error("RECH_DAEMON_MANAGER=oxmgr, but oxmgr is not on PATH. Install it with `bun i -g oxmgr` and ensure the global bin directory is on PATH.");
28
28
  }
29
29
  if (override === "pm2" && !opts.pm2Bin) {
30
30
  throw new Error("RECH_DAEMON_MANAGER=pm2, but pm2 is not on PATH. Install it with `bun add -g pm2` and ensure the global bin directory is on PATH.");
31
31
  }
32
32
  if (!opts.oxmgrBin && !opts.pm2Bin) {
33
- throw new Error("No daemon process manager found on PATH (oxmgr or pm2). Install pm2 with `bun add -g pm2`, ensure the global bin directory is on PATH, then rerun `bunx rechrome setup`.");
33
+ const install = opts.isWindows ? "Install oxmgr with `bun i -g oxmgr` (on Windows, pm2 via `bun add -g pm2` is preferred unless oxmgr is the +winfix build)" : "Install oxmgr with `bun i -g oxmgr`";
34
+ throw new Error(`No daemon process manager found on PATH (oxmgr or pm2). ${install}, ensure the global bin directory is on PATH, then rerun \`bunx rechrome setup\`.`);
34
35
  }
35
36
  const oxmgr = { id: "oxmgr", bin: opts.oxmgrBin ?? "oxmgr" };
36
37
  const pm2 = { id: "pm2", bin: opts.pm2Bin ?? "pm2" };
@@ -62,9 +63,27 @@ function oxmgrInstallCommand(env) {
62
63
  return ["npm", "i", "-g", "oxmgr"];
63
64
  return ["bun", "i", "-g", "oxmgr"];
64
65
  }
66
+ var PM2_DEPRECATION = "pm2 is deprecated as the rech daemon manager; install oxmgr (`bun i -g oxmgr`) and rerun `rech setup` to migrate.";
67
+ function isDeprecatedPm2Fallback(mgr, opts) {
68
+ return mgr.id === "pm2" && !opts.isWindows && !opts.override;
69
+ }
70
+ function listsProcess(id, output, name) {
71
+ if (id === "pm2") {
72
+ try {
73
+ const list = JSON.parse(output);
74
+ return Array.isArray(list) && list.some((p) => p?.name === name);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+ return output.split(/[\s\u2502|\u2503]+/).includes(name);
80
+ }
65
81
  export {
66
82
  OXMGR_WINFIX_BASE,
83
+ PM2_DEPRECATION,
67
84
  compareVersion,
85
+ isDeprecatedPm2Fallback,
86
+ listsProcess,
68
87
  oxmgrHasWinfix,
69
88
  oxmgrInstallCommand,
70
89
  pickDaemonManager
package/daemon-manager.ts CHANGED
@@ -58,13 +58,18 @@ export function pickDaemonManager(opts: {
58
58
  }): DaemonManager {
59
59
  const override = opts.override?.toLowerCase();
60
60
  if (override === "oxmgr" && !opts.oxmgrBin) {
61
- throw new Error("RECH_DAEMON_MANAGER=oxmgr, but oxmgr is not on PATH. Install oxmgr and add it to PATH, or install pm2 with `bun add -g pm2` and set RECH_DAEMON_MANAGER=pm2.");
61
+ throw new Error("RECH_DAEMON_MANAGER=oxmgr, but oxmgr is not on PATH. Install it with `bun i -g oxmgr` and ensure the global bin directory is on PATH.");
62
62
  }
63
63
  if (override === "pm2" && !opts.pm2Bin) {
64
64
  throw new Error("RECH_DAEMON_MANAGER=pm2, but pm2 is not on PATH. Install it with `bun add -g pm2` and ensure the global bin directory is on PATH.");
65
65
  }
66
66
  if (!opts.oxmgrBin && !opts.pm2Bin) {
67
- throw new Error("No daemon process manager found on PATH (oxmgr or pm2). Install pm2 with `bun add -g pm2`, ensure the global bin directory is on PATH, then rerun `bunx rechrome setup`.");
67
+ // oxmgr is the recommended manager. On Windows stock oxmgr is only used when pm2 is
68
+ // absent (see below), so pm2 stays a documented alternative there.
69
+ const install = opts.isWindows
70
+ ? "Install oxmgr with `bun i -g oxmgr` (on Windows, pm2 via `bun add -g pm2` is preferred unless oxmgr is the +winfix build)"
71
+ : "Install oxmgr with `bun i -g oxmgr`";
72
+ throw new Error(`No daemon process manager found on PATH (oxmgr or pm2). ${install}, ensure the global bin directory is on PATH, then rerun \`bunx rechrome setup\`.`);
68
73
  }
69
74
  const oxmgr: DaemonManager = { id: "oxmgr", bin: opts.oxmgrBin ?? "oxmgr" };
70
75
  const pm2: DaemonManager = { id: "pm2", bin: opts.pm2Bin ?? "pm2" };
@@ -97,3 +102,29 @@ export function oxmgrInstallCommand(env: { npm_config_user_agent?: string; npm_e
97
102
  if (/(^|\/)(npm|npx)(-cli\.js|\.cmd|\.exe)?$/i.test(execPath)) return ["npm", "i", "-g", "oxmgr"];
98
103
  return ["bun", "i", "-g", "oxmgr"];
99
104
  }
105
+
106
+ export const PM2_DEPRECATION =
107
+ "pm2 is deprecated as the rech daemon manager; install oxmgr (`bun i -g oxmgr`) and rerun `rech setup` to migrate.";
108
+
109
+ // Whether `mgr` is pm2 only as a stopgap because oxmgr is missing: the case where the user
110
+ // should be nudged to oxmgr. Never on Windows, where pm2 is the deliberate choice over stock
111
+ // (non-winfix) oxmgr and installing oxmgr would not change the pick, and never for an explicit
112
+ // RECH_DAEMON_MANAGER=pm2.
113
+ export function isDeprecatedPm2Fallback(mgr: DaemonManager, opts: { isWindows: boolean; override?: string | null }): boolean {
114
+ return mgr.id === "pm2" && !opts.isWindows && !opts.override;
115
+ }
116
+
117
+ // Whether a manager's process list (pm2 `jlist` JSON / oxmgr `list` table) registers exactly
118
+ // `name`. A substring test false-positives on other processes whose names, paths or args
119
+ // mention it (a pm2 app running from a `.../rechrome/...` checkout, or legacy `rechrome-serve`).
120
+ export function listsProcess(id: DaemonManager["id"], output: string, name: string): boolean {
121
+ if (id === "pm2") {
122
+ try {
123
+ const list = JSON.parse(output);
124
+ return Array.isArray(list) && list.some((p: { name?: unknown } | null) => p?.name === name);
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+ return output.split(/[\s│|┃]+/).includes(name);
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.28.1",
3
+ "version": "1.28.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
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;
@@ -977,15 +977,45 @@ async function callServe(
977
977
  console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
978
978
  process.exit(1);
979
979
  }
980
- return res.json();
980
+ const text = await res.text();
981
+ try {
982
+ return JSON.parse(text);
983
+ } catch {
984
+ // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
985
+ const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
986
+ if (throwOnFailure) throw new Error(detail);
987
+ console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
988
+ process.exit(1);
989
+ }
981
990
  }
982
991
 
992
+ const BOOLEAN_OPEN_FLAGS = new Set(["--headed", "--persistent", "--in-memory", "--extension"]);
993
+
983
994
  export function normalizeCommandArgs(args: string[]): string[] {
984
995
  const normalized = [...args];
985
996
  if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
997
+ // `rech open hello.com`: profile-scoped listeners accept only HTTP(S)/about:blank targets, so
998
+ // give a bare host an https:// scheme the way a browser address bar would.
999
+ if (["open", "goto", "tab-new"].includes(normalized[0])) {
1000
+ // The target is the first positional. A token after a `--flag` without `=` is that flag's
1001
+ // value (`open --profile my-profile url`), unless the flag is a known boolean.
1002
+ const takesValue = (flag: string) => flag.startsWith("-") && !flag.includes("=") && !BOOLEAN_OPEN_FLAGS.has(flag);
1003
+ const i = normalized.findIndex((a, idx) => idx > 0 && !a.startsWith("-") && !takesValue(normalized[idx - 1]!));
1004
+ if (i > 0) normalized[i] = withDefaultScheme(normalized[i]!);
1005
+ }
986
1006
  return normalized;
987
1007
  }
988
1008
 
1009
+ /** `hello.com` -> `https://hello.com`, `localhost:3000` -> `http://localhost:3000`; URLs with a scheme and paths are unchanged. */
1010
+ export function withDefaultScheme(target: string): string {
1011
+ if (/^[./\\~]/.test(target) || /^[a-z]:[\\/]/i.test(target)) return target; // a file path, not a host
1012
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !/^[^/:]+:\d+(\/|$|[?#])/.test(target)) return target;
1013
+ // Loopback by exact hostname: `localhost.example.com` / `127.example.com` are public hosts.
1014
+ const host = (target.match(/^(\[[^\]]*\]|[^/:?#]*)/)?.[1] ?? "").toLowerCase();
1015
+ const loopback = host === "localhost" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
1016
+ return loopback ? `http://${target}` : `https://${target}`;
1017
+ }
1018
+
989
1019
  // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
990
1020
  // Only flags before the first positional (the playwright subcommand) are rech globals — a
991
1021
  // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
@@ -1116,7 +1146,9 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1116
1146
  }
1117
1147
  if (stderr) {
1118
1148
  if (stderr.includes('Extension connection timeout')) {
1119
- const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
1149
+ // The daemon resolves registered profiles' bridge tokens itself and then asks for a reload;
1150
+ // only blame a missing install when neither side had credentials.
1151
+ const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] || /reload the .*extension/i.test(stderr);
1120
1152
  const last = hasToken
1121
1153
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
1122
1154
  : ` -> extension[not installed] (run: rech setup)`;
@@ -1405,6 +1437,8 @@ function daemonManager(): DaemonManager {
1405
1437
  isWindows: IS_WINDOWS,
1406
1438
  override: process.env.RECH_DAEMON_MANAGER,
1407
1439
  });
1440
+ if (isDeprecatedPm2Fallback(_daemonMgr, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER }))
1441
+ console.error(`[rech] warning: using pm2 because oxmgr is not on PATH. ${PM2_DEPRECATION}`);
1408
1442
  return _daemonMgr;
1409
1443
  }
1410
1444
 
@@ -1441,7 +1475,7 @@ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
1441
1475
  }
1442
1476
 
1443
1477
  // 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.
1478
+ // Match a name in it with listsProcess, not a substring test.
1445
1479
  async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
1446
1480
  const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1447
1481
  return await new Response(proc.stdout).text();
@@ -1489,18 +1523,22 @@ export function resolvePlaywrightCli(root: string = import.meta.dir): string {
1489
1523
  /**
1490
1524
  * Make sure a daemon process manager is available, offering to install oxmgr
1491
1525
  * (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
1492
- * since installing oxmgr would not satisfy it.
1526
+ * since installing oxmgr would not satisfy it. When only the deprecated pm2 fallback
1527
+ * is available (POSIX without oxmgr), the offer is made too, but declining keeps pm2.
1493
1528
  */
1494
1529
  async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
1530
+ let fallback: DaemonManager | undefined;
1495
1531
  try {
1496
- daemonManager();
1497
- return;
1532
+ fallback = daemonManager();
1533
+ if (!isDeprecatedPm2Fallback(fallback, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER })) return;
1498
1534
  } catch (error) {
1499
1535
  if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1500
1536
  }
1501
1537
  const command = oxmgrInstallCommand(process.env);
1502
- const answer = yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1538
+ const reason = fallback ? "Only the deprecated pm2 is available" : "oxmgr is missing";
1539
+ const answer = yes ? "yes" : (await ask(` ${reason}. Install oxmgr globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1503
1540
  if (!/^(y|yes)$/i.test(answer)) {
1541
+ if (fallback) return; // keep the working pm2 setup
1504
1542
  throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1505
1543
  }
1506
1544
  console.log(` Installing oxmgr: ${command.join(" ")}`);
@@ -1555,6 +1593,22 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1555
1593
 
1556
1594
  // Drop any prior registration (current + legacy names) before re-adding.
1557
1595
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1596
+ // Migrating from pm2 to oxmgr: a serve still registered in pm2 would hold the port and be
1597
+ // resurrected at login, fighting the oxmgr-managed one. Remove it from pm2 too.
1598
+ const pm2Bin = mgr.id === "oxmgr" ? Bun.which("pm2") : null;
1599
+ if (pm2Bin) {
1600
+ const pm2: DaemonManager = { id: "pm2", bin: pm2Bin };
1601
+ const listed = await pmList(pm2).catch(() => "");
1602
+ const stale = [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES].filter(name => listsProcess("pm2", listed, name));
1603
+ if (stale.length) {
1604
+ console.log(` Migrating from pm2: removing ${stale.join(", ")}`);
1605
+ for (const name of stale) await runPm(pm2, ["delete", name]);
1606
+ // --force: pm2 won't save an empty list otherwise, keeping the old dump that `pm2 resurrect`
1607
+ // would bring back at login to fight the oxmgr-managed serve over the port.
1608
+ if (await runPm(pm2, ["save", "--force"]) !== 0)
1609
+ console.warn(" pm2 save failed; run `pm2 save --force` so pm2 doesn't resurrect the old serve at login.");
1610
+ }
1611
+ }
1558
1612
 
1559
1613
  let startCode: number;
1560
1614
  if (mgr.id === "pm2") {
@@ -1593,7 +1647,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1593
1647
  async function daemonUninstall(): Promise<void> {
1594
1648
  const mgr = daemonManager();
1595
1649
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1596
- if (mgr.id === "pm2") await runPm(mgr, ["save"]);
1650
+ if (mgr.id === "pm2") await runPm(mgr, ["save", "--force"]); // an emptied list must still overwrite the dump
1597
1651
  else await runPm(mgr, ["service", "uninstall"]);
1598
1652
  console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
1599
1653
  }
@@ -2497,13 +2551,16 @@ async function status(): Promise<void> {
2497
2551
  // ReferenceError that took down the whole of `rech status`, so the one command
2498
2552
  // that reports "the relay is wedged" died exactly when the relay was wedged,
2499
2553
  // 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
2554
  // The daemon line is about this machine; a client of a remote host has no local daemon to report.
2503
2555
  const isHost = !!(await readListeners().catch(() => null));
2556
+ // No oxmgr/pm2 on PATH must not take down `rech status`: report it instead of throwing.
2557
+ let mgr: DaemonManager | undefined;
2558
+ if (isHost) try { mgr = daemonManager(); } catch { /* reported below */ }
2559
+ if (pingBody?.degraded)
2560
+ 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
2561
  if (isHost) {
2505
- const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
2506
- console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
2562
+ const daemonRegistered = mgr ? listsProcess(mgr.id, await pmList(mgr).catch(() => ""), PM_PROCESS_NAME) : false;
2563
+ console.log(`daemon: ${daemonRegistered ? `${mgr!.id} (${PM_PROCESS_NAME})` : mgr ? "not installed" : "not installed (no oxmgr or pm2 on PATH)"}`);
2507
2564
  }
2508
2565
  // Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
2509
2566
  const effective = resolveEffectiveProfile(parsed.profileDirectory);
package/rechrome.ts 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.ts";
16
+ import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.ts";
17
17
 
18
18
  export const ENV_KEY = "RECHROME_URL";
19
19
  export const DEFAULT_PORT = 13775;
@@ -977,15 +977,45 @@ async function callServe(
977
977
  console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
978
978
  process.exit(1);
979
979
  }
980
- return res.json();
980
+ const text = await res.text();
981
+ try {
982
+ return JSON.parse(text);
983
+ } catch {
984
+ // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
985
+ const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
986
+ if (throwOnFailure) throw new Error(detail);
987
+ console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
988
+ process.exit(1);
989
+ }
981
990
  }
982
991
 
992
+ const BOOLEAN_OPEN_FLAGS = new Set(["--headed", "--persistent", "--in-memory", "--extension"]);
993
+
983
994
  export function normalizeCommandArgs(args: string[]): string[] {
984
995
  const normalized = [...args];
985
996
  if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
997
+ // `rech open hello.com`: profile-scoped listeners accept only HTTP(S)/about:blank targets, so
998
+ // give a bare host an https:// scheme the way a browser address bar would.
999
+ if (["open", "goto", "tab-new"].includes(normalized[0])) {
1000
+ // The target is the first positional. A token after a `--flag` without `=` is that flag's
1001
+ // value (`open --profile my-profile url`), unless the flag is a known boolean.
1002
+ const takesValue = (flag: string) => flag.startsWith("-") && !flag.includes("=") && !BOOLEAN_OPEN_FLAGS.has(flag);
1003
+ const i = normalized.findIndex((a, idx) => idx > 0 && !a.startsWith("-") && !takesValue(normalized[idx - 1]!));
1004
+ if (i > 0) normalized[i] = withDefaultScheme(normalized[i]!);
1005
+ }
986
1006
  return normalized;
987
1007
  }
988
1008
 
1009
+ /** `hello.com` -> `https://hello.com`, `localhost:3000` -> `http://localhost:3000`; URLs with a scheme and paths are unchanged. */
1010
+ export function withDefaultScheme(target: string): string {
1011
+ if (/^[./\\~]/.test(target) || /^[a-z]:[\\/]/i.test(target)) return target; // a file path, not a host
1012
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !/^[^/:]+:\d+(\/|$|[?#])/.test(target)) return target;
1013
+ // Loopback by exact hostname: `localhost.example.com` / `127.example.com` are public hosts.
1014
+ const host = (target.match(/^(\[[^\]]*\]|[^/:?#]*)/)?.[1] ?? "").toLowerCase();
1015
+ const loopback = host === "localhost" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
1016
+ return loopback ? `http://${target}` : `https://${target}`;
1017
+ }
1018
+
989
1019
  // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
990
1020
  // Only flags before the first positional (the playwright subcommand) are rech globals — a
991
1021
  // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
@@ -1116,7 +1146,9 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1116
1146
  }
1117
1147
  if (stderr) {
1118
1148
  if (stderr.includes('Extension connection timeout')) {
1119
- const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
1149
+ // The daemon resolves registered profiles' bridge tokens itself and then asks for a reload;
1150
+ // only blame a missing install when neither side had credentials.
1151
+ const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] || /reload the .*extension/i.test(stderr);
1120
1152
  const last = hasToken
1121
1153
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
1122
1154
  : ` -> extension[not installed] (run: rech setup)`;
@@ -1405,6 +1437,8 @@ function daemonManager(): DaemonManager {
1405
1437
  isWindows: IS_WINDOWS,
1406
1438
  override: process.env.RECH_DAEMON_MANAGER,
1407
1439
  });
1440
+ if (isDeprecatedPm2Fallback(_daemonMgr, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER }))
1441
+ console.error(`[rech] warning: using pm2 because oxmgr is not on PATH. ${PM2_DEPRECATION}`);
1408
1442
  return _daemonMgr;
1409
1443
  }
1410
1444
 
@@ -1441,7 +1475,7 @@ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
1441
1475
  }
1442
1476
 
1443
1477
  // 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.
1478
+ // Match a name in it with listsProcess, not a substring test.
1445
1479
  async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
1446
1480
  const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1447
1481
  return await new Response(proc.stdout).text();
@@ -1489,18 +1523,22 @@ export function resolvePlaywrightCli(root: string = import.meta.dir): string {
1489
1523
  /**
1490
1524
  * Make sure a daemon process manager is available, offering to install oxmgr
1491
1525
  * (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
1492
- * since installing oxmgr would not satisfy it.
1526
+ * since installing oxmgr would not satisfy it. When only the deprecated pm2 fallback
1527
+ * is available (POSIX without oxmgr), the offer is made too, but declining keeps pm2.
1493
1528
  */
1494
1529
  async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
1530
+ let fallback: DaemonManager | undefined;
1495
1531
  try {
1496
- daemonManager();
1497
- return;
1532
+ fallback = daemonManager();
1533
+ if (!isDeprecatedPm2Fallback(fallback, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER })) return;
1498
1534
  } catch (error) {
1499
1535
  if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1500
1536
  }
1501
1537
  const command = oxmgrInstallCommand(process.env);
1502
- const answer = yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1538
+ const reason = fallback ? "Only the deprecated pm2 is available" : "oxmgr is missing";
1539
+ const answer = yes ? "yes" : (await ask(` ${reason}. Install oxmgr globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1503
1540
  if (!/^(y|yes)$/i.test(answer)) {
1541
+ if (fallback) return; // keep the working pm2 setup
1504
1542
  throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1505
1543
  }
1506
1544
  console.log(` Installing oxmgr: ${command.join(" ")}`);
@@ -1555,6 +1593,22 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1555
1593
 
1556
1594
  // Drop any prior registration (current + legacy names) before re-adding.
1557
1595
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1596
+ // Migrating from pm2 to oxmgr: a serve still registered in pm2 would hold the port and be
1597
+ // resurrected at login, fighting the oxmgr-managed one. Remove it from pm2 too.
1598
+ const pm2Bin = mgr.id === "oxmgr" ? Bun.which("pm2") : null;
1599
+ if (pm2Bin) {
1600
+ const pm2: DaemonManager = { id: "pm2", bin: pm2Bin };
1601
+ const listed = await pmList(pm2).catch(() => "");
1602
+ const stale = [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES].filter(name => listsProcess("pm2", listed, name));
1603
+ if (stale.length) {
1604
+ console.log(` Migrating from pm2: removing ${stale.join(", ")}`);
1605
+ for (const name of stale) await runPm(pm2, ["delete", name]);
1606
+ // --force: pm2 won't save an empty list otherwise, keeping the old dump that `pm2 resurrect`
1607
+ // would bring back at login to fight the oxmgr-managed serve over the port.
1608
+ if (await runPm(pm2, ["save", "--force"]) !== 0)
1609
+ console.warn(" pm2 save failed; run `pm2 save --force` so pm2 doesn't resurrect the old serve at login.");
1610
+ }
1611
+ }
1558
1612
 
1559
1613
  let startCode: number;
1560
1614
  if (mgr.id === "pm2") {
@@ -1593,7 +1647,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1593
1647
  async function daemonUninstall(): Promise<void> {
1594
1648
  const mgr = daemonManager();
1595
1649
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1596
- if (mgr.id === "pm2") await runPm(mgr, ["save"]);
1650
+ if (mgr.id === "pm2") await runPm(mgr, ["save", "--force"]); // an emptied list must still overwrite the dump
1597
1651
  else await runPm(mgr, ["service", "uninstall"]);
1598
1652
  console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
1599
1653
  }
@@ -2497,13 +2551,16 @@ async function status(): Promise<void> {
2497
2551
  // ReferenceError that took down the whole of `rech status`, so the one command
2498
2552
  // that reports "the relay is wedged" died exactly when the relay was wedged,
2499
2553
  // 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
2554
  // The daemon line is about this machine; a client of a remote host has no local daemon to report.
2503
2555
  const isHost = !!(await readListeners().catch(() => null));
2556
+ // No oxmgr/pm2 on PATH must not take down `rech status`: report it instead of throwing.
2557
+ let mgr: DaemonManager | undefined;
2558
+ if (isHost) try { mgr = daemonManager(); } catch { /* reported below */ }
2559
+ if (pingBody?.degraded)
2560
+ 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
2561
  if (isHost) {
2505
- const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
2506
- console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
2562
+ const daemonRegistered = mgr ? listsProcess(mgr.id, await pmList(mgr).catch(() => ""), PM_PROCESS_NAME) : false;
2563
+ console.log(`daemon: ${daemonRegistered ? `${mgr!.id} (${PM_PROCESS_NAME})` : mgr ? "not installed" : "not installed (no oxmgr or pm2 on PATH)"}`);
2507
2564
  }
2508
2565
  // Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
2509
2566
  const effective = resolveEffectiveProfile(parsed.profileDirectory);