rechrome 1.28.0 → 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/README.md CHANGED
@@ -285,7 +285,7 @@ directory. Pass `-s=<name>` for a named sub-session, or `--isolate` for a throwa
285
285
  ```bash
286
286
  git clone https://github.com/snomiao/rechrome.git
287
287
  cd rechrome
288
- bun install
288
+ bun install # `prepare` also builds vendor/ from vendor-src/, so the checkout has a working CLI
289
289
  bun link # makes this checkout the global rechrome / rech
290
290
  bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts # rechrome's own tests (plain `bun test` also finds the vendored forks' suites)
291
291
  ```
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.0",
3
+ "version": "1.28.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
@@ -32,7 +32,9 @@
32
32
  "serve": "bun run rechrome.ts serve",
33
33
  "test": "bun test",
34
34
  "vendor-cli": "bash scripts/vendor-cli.sh",
35
- "prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js"
35
+ "prepare": "bash scripts/vendor-cli.sh --prepare",
36
+ "check-pack": "bash scripts/check-pack.sh",
37
+ "prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bash scripts/check-pack.sh"
36
38
  },
37
39
  "devDependencies": {
38
40
  "@types/bun": "latest",
package/rechrome.js CHANGED
@@ -10,9 +10,10 @@ import { hostname, homedir, networkInterfaces } from "os";
10
10
  import { isIPv4 } from "net";
11
11
  import { join, basename, dirname } from "path";
12
12
  import { pathToFileURL } from "url";
13
+ import { createRequire } from "node:module";
13
14
  import { spawn as cpSpawn } from "child_process";
14
15
  import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
15
- import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
16
+ import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.js";
16
17
 
17
18
  export const ENV_KEY = "RECHROME_URL";
18
19
  export const DEFAULT_PORT = 13775;
@@ -976,15 +977,45 @@ async function callServe(
976
977
  console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
977
978
  process.exit(1);
978
979
  }
979
- 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
+ }
980
990
  }
981
991
 
992
+ const BOOLEAN_OPEN_FLAGS = new Set(["--headed", "--persistent", "--in-memory", "--extension"]);
993
+
982
994
  export function normalizeCommandArgs(args: string[]): string[] {
983
995
  const normalized = [...args];
984
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
+ }
985
1006
  return normalized;
986
1007
  }
987
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
+
988
1019
  // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
989
1020
  // Only flags before the first positional (the playwright subcommand) are rech globals — a
990
1021
  // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
@@ -1115,7 +1146,9 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1115
1146
  }
1116
1147
  if (stderr) {
1117
1148
  if (stderr.includes('Extension connection timeout')) {
1118
- 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);
1119
1152
  const last = hasToken
1120
1153
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
1121
1154
  : ` -> extension[not installed] (run: rech setup)`;
@@ -1404,6 +1437,8 @@ function daemonManager(): DaemonManager {
1404
1437
  isWindows: IS_WINDOWS,
1405
1438
  override: process.env.RECH_DAEMON_MANAGER,
1406
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}`);
1407
1442
  return _daemonMgr;
1408
1443
  }
1409
1444
 
@@ -1440,29 +1475,47 @@ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
1440
1475
  }
1441
1476
 
1442
1477
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
1443
- // Both render the process name verbatim, so callers can substring-match it.
1478
+ // Match a name in it with listsProcess, not a substring test.
1444
1479
  async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
1445
1480
  const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1446
1481
  return await new Response(proc.stdout).text();
1447
1482
  }
1448
1483
 
1484
+ // A candidate playwright-cli entry is usable only if the playwright-core it requires actually
1485
+ // resolves FROM that entry — the wrapper does `require('playwright-core/lib/tools/cli-client/program')`,
1486
+ // a deep subpath reachable only through the fork's patched `exports` map. existsSync on the .js is
1487
+ // not the same check: an uninitialised/half-built lib/playwright-cli submodule leaves the wrapper on
1488
+ // disk with no resolvable core, and the failure surfaces later as MODULE_NOT_FOUND inside the daemon.
1489
+ export function playwrightCliIsUsable(jsEntry: string): boolean {
1490
+ try {
1491
+ createRequire(jsEntry).resolve("playwright-core/lib/tools/cli-client/program");
1492
+ return true;
1493
+ } catch {
1494
+ return false;
1495
+ }
1496
+ }
1497
+
1449
1498
  // Resolve which playwright-cli the daemon runs to drive Chrome. Priority:
1450
1499
  // 1. PLAYWRIGHT_CLI env override — explicit, already a full command string.
1451
1500
  // 2. Vendored fork in a git checkout (lib/playwright-cli/playwright-cli.js) — the patched
1452
1501
  // multi-tab CLI + patched playwright-core (PLAYWRIGHT_MCP_PROFILE_DIRECTORY etc.).
1453
1502
  // 3. The fork bundled into the npm tarball (vendor/playwright-cli/playwright-cli.js, produced by
1454
- // scripts/vendor-cli.sh at prepublish). This is the batteries-included default for
1455
- // `bun i -g rechrome`: self-contained, no @playwright/cli dep, no browser-binary download.
1503
+ // scripts/vendor-cli.sh at prepublish, and by `prepare` on `bun install` in a checkout). This
1504
+ // is the batteries-included default for `bun i -g rechrome`: self-contained, no
1505
+ // @playwright/cli dep, no browser-binary download.
1456
1506
  // 4. Bare `playwright-cli-multi-tab` on PATH — legacy fallback for a pre-existing global link.
1507
+ // Candidates 2–3 must also pass playwrightCliIsUsable(), so a present-but-broken one falls through.
1508
+ // lib/ stays ahead of vendor/ on purpose: a dev who built the fork wants their patched core, not
1509
+ // the (possibly older) vendor-src snapshot that `prepare` unpacks into vendor/.
1457
1510
  // A resolved .js entry is run through `node` on Windows (which can't exec a .js by shebang) and
1458
1511
  // bare on POSIX (its `#!/usr/bin/env node` shebang runs it under node, which the relay handshake
1459
1512
  // needs — see daemonInstall). serve splits the result on spaces into argv.
1460
- export function resolvePlaywrightCli(): string {
1513
+ export function resolvePlaywrightCli(root: string = import.meta.dir): string {
1461
1514
  if (process.env.PLAYWRIGHT_CLI) return process.env.PLAYWRIGHT_CLI;
1462
1515
  const jsEntry = [
1463
- join(import.meta.dir, "lib/playwright-cli/playwright-cli.js"),
1464
- join(import.meta.dir, "vendor/playwright-cli/playwright-cli.js"),
1465
- ].find(existsSync);
1516
+ join(root, "lib/playwright-cli/playwright-cli.js"),
1517
+ join(root, "vendor/playwright-cli/playwright-cli.js"),
1518
+ ].filter(existsSync).find(playwrightCliIsUsable);
1466
1519
  if (jsEntry) return IS_WINDOWS ? `node ${jsEntry}` : jsEntry;
1467
1520
  return "playwright-cli-multi-tab";
1468
1521
  }
@@ -1470,18 +1523,22 @@ export function resolvePlaywrightCli(): string {
1470
1523
  /**
1471
1524
  * Make sure a daemon process manager is available, offering to install oxmgr
1472
1525
  * (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
1473
- * 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.
1474
1528
  */
1475
1529
  async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
1530
+ let fallback: DaemonManager | undefined;
1476
1531
  try {
1477
- daemonManager();
1478
- return;
1532
+ fallback = daemonManager();
1533
+ if (!isDeprecatedPm2Fallback(fallback, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER })) return;
1479
1534
  } catch (error) {
1480
1535
  if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1481
1536
  }
1482
1537
  const command = oxmgrInstallCommand(process.env);
1483
- 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();
1484
1540
  if (!/^(y|yes)$/i.test(answer)) {
1541
+ if (fallback) return; // keep the working pm2 setup
1485
1542
  throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1486
1543
  }
1487
1544
  console.log(` Installing oxmgr: ${command.join(" ")}`);
@@ -1536,6 +1593,22 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1536
1593
 
1537
1594
  // Drop any prior registration (current + legacy names) before re-adding.
1538
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
+ }
1539
1612
 
1540
1613
  let startCode: number;
1541
1614
  if (mgr.id === "pm2") {
@@ -1574,7 +1647,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1574
1647
  async function daemonUninstall(): Promise<void> {
1575
1648
  const mgr = daemonManager();
1576
1649
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1577
- 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
1578
1651
  else await runPm(mgr, ["service", "uninstall"]);
1579
1652
  console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
1580
1653
  }
@@ -2478,13 +2551,16 @@ async function status(): Promise<void> {
2478
2551
  // ReferenceError that took down the whole of `rech status`, so the one command
2479
2552
  // that reports "the relay is wedged" died exactly when the relay was wedged,
2480
2553
  // printing a stack trace instead of the restart hint.
2481
- if (pingBody?.degraded)
2482
- 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}\``);
2483
2554
  // The daemon line is about this machine; a client of a remote host has no local daemon to report.
2484
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"}`);
2485
2561
  if (isHost) {
2486
- const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
2487
- 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)"}`);
2488
2564
  }
2489
2565
  // Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
2490
2566
  const effective = resolveEffectiveProfile(parsed.profileDirectory);
@@ -2586,7 +2662,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2586
2662
  ? handlers.urlList()
2587
2663
  : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2588
2664
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2589
- .positional("url", { type: "string", demandOption: true }),
2665
+ .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech url <profile>` on the machine with Chrome. Quote it: it contains #" })
2666
+ .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
2590
2667
  a => handlers.connect(a.url))
2591
2668
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
2592
2669
  .command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
@@ -2630,8 +2707,16 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2630
2707
  .demandCommand(1)
2631
2708
  .strict()
2632
2709
  .help()
2710
+ .alias("help", "h")
2633
2711
  .version(false)
2634
- .fail((message, error) => { throw error ?? new Error(message); });
2712
+ // A parse error (missing argument, unknown option…) shows that command's help above the
2713
+ // error, so the fix is visible. Errors thrown by a command's own handler pass through.
2714
+ .fail((message, error, y) => {
2715
+ if (error) throw error;
2716
+ // Straight to stderr: Bun's console.error would paint the whole help red.
2717
+ y.showHelp((help: string) => process.stderr.write(`${help}\n\n`));
2718
+ throw new Error(`rech: ${/^Not enough non-option arguments/.test(message) ? "missing a required argument; see the usage line above" : message}`);
2719
+ });
2635
2720
  }
2636
2721
 
2637
2722
  if (import.meta.main) {
package/rechrome.ts CHANGED
@@ -10,9 +10,10 @@ import { hostname, homedir, networkInterfaces } from "os";
10
10
  import { isIPv4 } from "net";
11
11
  import { join, basename, dirname } from "path";
12
12
  import { pathToFileURL } from "url";
13
+ import { createRequire } from "node:module";
13
14
  import { spawn as cpSpawn } from "child_process";
14
15
  import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
15
- import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
16
+ import { isDeprecatedPm2Fallback, listsProcess, oxmgrInstallCommand, pickDaemonManager, PM2_DEPRECATION, type DaemonManager } from "./daemon-manager.ts";
16
17
 
17
18
  export const ENV_KEY = "RECHROME_URL";
18
19
  export const DEFAULT_PORT = 13775;
@@ -976,15 +977,45 @@ async function callServe(
976
977
  console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
977
978
  process.exit(1);
978
979
  }
979
- 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
+ }
980
990
  }
981
991
 
992
+ const BOOLEAN_OPEN_FLAGS = new Set(["--headed", "--persistent", "--in-memory", "--extension"]);
993
+
982
994
  export function normalizeCommandArgs(args: string[]): string[] {
983
995
  const normalized = [...args];
984
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
+ }
985
1006
  return normalized;
986
1007
  }
987
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
+
988
1019
  // Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
989
1020
  // Only flags before the first positional (the playwright subcommand) are rech globals — a
990
1021
  // --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
@@ -1115,7 +1146,9 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1115
1146
  }
1116
1147
  if (stderr) {
1117
1148
  if (stderr.includes('Extension connection timeout')) {
1118
- 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);
1119
1152
  const last = hasToken
1120
1153
  ? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
1121
1154
  : ` -> extension[not installed] (run: rech setup)`;
@@ -1404,6 +1437,8 @@ function daemonManager(): DaemonManager {
1404
1437
  isWindows: IS_WINDOWS,
1405
1438
  override: process.env.RECH_DAEMON_MANAGER,
1406
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}`);
1407
1442
  return _daemonMgr;
1408
1443
  }
1409
1444
 
@@ -1440,29 +1475,47 @@ async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
1440
1475
  }
1441
1476
 
1442
1477
  // Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
1443
- // Both render the process name verbatim, so callers can substring-match it.
1478
+ // Match a name in it with listsProcess, not a substring test.
1444
1479
  async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
1445
1480
  const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
1446
1481
  return await new Response(proc.stdout).text();
1447
1482
  }
1448
1483
 
1484
+ // A candidate playwright-cli entry is usable only if the playwright-core it requires actually
1485
+ // resolves FROM that entry — the wrapper does `require('playwright-core/lib/tools/cli-client/program')`,
1486
+ // a deep subpath reachable only through the fork's patched `exports` map. existsSync on the .js is
1487
+ // not the same check: an uninitialised/half-built lib/playwright-cli submodule leaves the wrapper on
1488
+ // disk with no resolvable core, and the failure surfaces later as MODULE_NOT_FOUND inside the daemon.
1489
+ export function playwrightCliIsUsable(jsEntry: string): boolean {
1490
+ try {
1491
+ createRequire(jsEntry).resolve("playwright-core/lib/tools/cli-client/program");
1492
+ return true;
1493
+ } catch {
1494
+ return false;
1495
+ }
1496
+ }
1497
+
1449
1498
  // Resolve which playwright-cli the daemon runs to drive Chrome. Priority:
1450
1499
  // 1. PLAYWRIGHT_CLI env override — explicit, already a full command string.
1451
1500
  // 2. Vendored fork in a git checkout (lib/playwright-cli/playwright-cli.js) — the patched
1452
1501
  // multi-tab CLI + patched playwright-core (PLAYWRIGHT_MCP_PROFILE_DIRECTORY etc.).
1453
1502
  // 3. The fork bundled into the npm tarball (vendor/playwright-cli/playwright-cli.js, produced by
1454
- // scripts/vendor-cli.sh at prepublish). This is the batteries-included default for
1455
- // `bun i -g rechrome`: self-contained, no @playwright/cli dep, no browser-binary download.
1503
+ // scripts/vendor-cli.sh at prepublish, and by `prepare` on `bun install` in a checkout). This
1504
+ // is the batteries-included default for `bun i -g rechrome`: self-contained, no
1505
+ // @playwright/cli dep, no browser-binary download.
1456
1506
  // 4. Bare `playwright-cli-multi-tab` on PATH — legacy fallback for a pre-existing global link.
1507
+ // Candidates 2–3 must also pass playwrightCliIsUsable(), so a present-but-broken one falls through.
1508
+ // lib/ stays ahead of vendor/ on purpose: a dev who built the fork wants their patched core, not
1509
+ // the (possibly older) vendor-src snapshot that `prepare` unpacks into vendor/.
1457
1510
  // A resolved .js entry is run through `node` on Windows (which can't exec a .js by shebang) and
1458
1511
  // bare on POSIX (its `#!/usr/bin/env node` shebang runs it under node, which the relay handshake
1459
1512
  // needs — see daemonInstall). serve splits the result on spaces into argv.
1460
- export function resolvePlaywrightCli(): string {
1513
+ export function resolvePlaywrightCli(root: string = import.meta.dir): string {
1461
1514
  if (process.env.PLAYWRIGHT_CLI) return process.env.PLAYWRIGHT_CLI;
1462
1515
  const jsEntry = [
1463
- join(import.meta.dir, "lib/playwright-cli/playwright-cli.js"),
1464
- join(import.meta.dir, "vendor/playwright-cli/playwright-cli.js"),
1465
- ].find(existsSync);
1516
+ join(root, "lib/playwright-cli/playwright-cli.js"),
1517
+ join(root, "vendor/playwright-cli/playwright-cli.js"),
1518
+ ].filter(existsSync).find(playwrightCliIsUsable);
1466
1519
  if (jsEntry) return IS_WINDOWS ? `node ${jsEntry}` : jsEntry;
1467
1520
  return "playwright-cli-multi-tab";
1468
1521
  }
@@ -1470,18 +1523,22 @@ export function resolvePlaywrightCli(): string {
1470
1523
  /**
1471
1524
  * Make sure a daemon process manager is available, offering to install oxmgr
1472
1525
  * (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
1473
- * 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.
1474
1528
  */
1475
1529
  async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
1530
+ let fallback: DaemonManager | undefined;
1476
1531
  try {
1477
- daemonManager();
1478
- return;
1532
+ fallback = daemonManager();
1533
+ if (!isDeprecatedPm2Fallback(fallback, { isWindows: IS_WINDOWS, override: process.env.RECH_DAEMON_MANAGER })) return;
1479
1534
  } catch (error) {
1480
1535
  if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1481
1536
  }
1482
1537
  const command = oxmgrInstallCommand(process.env);
1483
- 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();
1484
1540
  if (!/^(y|yes)$/i.test(answer)) {
1541
+ if (fallback) return; // keep the working pm2 setup
1485
1542
  throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1486
1543
  }
1487
1544
  console.log(` Installing oxmgr: ${command.join(" ")}`);
@@ -1536,6 +1593,22 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1536
1593
 
1537
1594
  // Drop any prior registration (current + legacy names) before re-adding.
1538
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
+ }
1539
1612
 
1540
1613
  let startCode: number;
1541
1614
  if (mgr.id === "pm2") {
@@ -1574,7 +1647,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
1574
1647
  async function daemonUninstall(): Promise<void> {
1575
1648
  const mgr = daemonManager();
1576
1649
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
1577
- 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
1578
1651
  else await runPm(mgr, ["service", "uninstall"]);
1579
1652
  console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
1580
1653
  }
@@ -2478,13 +2551,16 @@ async function status(): Promise<void> {
2478
2551
  // ReferenceError that took down the whole of `rech status`, so the one command
2479
2552
  // that reports "the relay is wedged" died exactly when the relay was wedged,
2480
2553
  // printing a stack trace instead of the restart hint.
2481
- if (pingBody?.degraded)
2482
- 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}\``);
2483
2554
  // The daemon line is about this machine; a client of a remote host has no local daemon to report.
2484
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"}`);
2485
2561
  if (isHost) {
2486
- const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
2487
- 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)"}`);
2488
2564
  }
2489
2565
  // Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
2490
2566
  const effective = resolveEffectiveProfile(parsed.profileDirectory);
@@ -2586,7 +2662,8 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2586
2662
  ? handlers.urlList()
2587
2663
  : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2588
2664
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2589
- .positional("url", { type: "string", demandOption: true }),
2665
+ .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech url <profile>` on the machine with Chrome. Quote it: it contains #" })
2666
+ .example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", ""),
2590
2667
  a => handlers.connect(a.url))
2591
2668
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
2592
2669
  .command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
@@ -2630,8 +2707,16 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2630
2707
  .demandCommand(1)
2631
2708
  .strict()
2632
2709
  .help()
2710
+ .alias("help", "h")
2633
2711
  .version(false)
2634
- .fail((message, error) => { throw error ?? new Error(message); });
2712
+ // A parse error (missing argument, unknown option…) shows that command's help above the
2713
+ // error, so the fix is visible. Errors thrown by a command's own handler pass through.
2714
+ .fail((message, error, y) => {
2715
+ if (error) throw error;
2716
+ // Straight to stderr: Bun's console.error would paint the whole help red.
2717
+ y.showHelp((help: string) => process.stderr.write(`${help}\n\n`));
2718
+ throw new Error(`rech: ${/^Not enough non-option arguments/.test(message) ? "missing a required argument; see the usage line above" : message}`);
2719
+ });
2635
2720
  }
2636
2721
 
2637
2722
  if (import.meta.main) {