rechrome 1.27.0 → 1.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +4 -2
- package/rechrome.js +226 -189
- package/rechrome.ts +226 -189
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Pick the profile up front with `rech setup --profile you@example.com`.
|
|
|
39
39
|
Check it:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
rech status #
|
|
42
|
+
rech status # is it working: the URL in use, the daemon, the current profile
|
|
43
43
|
rech profile # every Chrome profile and whether it is connected
|
|
44
44
|
```
|
|
45
45
|
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.1",
|
|
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
|
-
"
|
|
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,6 +10,7 @@ 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
16
|
import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
|
|
@@ -1041,7 +1042,49 @@ export function extractGlobalProfileArg(args: string[]): { args: string[]; selec
|
|
|
1041
1042
|
return { args: rest, selector };
|
|
1042
1043
|
}
|
|
1043
1044
|
|
|
1044
|
-
|
|
1045
|
+
function editDistance(a: string, b: string): number {
|
|
1046
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
1047
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1048
|
+
let diagonal = row[0];
|
|
1049
|
+
row[0] = i;
|
|
1050
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1051
|
+
const above = row[j];
|
|
1052
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1053
|
+
diagonal = above;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return row[b.length];
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** What to do when no RECHROME_URL is configured: set up here, or connect to another machine. */
|
|
1060
|
+
export function notConnectedMessage(): string {
|
|
1061
|
+
return [
|
|
1062
|
+
`rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
|
|
1063
|
+
` On the machine with Chrome: rech setup`,
|
|
1064
|
+
` On another machine: rech connect '<URL printed by \`rech url\` on that machine>'`,
|
|
1065
|
+
].join("\n");
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* When playwright-cli rejects a command, replace its own usage dump with a rech-branded hint.
|
|
1070
|
+
* Candidates are rech's commands plus the browser commands listed in that usage text, so the
|
|
1071
|
+
* suggestion stays current without a hardcoded list. Returns null for any other output.
|
|
1072
|
+
*/
|
|
1073
|
+
export function unknownCommandHint(output: string, rechCommands: Iterable<string> = RECH_COMMANDS): string | null {
|
|
1074
|
+
const unknown = output.match(/^Unknown command: (\S+)/m)?.[1];
|
|
1075
|
+
if (!unknown) return null;
|
|
1076
|
+
const browser = [...output.matchAll(/^ {2}([a-z][a-z0-9-]+) /gm)].map(m => m[1]);
|
|
1077
|
+
const candidates = [...new Set([...rechCommands, ...browser])];
|
|
1078
|
+
const scored = candidates.map(c => ({ c, d: editDistance(unknown.toLowerCase(), c) })).sort((x, y) => x.d - y.d);
|
|
1079
|
+
const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
|
|
1080
|
+
return [
|
|
1081
|
+
`rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
|
|
1082
|
+
` rech --help rechrome commands (setup, status, profile, url, connect, listener…)`,
|
|
1083
|
+
` rech pw --help browser commands (open, click, screenshot…)`,
|
|
1084
|
+
].join("\n");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
|
|
1045
1088
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
1046
1089
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
1047
1090
|
args = normalizeCommandArgs(args);
|
|
@@ -1060,11 +1103,17 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
1060
1103
|
|
|
1061
1104
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
1062
1105
|
if (existingSession && isOpenWithUrl) {
|
|
1063
|
-
return run(url, ["goto", ...args.slice(1)], overrideEnv);
|
|
1106
|
+
return run(url, ["goto", ...args.slice(1)], overrideEnv, opts);
|
|
1064
1107
|
}
|
|
1065
1108
|
|
|
1066
1109
|
if (existingSession)
|
|
1067
1110
|
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
1111
|
+
// A typo'd command: rech's hint instead of playwright-cli's full usage (kept for `rech pw`).
|
|
1112
|
+
const hint = !opts.verbatim && status !== 0 ? unknownCommandHint(`${stderr ?? ""}\n${stdout ?? ""}`) : null;
|
|
1113
|
+
if (hint) {
|
|
1114
|
+
console.error(hint);
|
|
1115
|
+
process.exit(status || 1);
|
|
1116
|
+
}
|
|
1068
1117
|
if (stderr) {
|
|
1069
1118
|
if (stderr.includes('Extension connection timeout')) {
|
|
1070
1119
|
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
@@ -1398,23 +1447,41 @@ async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
|
|
|
1398
1447
|
return await new Response(proc.stdout).text();
|
|
1399
1448
|
}
|
|
1400
1449
|
|
|
1450
|
+
// A candidate playwright-cli entry is usable only if the playwright-core it requires actually
|
|
1451
|
+
// resolves FROM that entry — the wrapper does `require('playwright-core/lib/tools/cli-client/program')`,
|
|
1452
|
+
// a deep subpath reachable only through the fork's patched `exports` map. existsSync on the .js is
|
|
1453
|
+
// not the same check: an uninitialised/half-built lib/playwright-cli submodule leaves the wrapper on
|
|
1454
|
+
// disk with no resolvable core, and the failure surfaces later as MODULE_NOT_FOUND inside the daemon.
|
|
1455
|
+
export function playwrightCliIsUsable(jsEntry: string): boolean {
|
|
1456
|
+
try {
|
|
1457
|
+
createRequire(jsEntry).resolve("playwright-core/lib/tools/cli-client/program");
|
|
1458
|
+
return true;
|
|
1459
|
+
} catch {
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1401
1464
|
// Resolve which playwright-cli the daemon runs to drive Chrome. Priority:
|
|
1402
1465
|
// 1. PLAYWRIGHT_CLI env override — explicit, already a full command string.
|
|
1403
1466
|
// 2. Vendored fork in a git checkout (lib/playwright-cli/playwright-cli.js) — the patched
|
|
1404
1467
|
// multi-tab CLI + patched playwright-core (PLAYWRIGHT_MCP_PROFILE_DIRECTORY etc.).
|
|
1405
1468
|
// 3. The fork bundled into the npm tarball (vendor/playwright-cli/playwright-cli.js, produced by
|
|
1406
|
-
// scripts/vendor-cli.sh at prepublish
|
|
1407
|
-
// `bun i -g rechrome`: self-contained, no
|
|
1469
|
+
// scripts/vendor-cli.sh at prepublish, and by `prepare` on `bun install` in a checkout). This
|
|
1470
|
+
// is the batteries-included default for `bun i -g rechrome`: self-contained, no
|
|
1471
|
+
// @playwright/cli dep, no browser-binary download.
|
|
1408
1472
|
// 4. Bare `playwright-cli-multi-tab` on PATH — legacy fallback for a pre-existing global link.
|
|
1473
|
+
// Candidates 2–3 must also pass playwrightCliIsUsable(), so a present-but-broken one falls through.
|
|
1474
|
+
// lib/ stays ahead of vendor/ on purpose: a dev who built the fork wants their patched core, not
|
|
1475
|
+
// the (possibly older) vendor-src snapshot that `prepare` unpacks into vendor/.
|
|
1409
1476
|
// A resolved .js entry is run through `node` on Windows (which can't exec a .js by shebang) and
|
|
1410
1477
|
// bare on POSIX (its `#!/usr/bin/env node` shebang runs it under node, which the relay handshake
|
|
1411
1478
|
// needs — see daemonInstall). serve splits the result on spaces into argv.
|
|
1412
|
-
export function resolvePlaywrightCli(): string {
|
|
1479
|
+
export function resolvePlaywrightCli(root: string = import.meta.dir): string {
|
|
1413
1480
|
if (process.env.PLAYWRIGHT_CLI) return process.env.PLAYWRIGHT_CLI;
|
|
1414
1481
|
const jsEntry = [
|
|
1415
|
-
join(
|
|
1416
|
-
join(
|
|
1417
|
-
].
|
|
1482
|
+
join(root, "lib/playwright-cli/playwright-cli.js"),
|
|
1483
|
+
join(root, "vendor/playwright-cli/playwright-cli.js"),
|
|
1484
|
+
].filter(existsSync).find(playwrightCliIsUsable);
|
|
1418
1485
|
if (jsEntry) return IS_WINDOWS ? `node ${jsEntry}` : jsEntry;
|
|
1419
1486
|
return "playwright-cli-multi-tab";
|
|
1420
1487
|
}
|
|
@@ -1866,8 +1933,17 @@ async function requireListeners() {
|
|
|
1866
1933
|
return config;
|
|
1867
1934
|
}
|
|
1868
1935
|
|
|
1936
|
+
/** Print rows as left-aligned columns; the first row is the header. */
|
|
1937
|
+
function printTable(rows: string[][]): void {
|
|
1938
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => (r[i] ?? "").length)));
|
|
1939
|
+
for (const r of rows) console.log(r.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd());
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1869
1942
|
async function listListeners(): Promise<void> {
|
|
1870
|
-
|
|
1943
|
+
const rows = [["NAME", "ADDRESS", "PROFILES", "PUBLIC URL"]];
|
|
1944
|
+
for (const l of (await requireListeners()).listeners)
|
|
1945
|
+
rows.push([l.name, `${listenerAddress(l)}${normalizePrefix(l.prefix)}`, l.profiles === "*" ? "(all — local management)" : l.profiles.join(", "), l.publicUrl ?? "-"]);
|
|
1946
|
+
printTable(rows);
|
|
1871
1947
|
}
|
|
1872
1948
|
|
|
1873
1949
|
async function removeListener(name: string): Promise<void> {
|
|
@@ -1967,8 +2043,7 @@ async function urlList(): Promise<void> {
|
|
|
1967
2043
|
const local = `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`;
|
|
1968
2044
|
for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
|
|
1969
2045
|
}
|
|
1970
|
-
|
|
1971
|
-
for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
2046
|
+
printTable(rows);
|
|
1972
2047
|
console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
|
|
1973
2048
|
}
|
|
1974
2049
|
|
|
@@ -2400,126 +2475,45 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2400
2475
|
async function status(): Promise<void> {
|
|
2401
2476
|
const url = process.env[ENV_KEY];
|
|
2402
2477
|
if (!url) {
|
|
2403
|
-
console.log(`serve: not configured
|
|
2478
|
+
console.log(`serve: not configured`);
|
|
2479
|
+
console.log(notConnectedMessage().split("\n").slice(1).join("\n"));
|
|
2404
2480
|
return;
|
|
2405
2481
|
}
|
|
2406
2482
|
const parsed = parseUrl(url);
|
|
2407
2483
|
const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
2408
|
-
//
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; listener?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2484
|
+
// The authenticated /ping reports which listener answered, its bind, and the profiles it allows.
|
|
2485
|
+
const pingResponse = ping
|
|
2486
|
+
? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
|
|
2487
|
+
: null;
|
|
2488
|
+
const pingBody = pingResponse?.ok
|
|
2489
|
+
? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2415
2490
|
: null;
|
|
2416
2491
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
2417
2492
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
2418
|
-
console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` :
|
|
2493
|
+
console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
|
|
2494
|
+
if (pingResponse?.status === 401)
|
|
2495
|
+
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech url <profile>\`), then \`rech connect '<url>'\``);
|
|
2419
2496
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
2420
2497
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
2421
2498
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
|
2422
2499
|
// printing a stack trace instead of the restart hint.
|
|
2423
2500
|
if (pingBody?.degraded)
|
|
2424
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}\``);
|
|
2425
|
-
|
|
2426
|
-
const
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
}
|
|
2438
|
-
} else if (parsed.profileDirectory) {
|
|
2439
|
-
// Legacy: no registry yet, show from RECHROME_URL
|
|
2440
|
-
const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
|
|
2441
|
-
console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
function printHelp(): void {
|
|
2446
|
-
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
2447
|
-
|
|
2448
|
-
Usage:
|
|
2449
|
-
rech [--profile <email|name|folder>] <playwright-args...>
|
|
2450
|
-
Run Playwright CLI command with the given registered
|
|
2451
|
-
Chrome profile. --profile selects the profile by exact
|
|
2452
|
-
registered email (e.g. you@gmail.com), exact Chrome
|
|
2453
|
-
profile name, or exact profile folder name. The profile
|
|
2454
|
-
must already be registered (see \`rech setup\`). Place
|
|
2455
|
-
--profile before the playwright subcommand. Requires
|
|
2456
|
-
${ENV_KEY}.
|
|
2457
|
-
rech setup [--listen <local|lan|tailscale|IP>] [--profile <email|name|folder>] [--token <tok>] [--prefix <path>] [--port <port>] [--yes]
|
|
2458
|
-
First-time setup: daemon + Chrome extension + config
|
|
2459
|
-
--prefix=rechrome mounts at /rechrome/ on a scoped listener.
|
|
2460
|
-
Prefixed setup defaults to the management port + 1; override with --port.
|
|
2461
|
-
Offers to install missing oxmgr globally (y/N).
|
|
2462
|
-
--yes approves installation without prompting.
|
|
2463
|
-
--profile selects the Chrome profile non-interactively.
|
|
2464
|
-
Menu numbers are not accepted. Resolution order is exact
|
|
2465
|
-
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
2466
|
-
then exact profile folder name (e.g. "Profile 1"). See
|
|
2467
|
-
available values with \`rech profile\`.
|
|
2468
|
-
--token (or RECH_TOKEN) supplies the auth token for
|
|
2469
|
-
non-TTY/agent runs, skipping the interactive paste
|
|
2470
|
-
rech provision-profile <name> --experimental [--headed]
|
|
2471
|
-
(experimental) Auto-provision a managed QA profile on
|
|
2472
|
-
Chrome for Testing — branded Chrome 149+ rejects
|
|
2473
|
-
--load-extension, so this is a clean browser, not your
|
|
2474
|
-
real Chrome. For your real Chrome, use \`rech setup\`
|
|
2475
|
-
rech status Show current configuration and serve health
|
|
2476
|
-
rech tray [show|hide|stop] Native menu-bar/tray icon for the serve daemon
|
|
2477
|
-
(show=start, hide/show toggle, stop=quit). Auto-
|
|
2478
|
-
starts after \`rech setup\`; skipped with no GUI
|
|
2479
|
-
rech uninstall Remove the serve daemon and clear config
|
|
2480
|
-
rech serve Start the serve server manually (foreground)
|
|
2481
|
-
rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
|
|
2482
|
-
rech listener allow|deny <name> <profile...>
|
|
2483
|
-
Add or remove profiles on an existing listener
|
|
2484
|
-
rech listener port [name] Print a listener's port, for a reverse-proxy command
|
|
2485
|
-
rech listener set <name> --public-url <url>
|
|
2486
|
-
Record where a proxy exposes the listener (rech url uses it)
|
|
2487
|
-
rech listener rotate-key <name>
|
|
2488
|
-
New key for a listener; URLs with the old key stop working
|
|
2489
|
-
rech profile [ls|list]
|
|
2490
|
-
List Chrome + managed test profiles and connection status
|
|
2491
|
-
rech url [profile] [--listener <name>] [--local] [--save]
|
|
2492
|
-
Print a connection URL (includes the secret key): the public
|
|
2493
|
-
URL when one is set, else the listener address. --save also
|
|
2494
|
-
writes it to this project's .rechrome/.env.local.
|
|
2495
|
-
\`rech profile [name] --print-uri\` is an alias.
|
|
2496
|
-
rech url ls List every listener × profile URL (keys hidden)
|
|
2497
|
-
rech connect <url> Check a shared URL answers, then save it for this project
|
|
2498
|
-
rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
|
|
2499
|
-
rech pw <playwright-args...> Forward verbatim to playwright-cli, even when a name clashes
|
|
2500
|
-
with rech's own (rech pw --version, rech pw status)
|
|
2501
|
-
rech --version rechrome's version
|
|
2502
|
-
rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
|
|
2503
|
-
fragile single-shot flow (OAuth/login) never shares tabs
|
|
2504
|
-
with the worktree's default session
|
|
2505
|
-
|
|
2506
|
-
Environment:
|
|
2507
|
-
${ENV_KEY} Server URL set by \`rech setup\`
|
|
2508
|
-
RECH_TOKEN Auth token for \`rech setup\` (same as --token)
|
|
2509
|
-
RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
|
|
2510
|
-
client reuses is keyed on the worktree root path; \`branch\` restores the
|
|
2511
|
-
old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
|
|
2512
|
-
RECH_SETUP_AGENT Setup hints: codex | claude | none (otherwise auto-detected)
|
|
2513
|
-
|
|
2514
|
-
Examples:
|
|
2515
|
-
rech setup
|
|
2516
|
-
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
2517
|
-
rech --profile you@gmail.com open https://example.com
|
|
2518
|
-
rech eval "() => document.title"
|
|
2519
|
-
rech open https://example.com
|
|
2520
|
-
rech screenshot`);
|
|
2502
|
+
// The daemon line is about this machine; a client of a remote host has no local daemon to report.
|
|
2503
|
+
const isHost = !!(await readListeners().catch(() => null));
|
|
2504
|
+
if (isHost) {
|
|
2505
|
+
const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
|
|
2506
|
+
console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
2507
|
+
}
|
|
2508
|
+
// Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
|
|
2509
|
+
const effective = resolveEffectiveProfile(parsed.profileDirectory);
|
|
2510
|
+
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
2511
|
+
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
2512
|
+
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)`);
|
|
2521
2514
|
}
|
|
2522
2515
|
|
|
2516
|
+
|
|
2523
2517
|
export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
|
|
2524
2518
|
export type RechHandlers = {
|
|
2525
2519
|
serve(): Promise<void> | void;
|
|
@@ -2547,114 +2541,158 @@ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners"
|
|
|
2547
2541
|
|
|
2548
2542
|
const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
|
|
2549
2543
|
|
|
2544
|
+
// yargs trims indentation in .usage(), so the indented browser-command block lives in the epilogue.
|
|
2545
|
+
const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from scripts, agents and other machines
|
|
2546
|
+
|
|
2547
|
+
Usage: rech <command> [options] · rech <browser-command> [args]`;
|
|
2548
|
+
|
|
2549
|
+
const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
2550
|
+
rech [--profile <p>] [--isolate] <browser-command> [args]
|
|
2551
|
+
open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
|
|
2552
|
+
--profile <p> as another registered profile (email, name or folder); put it first
|
|
2553
|
+
--isolate in a throwaway session, e.g. for a login flow
|
|
2554
|
+
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
2555
|
+
rech --version rechrome's version
|
|
2556
|
+
|
|
2557
|
+
Environment:
|
|
2558
|
+
${ENV_KEY} connection URL; read from the nearest .rechrome/.env.local or .env.local
|
|
2559
|
+
RECH_IDENTITY session key: worktree (default) | branch | cwd
|
|
2560
|
+
RECH_TOKEN extension token for \`rech setup\` (same as --token)
|
|
2561
|
+
RECH_SETUP_AGENT setup hints: codex | claude | none (auto-detected)
|
|
2562
|
+
|
|
2563
|
+
Examples:
|
|
2564
|
+
rech setup --profile you@example.com set up Chrome on this machine
|
|
2565
|
+
rech open https://example.com open a page in this project's session
|
|
2566
|
+
rech screenshot saved to <project>/.rechrome/output/
|
|
2567
|
+
rech url you@example.com --listener share URL to give another machine (secret)
|
|
2568
|
+
rech connect '<url>' use that URL in this project
|
|
2569
|
+
|
|
2570
|
+
Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
|
|
2571
|
+
|
|
2550
2572
|
export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
2551
2573
|
return yargs(argv)
|
|
2552
2574
|
.scriptName("rech")
|
|
2575
|
+
.usage(HELP_USAGE)
|
|
2576
|
+
.epilogue(HELP_EPILOGUE)
|
|
2577
|
+
.wrap(Math.min(110, process.stdout.columns || 110))
|
|
2553
2578
|
.parserConfiguration({ "parse-numbers": false, "parse-positional-numbers": false })
|
|
2554
|
-
|
|
2555
|
-
.command("
|
|
2556
|
-
|
|
2557
|
-
.
|
|
2558
|
-
.
|
|
2579
|
+
// Set up and inspect this machine
|
|
2580
|
+
.command("setup", "Set up this machine: daemon, Chrome extension, connection", y => y
|
|
2581
|
+
.option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: exact email, Chrome profile name, or folder (e.g. \"Profile 1\"); not menu numbers" })
|
|
2582
|
+
.option("token", { type: "string", requiresArg: true, describe: "Extension token, for headless runs (default: read from the profile, or RECH_TOKEN)" })
|
|
2583
|
+
.option("listen", { type: "string", requiresArg: true, describe: "Who can reach this profile: local (default) | lan | tailscale | <detected IP>" })
|
|
2584
|
+
.option("prefix", { type: "string", requiresArg: true, describe: "URL path for a proxied listener, e.g. rechrome (port defaults to the management port + 1)" })
|
|
2585
|
+
.option("port", portOption)
|
|
2586
|
+
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2587
|
+
a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
|
|
2588
|
+
.command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
|
|
2589
|
+
.command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
|
|
2590
|
+
.positional("name", { type: "string", describe: "ls/list lists all (the default)" })
|
|
2591
|
+
.option("print-uri", { type: "boolean", describe: "Same as `rech url <name>`" })
|
|
2592
|
+
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
|
|
2593
|
+
a => {
|
|
2594
|
+
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
|
|
2595
|
+
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`);
|
|
2597
|
+
})
|
|
2598
|
+
// 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" })
|
|
2602
|
+
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2603
|
+
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
|
|
2604
|
+
a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
|
|
2605
|
+
? handlers.urlList()
|
|
2606
|
+
: handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
|
|
2607
|
+
.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 #" })
|
|
2609
|
+
.example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
|
|
2610
|
+
a => handlers.connect(a.url))
|
|
2611
|
+
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
2612
|
+
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
2613
|
+
.command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
|
|
2559
2614
|
.positional("name", { type: "string", demandOption: true })
|
|
2560
2615
|
.option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2561
2616
|
.option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
|
|
2562
2617
|
.option("port", portOption)
|
|
2563
2618
|
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
|
|
2564
2619
|
a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
|
|
2565
|
-
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2566
|
-
a => handlers.removeListener(a.name))
|
|
2567
|
-
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
2568
|
-
a => handlers.listenerPort(a.name))
|
|
2569
2620
|
.command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
|
|
2570
2621
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2571
2622
|
a => handlers.allowListener(a.name, a.profiles))
|
|
2572
2623
|
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
2573
2624
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2574
2625
|
a => handlers.denyListener(a.name, a.profiles))
|
|
2575
|
-
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2576
|
-
a => handlers.rotateKey(a.name))
|
|
2577
2626
|
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
2578
2627
|
.positional("name", { type: "string", demandOption: true })
|
|
2579
2628
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
|
|
2580
2629
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
2581
2630
|
.check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
|
|
2582
2631
|
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
|
|
2632
|
+
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
2633
|
+
a => handlers.listenerPort(a.name))
|
|
2634
|
+
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2635
|
+
a => handlers.rotateKey(a.name))
|
|
2636
|
+
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2637
|
+
a => handlers.removeListener(a.name))
|
|
2583
2638
|
.demandCommand(1).strict())
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
.option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
|
|
2587
|
-
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2588
|
-
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
|
|
2589
|
-
a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
|
|
2590
|
-
? handlers.urlList()
|
|
2591
|
-
: handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
|
|
2592
|
-
.command("connect <url>", "Check a shared connection URL and save it for this project", y => y
|
|
2593
|
-
.positional("url", { type: "string", demandOption: true }),
|
|
2594
|
-
a => handlers.connect(a.url))
|
|
2595
|
-
.command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
|
|
2596
|
-
.positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
|
|
2597
|
-
.option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
|
|
2598
|
-
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
|
|
2599
|
-
a => {
|
|
2600
|
-
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
|
|
2601
|
-
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2602
|
-
throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
|
|
2603
|
-
})
|
|
2604
|
-
.command("setup", "Install the daemon and connect a Chrome profile", y => y
|
|
2605
|
-
.option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: email, name or folder" })
|
|
2606
|
-
.option("token", { type: "string", requiresArg: true, describe: "Extension token (default: read from the profile, or RECH_TOKEN)" })
|
|
2607
|
-
.option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2608
|
-
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix for a scoped listener, e.g. rechrome" })
|
|
2609
|
-
.option("port", portOption)
|
|
2610
|
-
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2611
|
-
a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
|
|
2612
|
-
.command("tray [action]", "Show or hide the tray icon", y => y
|
|
2639
|
+
// Daemon and extras
|
|
2640
|
+
.command("tray [action]", "Menu-bar icon for the daemon (starts after setup)", y => y
|
|
2613
2641
|
.positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
|
|
2614
2642
|
a => handlers.tray(a.action))
|
|
2615
|
-
.command("provision-profile <name>", "
|
|
2643
|
+
.command("provision-profile <name>", "(experimental) Clean Chrome-for-Testing profile, fully automated; not your real Chrome", y => y
|
|
2616
2644
|
.positional("name", { type: "string", demandOption: true })
|
|
2617
2645
|
.option("experimental", { type: "boolean", default: false })
|
|
2618
2646
|
.option("headed", { type: "boolean", default: false }),
|
|
2619
2647
|
a => handlers.provisionProfile(a.name, { headed: a.headed, experimental: a.experimental }))
|
|
2620
|
-
.command("uninstall", "Stop and remove the
|
|
2648
|
+
.command("uninstall", "Stop and remove the daemon", {}, () => handlers.uninstall())
|
|
2649
|
+
.command("serve", "Run the daemon in the foreground (normally managed by oxmgr)", {}, () => handlers.serve())
|
|
2621
2650
|
.demandCommand(1)
|
|
2622
2651
|
.strict()
|
|
2623
2652
|
.help()
|
|
2653
|
+
.alias("help", "h")
|
|
2624
2654
|
.version(false)
|
|
2625
|
-
|
|
2655
|
+
// A parse error (missing argument, unknown option…) shows that command's help above the
|
|
2656
|
+
// error, so the fix is visible. Errors thrown by a command's own handler pass through.
|
|
2657
|
+
.fail((message, error, y) => {
|
|
2658
|
+
if (error) throw error;
|
|
2659
|
+
// Straight to stderr: Bun's console.error would paint the whole help red.
|
|
2660
|
+
y.showHelp((help: string) => process.stderr.write(`${help}\n\n`));
|
|
2661
|
+
throw new Error(`rech: ${/^Not enough non-option arguments/.test(message) ? "missing a required argument; see the usage line above" : message}`);
|
|
2662
|
+
});
|
|
2626
2663
|
}
|
|
2627
2664
|
|
|
2628
2665
|
if (import.meta.main) {
|
|
2629
2666
|
let args = process.argv.slice(2);
|
|
2630
2667
|
const cmd = args[0]?.toLowerCase();
|
|
2631
2668
|
|
|
2669
|
+
const handlers: RechHandlers = {
|
|
2670
|
+
serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2671
|
+
status,
|
|
2672
|
+
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2673
|
+
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
2674
|
+
setup: async (opts) => {
|
|
2675
|
+
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2676
|
+
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
2677
|
+
await startTray({ quiet: true }).catch(() => {});
|
|
2678
|
+
},
|
|
2679
|
+
tray: trayCommand,
|
|
2680
|
+
provisionProfile: async (name, { headed, experimental }) => {
|
|
2681
|
+
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
2682
|
+
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
2683
|
+
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
2684
|
+
if (!experimental) throw new Error([
|
|
2685
|
+
`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
|
|
2686
|
+
`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
|
|
2687
|
+
`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
|
|
2688
|
+
`To proceed anyway, re-run with --experimental.`,
|
|
2689
|
+
].join("\n"));
|
|
2690
|
+
await provisionProfile(name, { headed });
|
|
2691
|
+
},
|
|
2692
|
+
uninstall: daemonUninstall,
|
|
2693
|
+
};
|
|
2694
|
+
|
|
2632
2695
|
if (cmd && RECH_COMMANDS.has(cmd)) {
|
|
2633
|
-
const handlers: RechHandlers = {
|
|
2634
|
-
serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2635
|
-
status,
|
|
2636
|
-
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2637
|
-
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
2638
|
-
setup: async (opts) => {
|
|
2639
|
-
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2640
|
-
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
2641
|
-
await startTray({ quiet: true }).catch(() => {});
|
|
2642
|
-
},
|
|
2643
|
-
tray: trayCommand,
|
|
2644
|
-
provisionProfile: async (name, { headed, experimental }) => {
|
|
2645
|
-
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
2646
|
-
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
2647
|
-
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
2648
|
-
if (!experimental) throw new Error([
|
|
2649
|
-
`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
|
|
2650
|
-
`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
|
|
2651
|
-
`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
|
|
2652
|
-
`To proceed anyway, re-run with --experimental.`,
|
|
2653
|
-
].join("\n"));
|
|
2654
|
-
await provisionProfile(name, { headed });
|
|
2655
|
-
},
|
|
2656
|
-
uninstall: daemonUninstall,
|
|
2657
|
-
};
|
|
2658
2696
|
try {
|
|
2659
2697
|
await rechCli([cmd, ...args.slice(1)], handlers).parseAsync();
|
|
2660
2698
|
} catch (error) {
|
|
@@ -2667,13 +2705,12 @@ if (import.meta.main) {
|
|
|
2667
2705
|
console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
|
|
2668
2706
|
envWatcher?.close();
|
|
2669
2707
|
} else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
|
|
2670
|
-
|
|
2671
|
-
envWatcher?.close();
|
|
2708
|
+
try { await rechCli(["--help"], handlers).parseAsync(); }
|
|
2709
|
+
finally { envWatcher?.close(); }
|
|
2672
2710
|
} else {
|
|
2673
2711
|
const url = process.env[ENV_KEY];
|
|
2674
2712
|
if (!url) {
|
|
2675
|
-
console.error(
|
|
2676
|
-
printHelp();
|
|
2713
|
+
console.error(notConnectedMessage());
|
|
2677
2714
|
process.exit(1);
|
|
2678
2715
|
}
|
|
2679
2716
|
// --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
|
|
@@ -2724,7 +2761,7 @@ if (import.meta.main) {
|
|
|
2724
2761
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
2725
2762
|
}
|
|
2726
2763
|
args = [...forwarded, ...args];
|
|
2727
|
-
await run(url, args, overrideEnv);
|
|
2764
|
+
await run(url, args, overrideEnv, { verbatim: separator !== -1 });
|
|
2728
2765
|
envWatcher?.close();
|
|
2729
2766
|
}
|
|
2730
2767
|
}
|
package/rechrome.ts
CHANGED
|
@@ -10,6 +10,7 @@ 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
16
|
import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
|
|
@@ -1041,7 +1042,49 @@ export function extractGlobalProfileArg(args: string[]): { args: string[]; selec
|
|
|
1041
1042
|
return { args: rest, selector };
|
|
1042
1043
|
}
|
|
1043
1044
|
|
|
1044
|
-
|
|
1045
|
+
function editDistance(a: string, b: string): number {
|
|
1046
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
1047
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1048
|
+
let diagonal = row[0];
|
|
1049
|
+
row[0] = i;
|
|
1050
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1051
|
+
const above = row[j];
|
|
1052
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1053
|
+
diagonal = above;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return row[b.length];
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** What to do when no RECHROME_URL is configured: set up here, or connect to another machine. */
|
|
1060
|
+
export function notConnectedMessage(): string {
|
|
1061
|
+
return [
|
|
1062
|
+
`rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
|
|
1063
|
+
` On the machine with Chrome: rech setup`,
|
|
1064
|
+
` On another machine: rech connect '<URL printed by \`rech url\` on that machine>'`,
|
|
1065
|
+
].join("\n");
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* When playwright-cli rejects a command, replace its own usage dump with a rech-branded hint.
|
|
1070
|
+
* Candidates are rech's commands plus the browser commands listed in that usage text, so the
|
|
1071
|
+
* suggestion stays current without a hardcoded list. Returns null for any other output.
|
|
1072
|
+
*/
|
|
1073
|
+
export function unknownCommandHint(output: string, rechCommands: Iterable<string> = RECH_COMMANDS): string | null {
|
|
1074
|
+
const unknown = output.match(/^Unknown command: (\S+)/m)?.[1];
|
|
1075
|
+
if (!unknown) return null;
|
|
1076
|
+
const browser = [...output.matchAll(/^ {2}([a-z][a-z0-9-]+) /gm)].map(m => m[1]);
|
|
1077
|
+
const candidates = [...new Set([...rechCommands, ...browser])];
|
|
1078
|
+
const scored = candidates.map(c => ({ c, d: editDistance(unknown.toLowerCase(), c) })).sort((x, y) => x.d - y.d);
|
|
1079
|
+
const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
|
|
1080
|
+
return [
|
|
1081
|
+
`rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
|
|
1082
|
+
` rech --help rechrome commands (setup, status, profile, url, connect, listener…)`,
|
|
1083
|
+
` rech pw --help browser commands (open, click, screenshot…)`,
|
|
1084
|
+
].join("\n");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
|
|
1045
1088
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
1046
1089
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
1047
1090
|
args = normalizeCommandArgs(args);
|
|
@@ -1060,11 +1103,17 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
1060
1103
|
|
|
1061
1104
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
1062
1105
|
if (existingSession && isOpenWithUrl) {
|
|
1063
|
-
return run(url, ["goto", ...args.slice(1)], overrideEnv);
|
|
1106
|
+
return run(url, ["goto", ...args.slice(1)], overrideEnv, opts);
|
|
1064
1107
|
}
|
|
1065
1108
|
|
|
1066
1109
|
if (existingSession)
|
|
1067
1110
|
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
1111
|
+
// A typo'd command: rech's hint instead of playwright-cli's full usage (kept for `rech pw`).
|
|
1112
|
+
const hint = !opts.verbatim && status !== 0 ? unknownCommandHint(`${stderr ?? ""}\n${stdout ?? ""}`) : null;
|
|
1113
|
+
if (hint) {
|
|
1114
|
+
console.error(hint);
|
|
1115
|
+
process.exit(status || 1);
|
|
1116
|
+
}
|
|
1068
1117
|
if (stderr) {
|
|
1069
1118
|
if (stderr.includes('Extension connection timeout')) {
|
|
1070
1119
|
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
@@ -1398,23 +1447,41 @@ async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
|
|
|
1398
1447
|
return await new Response(proc.stdout).text();
|
|
1399
1448
|
}
|
|
1400
1449
|
|
|
1450
|
+
// A candidate playwright-cli entry is usable only if the playwright-core it requires actually
|
|
1451
|
+
// resolves FROM that entry — the wrapper does `require('playwright-core/lib/tools/cli-client/program')`,
|
|
1452
|
+
// a deep subpath reachable only through the fork's patched `exports` map. existsSync on the .js is
|
|
1453
|
+
// not the same check: an uninitialised/half-built lib/playwright-cli submodule leaves the wrapper on
|
|
1454
|
+
// disk with no resolvable core, and the failure surfaces later as MODULE_NOT_FOUND inside the daemon.
|
|
1455
|
+
export function playwrightCliIsUsable(jsEntry: string): boolean {
|
|
1456
|
+
try {
|
|
1457
|
+
createRequire(jsEntry).resolve("playwright-core/lib/tools/cli-client/program");
|
|
1458
|
+
return true;
|
|
1459
|
+
} catch {
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1401
1464
|
// Resolve which playwright-cli the daemon runs to drive Chrome. Priority:
|
|
1402
1465
|
// 1. PLAYWRIGHT_CLI env override — explicit, already a full command string.
|
|
1403
1466
|
// 2. Vendored fork in a git checkout (lib/playwright-cli/playwright-cli.js) — the patched
|
|
1404
1467
|
// multi-tab CLI + patched playwright-core (PLAYWRIGHT_MCP_PROFILE_DIRECTORY etc.).
|
|
1405
1468
|
// 3. The fork bundled into the npm tarball (vendor/playwright-cli/playwright-cli.js, produced by
|
|
1406
|
-
// scripts/vendor-cli.sh at prepublish
|
|
1407
|
-
// `bun i -g rechrome`: self-contained, no
|
|
1469
|
+
// scripts/vendor-cli.sh at prepublish, and by `prepare` on `bun install` in a checkout). This
|
|
1470
|
+
// is the batteries-included default for `bun i -g rechrome`: self-contained, no
|
|
1471
|
+
// @playwright/cli dep, no browser-binary download.
|
|
1408
1472
|
// 4. Bare `playwright-cli-multi-tab` on PATH — legacy fallback for a pre-existing global link.
|
|
1473
|
+
// Candidates 2–3 must also pass playwrightCliIsUsable(), so a present-but-broken one falls through.
|
|
1474
|
+
// lib/ stays ahead of vendor/ on purpose: a dev who built the fork wants their patched core, not
|
|
1475
|
+
// the (possibly older) vendor-src snapshot that `prepare` unpacks into vendor/.
|
|
1409
1476
|
// A resolved .js entry is run through `node` on Windows (which can't exec a .js by shebang) and
|
|
1410
1477
|
// bare on POSIX (its `#!/usr/bin/env node` shebang runs it under node, which the relay handshake
|
|
1411
1478
|
// needs — see daemonInstall). serve splits the result on spaces into argv.
|
|
1412
|
-
export function resolvePlaywrightCli(): string {
|
|
1479
|
+
export function resolvePlaywrightCli(root: string = import.meta.dir): string {
|
|
1413
1480
|
if (process.env.PLAYWRIGHT_CLI) return process.env.PLAYWRIGHT_CLI;
|
|
1414
1481
|
const jsEntry = [
|
|
1415
|
-
join(
|
|
1416
|
-
join(
|
|
1417
|
-
].
|
|
1482
|
+
join(root, "lib/playwright-cli/playwright-cli.js"),
|
|
1483
|
+
join(root, "vendor/playwright-cli/playwright-cli.js"),
|
|
1484
|
+
].filter(existsSync).find(playwrightCliIsUsable);
|
|
1418
1485
|
if (jsEntry) return IS_WINDOWS ? `node ${jsEntry}` : jsEntry;
|
|
1419
1486
|
return "playwright-cli-multi-tab";
|
|
1420
1487
|
}
|
|
@@ -1866,8 +1933,17 @@ async function requireListeners() {
|
|
|
1866
1933
|
return config;
|
|
1867
1934
|
}
|
|
1868
1935
|
|
|
1936
|
+
/** Print rows as left-aligned columns; the first row is the header. */
|
|
1937
|
+
function printTable(rows: string[][]): void {
|
|
1938
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => (r[i] ?? "").length)));
|
|
1939
|
+
for (const r of rows) console.log(r.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd());
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1869
1942
|
async function listListeners(): Promise<void> {
|
|
1870
|
-
|
|
1943
|
+
const rows = [["NAME", "ADDRESS", "PROFILES", "PUBLIC URL"]];
|
|
1944
|
+
for (const l of (await requireListeners()).listeners)
|
|
1945
|
+
rows.push([l.name, `${listenerAddress(l)}${normalizePrefix(l.prefix)}`, l.profiles === "*" ? "(all — local management)" : l.profiles.join(", "), l.publicUrl ?? "-"]);
|
|
1946
|
+
printTable(rows);
|
|
1871
1947
|
}
|
|
1872
1948
|
|
|
1873
1949
|
async function removeListener(name: string): Promise<void> {
|
|
@@ -1967,8 +2043,7 @@ async function urlList(): Promise<void> {
|
|
|
1967
2043
|
const local = `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`;
|
|
1968
2044
|
for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
|
|
1969
2045
|
}
|
|
1970
|
-
|
|
1971
|
-
for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
2046
|
+
printTable(rows);
|
|
1972
2047
|
console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
|
|
1973
2048
|
}
|
|
1974
2049
|
|
|
@@ -2400,126 +2475,45 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2400
2475
|
async function status(): Promise<void> {
|
|
2401
2476
|
const url = process.env[ENV_KEY];
|
|
2402
2477
|
if (!url) {
|
|
2403
|
-
console.log(`serve: not configured
|
|
2478
|
+
console.log(`serve: not configured`);
|
|
2479
|
+
console.log(notConnectedMessage().split("\n").slice(1).join("\n"));
|
|
2404
2480
|
return;
|
|
2405
2481
|
}
|
|
2406
2482
|
const parsed = parseUrl(url);
|
|
2407
2483
|
const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
2408
|
-
//
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; listener?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2484
|
+
// The authenticated /ping reports which listener answered, its bind, and the profiles it allows.
|
|
2485
|
+
const pingResponse = ping
|
|
2486
|
+
? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
|
|
2487
|
+
: null;
|
|
2488
|
+
const pingBody = pingResponse?.ok
|
|
2489
|
+
? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2415
2490
|
: null;
|
|
2416
2491
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
2417
2492
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
2418
|
-
console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` :
|
|
2493
|
+
console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
|
|
2494
|
+
if (pingResponse?.status === 401)
|
|
2495
|
+
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech url <profile>\`), then \`rech connect '<url>'\``);
|
|
2419
2496
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
2420
2497
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
2421
2498
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
|
2422
2499
|
// printing a stack trace instead of the restart hint.
|
|
2423
2500
|
if (pingBody?.degraded)
|
|
2424
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}\``);
|
|
2425
|
-
|
|
2426
|
-
const
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
}
|
|
2438
|
-
} else if (parsed.profileDirectory) {
|
|
2439
|
-
// Legacy: no registry yet, show from RECHROME_URL
|
|
2440
|
-
const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
|
|
2441
|
-
console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
function printHelp(): void {
|
|
2446
|
-
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
2447
|
-
|
|
2448
|
-
Usage:
|
|
2449
|
-
rech [--profile <email|name|folder>] <playwright-args...>
|
|
2450
|
-
Run Playwright CLI command with the given registered
|
|
2451
|
-
Chrome profile. --profile selects the profile by exact
|
|
2452
|
-
registered email (e.g. you@gmail.com), exact Chrome
|
|
2453
|
-
profile name, or exact profile folder name. The profile
|
|
2454
|
-
must already be registered (see \`rech setup\`). Place
|
|
2455
|
-
--profile before the playwright subcommand. Requires
|
|
2456
|
-
${ENV_KEY}.
|
|
2457
|
-
rech setup [--listen <local|lan|tailscale|IP>] [--profile <email|name|folder>] [--token <tok>] [--prefix <path>] [--port <port>] [--yes]
|
|
2458
|
-
First-time setup: daemon + Chrome extension + config
|
|
2459
|
-
--prefix=rechrome mounts at /rechrome/ on a scoped listener.
|
|
2460
|
-
Prefixed setup defaults to the management port + 1; override with --port.
|
|
2461
|
-
Offers to install missing oxmgr globally (y/N).
|
|
2462
|
-
--yes approves installation without prompting.
|
|
2463
|
-
--profile selects the Chrome profile non-interactively.
|
|
2464
|
-
Menu numbers are not accepted. Resolution order is exact
|
|
2465
|
-
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
2466
|
-
then exact profile folder name (e.g. "Profile 1"). See
|
|
2467
|
-
available values with \`rech profile\`.
|
|
2468
|
-
--token (or RECH_TOKEN) supplies the auth token for
|
|
2469
|
-
non-TTY/agent runs, skipping the interactive paste
|
|
2470
|
-
rech provision-profile <name> --experimental [--headed]
|
|
2471
|
-
(experimental) Auto-provision a managed QA profile on
|
|
2472
|
-
Chrome for Testing — branded Chrome 149+ rejects
|
|
2473
|
-
--load-extension, so this is a clean browser, not your
|
|
2474
|
-
real Chrome. For your real Chrome, use \`rech setup\`
|
|
2475
|
-
rech status Show current configuration and serve health
|
|
2476
|
-
rech tray [show|hide|stop] Native menu-bar/tray icon for the serve daemon
|
|
2477
|
-
(show=start, hide/show toggle, stop=quit). Auto-
|
|
2478
|
-
starts after \`rech setup\`; skipped with no GUI
|
|
2479
|
-
rech uninstall Remove the serve daemon and clear config
|
|
2480
|
-
rech serve Start the serve server manually (foreground)
|
|
2481
|
-
rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
|
|
2482
|
-
rech listener allow|deny <name> <profile...>
|
|
2483
|
-
Add or remove profiles on an existing listener
|
|
2484
|
-
rech listener port [name] Print a listener's port, for a reverse-proxy command
|
|
2485
|
-
rech listener set <name> --public-url <url>
|
|
2486
|
-
Record where a proxy exposes the listener (rech url uses it)
|
|
2487
|
-
rech listener rotate-key <name>
|
|
2488
|
-
New key for a listener; URLs with the old key stop working
|
|
2489
|
-
rech profile [ls|list]
|
|
2490
|
-
List Chrome + managed test profiles and connection status
|
|
2491
|
-
rech url [profile] [--listener <name>] [--local] [--save]
|
|
2492
|
-
Print a connection URL (includes the secret key): the public
|
|
2493
|
-
URL when one is set, else the listener address. --save also
|
|
2494
|
-
writes it to this project's .rechrome/.env.local.
|
|
2495
|
-
\`rech profile [name] --print-uri\` is an alias.
|
|
2496
|
-
rech url ls List every listener × profile URL (keys hidden)
|
|
2497
|
-
rech connect <url> Check a shared URL answers, then save it for this project
|
|
2498
|
-
rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
|
|
2499
|
-
rech pw <playwright-args...> Forward verbatim to playwright-cli, even when a name clashes
|
|
2500
|
-
with rech's own (rech pw --version, rech pw status)
|
|
2501
|
-
rech --version rechrome's version
|
|
2502
|
-
rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
|
|
2503
|
-
fragile single-shot flow (OAuth/login) never shares tabs
|
|
2504
|
-
with the worktree's default session
|
|
2505
|
-
|
|
2506
|
-
Environment:
|
|
2507
|
-
${ENV_KEY} Server URL set by \`rech setup\`
|
|
2508
|
-
RECH_TOKEN Auth token for \`rech setup\` (same as --token)
|
|
2509
|
-
RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
|
|
2510
|
-
client reuses is keyed on the worktree root path; \`branch\` restores the
|
|
2511
|
-
old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
|
|
2512
|
-
RECH_SETUP_AGENT Setup hints: codex | claude | none (otherwise auto-detected)
|
|
2513
|
-
|
|
2514
|
-
Examples:
|
|
2515
|
-
rech setup
|
|
2516
|
-
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
2517
|
-
rech --profile you@gmail.com open https://example.com
|
|
2518
|
-
rech eval "() => document.title"
|
|
2519
|
-
rech open https://example.com
|
|
2520
|
-
rech screenshot`);
|
|
2502
|
+
// The daemon line is about this machine; a client of a remote host has no local daemon to report.
|
|
2503
|
+
const isHost = !!(await readListeners().catch(() => null));
|
|
2504
|
+
if (isHost) {
|
|
2505
|
+
const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
|
|
2506
|
+
console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
2507
|
+
}
|
|
2508
|
+
// Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
|
|
2509
|
+
const effective = resolveEffectiveProfile(parsed.profileDirectory);
|
|
2510
|
+
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
2511
|
+
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
2512
|
+
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)`);
|
|
2521
2514
|
}
|
|
2522
2515
|
|
|
2516
|
+
|
|
2523
2517
|
export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
|
|
2524
2518
|
export type RechHandlers = {
|
|
2525
2519
|
serve(): Promise<void> | void;
|
|
@@ -2547,114 +2541,158 @@ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners"
|
|
|
2547
2541
|
|
|
2548
2542
|
const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
|
|
2549
2543
|
|
|
2544
|
+
// yargs trims indentation in .usage(), so the indented browser-command block lives in the epilogue.
|
|
2545
|
+
const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from scripts, agents and other machines
|
|
2546
|
+
|
|
2547
|
+
Usage: rech <command> [options] · rech <browser-command> [args]`;
|
|
2548
|
+
|
|
2549
|
+
const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
2550
|
+
rech [--profile <p>] [--isolate] <browser-command> [args]
|
|
2551
|
+
open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
|
|
2552
|
+
--profile <p> as another registered profile (email, name or folder); put it first
|
|
2553
|
+
--isolate in a throwaway session, e.g. for a login flow
|
|
2554
|
+
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
2555
|
+
rech --version rechrome's version
|
|
2556
|
+
|
|
2557
|
+
Environment:
|
|
2558
|
+
${ENV_KEY} connection URL; read from the nearest .rechrome/.env.local or .env.local
|
|
2559
|
+
RECH_IDENTITY session key: worktree (default) | branch | cwd
|
|
2560
|
+
RECH_TOKEN extension token for \`rech setup\` (same as --token)
|
|
2561
|
+
RECH_SETUP_AGENT setup hints: codex | claude | none (auto-detected)
|
|
2562
|
+
|
|
2563
|
+
Examples:
|
|
2564
|
+
rech setup --profile you@example.com set up Chrome on this machine
|
|
2565
|
+
rech open https://example.com open a page in this project's session
|
|
2566
|
+
rech screenshot saved to <project>/.rechrome/output/
|
|
2567
|
+
rech url you@example.com --listener share URL to give another machine (secret)
|
|
2568
|
+
rech connect '<url>' use that URL in this project
|
|
2569
|
+
|
|
2570
|
+
Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
|
|
2571
|
+
|
|
2550
2572
|
export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
2551
2573
|
return yargs(argv)
|
|
2552
2574
|
.scriptName("rech")
|
|
2575
|
+
.usage(HELP_USAGE)
|
|
2576
|
+
.epilogue(HELP_EPILOGUE)
|
|
2577
|
+
.wrap(Math.min(110, process.stdout.columns || 110))
|
|
2553
2578
|
.parserConfiguration({ "parse-numbers": false, "parse-positional-numbers": false })
|
|
2554
|
-
|
|
2555
|
-
.command("
|
|
2556
|
-
|
|
2557
|
-
.
|
|
2558
|
-
.
|
|
2579
|
+
// Set up and inspect this machine
|
|
2580
|
+
.command("setup", "Set up this machine: daemon, Chrome extension, connection", y => y
|
|
2581
|
+
.option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: exact email, Chrome profile name, or folder (e.g. \"Profile 1\"); not menu numbers" })
|
|
2582
|
+
.option("token", { type: "string", requiresArg: true, describe: "Extension token, for headless runs (default: read from the profile, or RECH_TOKEN)" })
|
|
2583
|
+
.option("listen", { type: "string", requiresArg: true, describe: "Who can reach this profile: local (default) | lan | tailscale | <detected IP>" })
|
|
2584
|
+
.option("prefix", { type: "string", requiresArg: true, describe: "URL path for a proxied listener, e.g. rechrome (port defaults to the management port + 1)" })
|
|
2585
|
+
.option("port", portOption)
|
|
2586
|
+
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2587
|
+
a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
|
|
2588
|
+
.command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
|
|
2589
|
+
.command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
|
|
2590
|
+
.positional("name", { type: "string", describe: "ls/list lists all (the default)" })
|
|
2591
|
+
.option("print-uri", { type: "boolean", describe: "Same as `rech url <name>`" })
|
|
2592
|
+
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
|
|
2593
|
+
a => {
|
|
2594
|
+
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
|
|
2595
|
+
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`);
|
|
2597
|
+
})
|
|
2598
|
+
// 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" })
|
|
2602
|
+
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2603
|
+
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
|
|
2604
|
+
a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
|
|
2605
|
+
? handlers.urlList()
|
|
2606
|
+
: handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
|
|
2607
|
+
.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 #" })
|
|
2609
|
+
.example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", ""),
|
|
2610
|
+
a => handlers.connect(a.url))
|
|
2611
|
+
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
2612
|
+
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
2613
|
+
.command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
|
|
2559
2614
|
.positional("name", { type: "string", demandOption: true })
|
|
2560
2615
|
.option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2561
2616
|
.option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
|
|
2562
2617
|
.option("port", portOption)
|
|
2563
2618
|
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
|
|
2564
2619
|
a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
|
|
2565
|
-
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2566
|
-
a => handlers.removeListener(a.name))
|
|
2567
|
-
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
2568
|
-
a => handlers.listenerPort(a.name))
|
|
2569
2620
|
.command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
|
|
2570
2621
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2571
2622
|
a => handlers.allowListener(a.name, a.profiles))
|
|
2572
2623
|
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
2573
2624
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2574
2625
|
a => handlers.denyListener(a.name, a.profiles))
|
|
2575
|
-
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2576
|
-
a => handlers.rotateKey(a.name))
|
|
2577
2626
|
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
2578
2627
|
.positional("name", { type: "string", demandOption: true })
|
|
2579
2628
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.ts.net/rechrome/" })
|
|
2580
2629
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
2581
2630
|
.check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
|
|
2582
2631
|
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
|
|
2632
|
+
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
2633
|
+
a => handlers.listenerPort(a.name))
|
|
2634
|
+
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2635
|
+
a => handlers.rotateKey(a.name))
|
|
2636
|
+
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2637
|
+
a => handlers.removeListener(a.name))
|
|
2583
2638
|
.demandCommand(1).strict())
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
.option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
|
|
2587
|
-
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2588
|
-
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
|
|
2589
|
-
a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
|
|
2590
|
-
? handlers.urlList()
|
|
2591
|
-
: handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
|
|
2592
|
-
.command("connect <url>", "Check a shared connection URL and save it for this project", y => y
|
|
2593
|
-
.positional("url", { type: "string", demandOption: true }),
|
|
2594
|
-
a => handlers.connect(a.url))
|
|
2595
|
-
.command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
|
|
2596
|
-
.positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
|
|
2597
|
-
.option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
|
|
2598
|
-
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
|
|
2599
|
-
a => {
|
|
2600
|
-
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
|
|
2601
|
-
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2602
|
-
throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
|
|
2603
|
-
})
|
|
2604
|
-
.command("setup", "Install the daemon and connect a Chrome profile", y => y
|
|
2605
|
-
.option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: email, name or folder" })
|
|
2606
|
-
.option("token", { type: "string", requiresArg: true, describe: "Extension token (default: read from the profile, or RECH_TOKEN)" })
|
|
2607
|
-
.option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2608
|
-
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix for a scoped listener, e.g. rechrome" })
|
|
2609
|
-
.option("port", portOption)
|
|
2610
|
-
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2611
|
-
a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
|
|
2612
|
-
.command("tray [action]", "Show or hide the tray icon", y => y
|
|
2639
|
+
// Daemon and extras
|
|
2640
|
+
.command("tray [action]", "Menu-bar icon for the daemon (starts after setup)", y => y
|
|
2613
2641
|
.positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
|
|
2614
2642
|
a => handlers.tray(a.action))
|
|
2615
|
-
.command("provision-profile <name>", "
|
|
2643
|
+
.command("provision-profile <name>", "(experimental) Clean Chrome-for-Testing profile, fully automated; not your real Chrome", y => y
|
|
2616
2644
|
.positional("name", { type: "string", demandOption: true })
|
|
2617
2645
|
.option("experimental", { type: "boolean", default: false })
|
|
2618
2646
|
.option("headed", { type: "boolean", default: false }),
|
|
2619
2647
|
a => handlers.provisionProfile(a.name, { headed: a.headed, experimental: a.experimental }))
|
|
2620
|
-
.command("uninstall", "Stop and remove the
|
|
2648
|
+
.command("uninstall", "Stop and remove the daemon", {}, () => handlers.uninstall())
|
|
2649
|
+
.command("serve", "Run the daemon in the foreground (normally managed by oxmgr)", {}, () => handlers.serve())
|
|
2621
2650
|
.demandCommand(1)
|
|
2622
2651
|
.strict()
|
|
2623
2652
|
.help()
|
|
2653
|
+
.alias("help", "h")
|
|
2624
2654
|
.version(false)
|
|
2625
|
-
|
|
2655
|
+
// A parse error (missing argument, unknown option…) shows that command's help above the
|
|
2656
|
+
// error, so the fix is visible. Errors thrown by a command's own handler pass through.
|
|
2657
|
+
.fail((message, error, y) => {
|
|
2658
|
+
if (error) throw error;
|
|
2659
|
+
// Straight to stderr: Bun's console.error would paint the whole help red.
|
|
2660
|
+
y.showHelp((help: string) => process.stderr.write(`${help}\n\n`));
|
|
2661
|
+
throw new Error(`rech: ${/^Not enough non-option arguments/.test(message) ? "missing a required argument; see the usage line above" : message}`);
|
|
2662
|
+
});
|
|
2626
2663
|
}
|
|
2627
2664
|
|
|
2628
2665
|
if (import.meta.main) {
|
|
2629
2666
|
let args = process.argv.slice(2);
|
|
2630
2667
|
const cmd = args[0]?.toLowerCase();
|
|
2631
2668
|
|
|
2669
|
+
const handlers: RechHandlers = {
|
|
2670
|
+
serve: async () => { const { serve } = await import("./serve.ts"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2671
|
+
status,
|
|
2672
|
+
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2673
|
+
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
2674
|
+
setup: async (opts) => {
|
|
2675
|
+
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2676
|
+
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
2677
|
+
await startTray({ quiet: true }).catch(() => {});
|
|
2678
|
+
},
|
|
2679
|
+
tray: trayCommand,
|
|
2680
|
+
provisionProfile: async (name, { headed, experimental }) => {
|
|
2681
|
+
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
2682
|
+
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
2683
|
+
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
2684
|
+
if (!experimental) throw new Error([
|
|
2685
|
+
`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
|
|
2686
|
+
`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
|
|
2687
|
+
`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
|
|
2688
|
+
`To proceed anyway, re-run with --experimental.`,
|
|
2689
|
+
].join("\n"));
|
|
2690
|
+
await provisionProfile(name, { headed });
|
|
2691
|
+
},
|
|
2692
|
+
uninstall: daemonUninstall,
|
|
2693
|
+
};
|
|
2694
|
+
|
|
2632
2695
|
if (cmd && RECH_COMMANDS.has(cmd)) {
|
|
2633
|
-
const handlers: RechHandlers = {
|
|
2634
|
-
serve: async () => { const { serve } = await import("./serve.ts"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2635
|
-
status,
|
|
2636
|
-
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2637
|
-
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
2638
|
-
setup: async (opts) => {
|
|
2639
|
-
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2640
|
-
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
2641
|
-
await startTray({ quiet: true }).catch(() => {});
|
|
2642
|
-
},
|
|
2643
|
-
tray: trayCommand,
|
|
2644
|
-
provisionProfile: async (name, { headed, experimental }) => {
|
|
2645
|
-
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
2646
|
-
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
2647
|
-
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
2648
|
-
if (!experimental) throw new Error([
|
|
2649
|
-
`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
|
|
2650
|
-
`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
|
|
2651
|
-
`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
|
|
2652
|
-
`To proceed anyway, re-run with --experimental.`,
|
|
2653
|
-
].join("\n"));
|
|
2654
|
-
await provisionProfile(name, { headed });
|
|
2655
|
-
},
|
|
2656
|
-
uninstall: daemonUninstall,
|
|
2657
|
-
};
|
|
2658
2696
|
try {
|
|
2659
2697
|
await rechCli([cmd, ...args.slice(1)], handlers).parseAsync();
|
|
2660
2698
|
} catch (error) {
|
|
@@ -2667,13 +2705,12 @@ if (import.meta.main) {
|
|
|
2667
2705
|
console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
|
|
2668
2706
|
envWatcher?.close();
|
|
2669
2707
|
} else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
|
|
2670
|
-
|
|
2671
|
-
envWatcher?.close();
|
|
2708
|
+
try { await rechCli(["--help"], handlers).parseAsync(); }
|
|
2709
|
+
finally { envWatcher?.close(); }
|
|
2672
2710
|
} else {
|
|
2673
2711
|
const url = process.env[ENV_KEY];
|
|
2674
2712
|
if (!url) {
|
|
2675
|
-
console.error(
|
|
2676
|
-
printHelp();
|
|
2713
|
+
console.error(notConnectedMessage());
|
|
2677
2714
|
process.exit(1);
|
|
2678
2715
|
}
|
|
2679
2716
|
// --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
|
|
@@ -2724,7 +2761,7 @@ if (import.meta.main) {
|
|
|
2724
2761
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
2725
2762
|
}
|
|
2726
2763
|
args = [...forwarded, ...args];
|
|
2727
|
-
await run(url, args, overrideEnv);
|
|
2764
|
+
await run(url, args, overrideEnv, { verbatim: separator !== -1 });
|
|
2728
2765
|
envWatcher?.close();
|
|
2729
2766
|
}
|
|
2730
2767
|
}
|