rechrome 1.26.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -103
- package/listeners.js +57 -0
- package/listeners.ts +54 -1
- package/package.json +1 -1
- package/rechrome.js +368 -166
- package/rechrome.ts +368 -166
- package/serve.js +3 -1
- package/serve.ts +3 -1
package/rechrome.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, type Listener } from "./listeners.js";
|
|
2
|
+
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, type Listener } from "./listeners.js";
|
|
3
3
|
|
|
4
4
|
import { file } from "bun";
|
|
5
5
|
import yargs from "yargs";
|
|
@@ -395,8 +395,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
395
395
|
return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
/**
|
|
399
|
-
export function
|
|
398
|
+
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
399
|
+
export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
|
|
400
400
|
const local = new URL(localUrl);
|
|
401
401
|
const remote = new URL(routeUrl);
|
|
402
402
|
remote.username = parseUrl(localUrl).key;
|
|
@@ -897,16 +897,22 @@ export function profileConnectionUri(profile: string, configuredUrl: string | un
|
|
|
897
897
|
return result.toString();
|
|
898
898
|
}
|
|
899
899
|
|
|
900
|
-
async function printProfileUri(selector?: string, listener?: string): Promise<void> {
|
|
900
|
+
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
|
|
901
901
|
const url = process.env[ENV_KEY];
|
|
902
902
|
selector ??= url ? parseUrl(url).profileDirectory : undefined;
|
|
903
|
-
if (!selector) throw new Error("Specify a profile: rech
|
|
903
|
+
if (!selector) throw new Error("Specify a profile: rech url <profile>");
|
|
904
904
|
const registry = await readTokenRegistry();
|
|
905
905
|
const cache = await readChromeProfileCache();
|
|
906
906
|
// A configured remote profile may not exist in this machine's local registry.
|
|
907
907
|
const profile = url && parseUrl(url).profileDirectory === selector && !listener
|
|
908
908
|
? selector : (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
909
|
-
|
|
909
|
+
const listeners = (await readListeners())?.listeners ?? [];
|
|
910
|
+
let uri = profileConnectionUri(profile, url, listeners, listener);
|
|
911
|
+
// Prefer where a proxy exposes the listener, when it has been recorded.
|
|
912
|
+
const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
|
|
913
|
+
if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
|
|
914
|
+
console.log(uri);
|
|
915
|
+
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
910
916
|
}
|
|
911
917
|
|
|
912
918
|
export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
|
|
@@ -984,6 +990,31 @@ export function normalizeCommandArgs(args: string[]): string[] {
|
|
|
984
990
|
// --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
|
|
985
991
|
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
986
992
|
// missing value; accepts multiple occurrences (last one wins).
|
|
993
|
+
/**
|
|
994
|
+
* `rech [--profile X] [--isolate] pw <args>` forwards <args> to playwright-cli verbatim, so
|
|
995
|
+
* `rech pw --version` or `rech pw status` reach playwright instead of rech. `--` works the
|
|
996
|
+
* same after a rech flag (`rech --profile X -- status`); a bare leading `--` cannot, because
|
|
997
|
+
* Bun consumes the `--` right after the script path. The separator counts only when
|
|
998
|
+
* everything before it is a rech global flag; otherwise it belongs to the playwright command
|
|
999
|
+
* (`rech open -- x`). Returns its index, or -1.
|
|
1000
|
+
*/
|
|
1001
|
+
export function rechSeparatorIndex(args: string[]): number {
|
|
1002
|
+
for (let i = 0; i < args.length; i++) {
|
|
1003
|
+
const a = args[i];
|
|
1004
|
+
if (a === "--" || a === "pw" || a === "playwright") return i;
|
|
1005
|
+
if (a === "--profile") { i++; continue; }
|
|
1006
|
+
if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated") continue;
|
|
1007
|
+
return -1;
|
|
1008
|
+
}
|
|
1009
|
+
return -1;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** rechrome's own version, from the package.json shipped next to this file. */
|
|
1013
|
+
export function rechromeVersion(): string {
|
|
1014
|
+
try { return JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")).version ?? "unknown"; }
|
|
1015
|
+
catch { return "unknown"; }
|
|
1016
|
+
}
|
|
1017
|
+
|
|
987
1018
|
export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
|
|
988
1019
|
const rest = [...args];
|
|
989
1020
|
let selector: string | undefined;
|
|
@@ -1010,7 +1041,49 @@ export function extractGlobalProfileArg(args: string[]): { args: string[]; selec
|
|
|
1010
1041
|
return { args: rest, selector };
|
|
1011
1042
|
}
|
|
1012
1043
|
|
|
1013
|
-
|
|
1044
|
+
function editDistance(a: string, b: string): number {
|
|
1045
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
1046
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1047
|
+
let diagonal = row[0];
|
|
1048
|
+
row[0] = i;
|
|
1049
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1050
|
+
const above = row[j];
|
|
1051
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1052
|
+
diagonal = above;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return row[b.length];
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** What to do when no RECHROME_URL is configured: set up here, or connect to another machine. */
|
|
1059
|
+
export function notConnectedMessage(): string {
|
|
1060
|
+
return [
|
|
1061
|
+
`rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
|
|
1062
|
+
` On the machine with Chrome: rech setup`,
|
|
1063
|
+
` On another machine: rech connect '<URL printed by \`rech url\` on that machine>'`,
|
|
1064
|
+
].join("\n");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* When playwright-cli rejects a command, replace its own usage dump with a rech-branded hint.
|
|
1069
|
+
* Candidates are rech's commands plus the browser commands listed in that usage text, so the
|
|
1070
|
+
* suggestion stays current without a hardcoded list. Returns null for any other output.
|
|
1071
|
+
*/
|
|
1072
|
+
export function unknownCommandHint(output: string, rechCommands: Iterable<string> = RECH_COMMANDS): string | null {
|
|
1073
|
+
const unknown = output.match(/^Unknown command: (\S+)/m)?.[1];
|
|
1074
|
+
if (!unknown) return null;
|
|
1075
|
+
const browser = [...output.matchAll(/^ {2}([a-z][a-z0-9-]+) /gm)].map(m => m[1]);
|
|
1076
|
+
const candidates = [...new Set([...rechCommands, ...browser])];
|
|
1077
|
+
const scored = candidates.map(c => ({ c, d: editDistance(unknown.toLowerCase(), c) })).sort((x, y) => x.d - y.d);
|
|
1078
|
+
const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
|
|
1079
|
+
return [
|
|
1080
|
+
`rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
|
|
1081
|
+
` rech --help rechrome commands (setup, status, profile, url, connect, listener…)`,
|
|
1082
|
+
` rech pw --help browser commands (open, click, screenshot…)`,
|
|
1083
|
+
].join("\n");
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
|
|
1014
1087
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
1015
1088
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
1016
1089
|
args = normalizeCommandArgs(args);
|
|
@@ -1029,11 +1102,17 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
1029
1102
|
|
|
1030
1103
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
1031
1104
|
if (existingSession && isOpenWithUrl) {
|
|
1032
|
-
return run(url, ["goto", ...args.slice(1)], overrideEnv);
|
|
1105
|
+
return run(url, ["goto", ...args.slice(1)], overrideEnv, opts);
|
|
1033
1106
|
}
|
|
1034
1107
|
|
|
1035
1108
|
if (existingSession)
|
|
1036
1109
|
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
1110
|
+
// A typo'd command: rech's hint instead of playwright-cli's full usage (kept for `rech pw`).
|
|
1111
|
+
const hint = !opts.verbatim && status !== 0 ? unknownCommandHint(`${stderr ?? ""}\n${stdout ?? ""}`) : null;
|
|
1112
|
+
if (hint) {
|
|
1113
|
+
console.error(hint);
|
|
1114
|
+
process.exit(status || 1);
|
|
1115
|
+
}
|
|
1037
1116
|
if (stderr) {
|
|
1038
1117
|
if (stderr.includes('Extension connection timeout')) {
|
|
1039
1118
|
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
@@ -1835,8 +1914,17 @@ async function requireListeners() {
|
|
|
1835
1914
|
return config;
|
|
1836
1915
|
}
|
|
1837
1916
|
|
|
1917
|
+
/** Print rows as left-aligned columns; the first row is the header. */
|
|
1918
|
+
function printTable(rows: string[][]): void {
|
|
1919
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => (r[i] ?? "").length)));
|
|
1920
|
+
for (const r of rows) console.log(r.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd());
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1838
1923
|
async function listListeners(): Promise<void> {
|
|
1839
|
-
|
|
1924
|
+
const rows = [["NAME", "ADDRESS", "PROFILES", "PUBLIC URL"]];
|
|
1925
|
+
for (const l of (await requireListeners()).listeners)
|
|
1926
|
+
rows.push([l.name, `${listenerAddress(l)}${normalizePrefix(l.prefix)}`, l.profiles === "*" ? "(all — local management)" : l.profiles.join(", "), l.publicUrl ?? "-"]);
|
|
1927
|
+
printTable(rows);
|
|
1840
1928
|
}
|
|
1841
1929
|
|
|
1842
1930
|
async function removeListener(name: string): Promise<void> {
|
|
@@ -1849,17 +1937,122 @@ async function removeListener(name: string): Promise<void> {
|
|
|
1849
1937
|
console.log("Listener removed from configuration; daemon reloads automatically.");
|
|
1850
1938
|
}
|
|
1851
1939
|
|
|
1852
|
-
async function
|
|
1853
|
-
const config = await requireListeners();
|
|
1940
|
+
async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
|
|
1854
1941
|
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
1855
1942
|
const profiles: string[] = [];
|
|
1856
|
-
for (const selector of
|
|
1943
|
+
for (const selector of selectors) profiles.push((await resolveGlobalProfile(registry, cache, selector)).email);
|
|
1944
|
+
return [...new Set(profiles)];
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** Next steps after exposing a listener: proxy it, record where, share. Plain text, so any shell works. */
|
|
1948
|
+
export function listenerNextSteps(listener: Listener, profile?: string): string[] {
|
|
1949
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
1950
|
+
const mount = prefix === "/" ? "" : prefix.slice(0, -1);
|
|
1951
|
+
return [
|
|
1952
|
+
`Expose it through any reverse proxy on port ${listener.port}${mount ? ` (mount ${mount}; stripping it is fine)` : ""}, e.g.:`,
|
|
1953
|
+
` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
|
|
1954
|
+
`Then record where it is reachable and print the URL to share:`,
|
|
1955
|
+
` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
|
|
1956
|
+
` rech url ${profile ? JSON.stringify(profile) : "<profile>"} --listener ${listener.name}`,
|
|
1957
|
+
];
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async function addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void> {
|
|
1961
|
+
const config = await requireListeners();
|
|
1962
|
+
const profiles = await resolveProfileKeys(opts.profile);
|
|
1857
1963
|
const host = chooseListenAddress(await detectListenChoices(), opts.listen);
|
|
1858
1964
|
const port = opts.port ?? DEFAULT_PORT;
|
|
1859
|
-
if (config.listeners.some(l => l.name === name)) throw new Error(
|
|
1860
|
-
|
|
1965
|
+
if (config.listeners.some(l => l.name === name)) throw new Error(`Listener "${name}" already exists; change its profiles with rech listener allow|deny ${name} <profile>`);
|
|
1966
|
+
const listener: Listener = { name, host, port, prefix: normalizePrefix(opts.prefix), key: randomBytes(24).toString("base64url"), profiles };
|
|
1967
|
+
config.listeners.push(listener);
|
|
1861
1968
|
await writeListeners(config);
|
|
1862
1969
|
console.log(`Listener saved for ${host}:${port}; daemon reloads automatically. Credentials are in ~/.rechrome/listeners.json (keep private).`);
|
|
1970
|
+
if (isLoopback(host)) for (const line of listenerNextSteps(listener, profiles[0])) console.log(line);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function findListener(config: { listeners: Listener[] }, name?: string): Listener {
|
|
1974
|
+
if (name) {
|
|
1975
|
+
const listener = config.listeners.find(l => l.name === name);
|
|
1976
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1977
|
+
return listener;
|
|
1978
|
+
}
|
|
1979
|
+
const scoped = config.listeners.filter(l => l.profiles !== "*");
|
|
1980
|
+
if (scoped.length !== 1) throw new Error(`Name a listener: ${config.listeners.map(l => l.name).join(", ")}`);
|
|
1981
|
+
return scoped[0];
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
async function listenerPort(name?: string): Promise<void> {
|
|
1985
|
+
console.log(findListener(await requireListeners(), name).port);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
async function allowListener(name: string, selectors: string[]): Promise<void> {
|
|
1989
|
+
const config = await requireListeners();
|
|
1990
|
+
const added = allowProfiles(config, name, await resolveProfileKeys(selectors));
|
|
1991
|
+
await writeListeners(config);
|
|
1992
|
+
console.log(added.length ? `Allowed on ${name}: ${added.join(", ")}` : `Already allowed on ${name}; nothing changed.`);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
async function denyListener(name: string, selectors: string[]): Promise<void> {
|
|
1996
|
+
const config = await requireListeners();
|
|
1997
|
+
// Accept raw registry keys too, so a profile that no longer resolves can still be removed.
|
|
1998
|
+
const keys = [...selectors, ...await resolveProfileKeys(selectors).catch(() => [] as string[])];
|
|
1999
|
+
const removed = denyProfiles(config, name, keys);
|
|
2000
|
+
await writeListeners(config);
|
|
2001
|
+
console.log(removed.length ? `Removed from ${name}: ${removed.join(", ")}` : `Not on ${name}; nothing changed.`);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
async function rotateKey(name: string): Promise<void> {
|
|
2005
|
+
const config = await requireListeners();
|
|
2006
|
+
rotateListenerKey(config, name);
|
|
2007
|
+
await writeListeners(config);
|
|
2008
|
+
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech url <profile> --listener ${name}`);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
2012
|
+
const config = await requireListeners();
|
|
2013
|
+
const listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
|
|
2014
|
+
await writeListeners(config);
|
|
2015
|
+
console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
2019
|
+
|
|
2020
|
+
async function urlList(): Promise<void> {
|
|
2021
|
+
const config = await requireListeners();
|
|
2022
|
+
const rows = [["LISTENER", "PROFILE", "LOCAL", "PUBLIC"]];
|
|
2023
|
+
for (const l of config.listeners) {
|
|
2024
|
+
const local = `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`;
|
|
2025
|
+
for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
|
|
2026
|
+
}
|
|
2027
|
+
printTable(rows);
|
|
2028
|
+
console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
|
|
2032
|
+
async function saveProjectUrl(url: string): Promise<string> {
|
|
2033
|
+
const { dataDir } = await getClientIdentity();
|
|
2034
|
+
mkdirSync(dataDir, { recursive: true });
|
|
2035
|
+
const gitignore = join(dataDir, ".gitignore");
|
|
2036
|
+
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
2037
|
+
const envPath = join(dataDir, ".env.local");
|
|
2038
|
+
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
2039
|
+
await writeFile(envPath, [...lines, `${ENV_KEY}=${url}`, ""].join("\n"), { mode: 0o600 });
|
|
2040
|
+
await chmod(envPath, 0o600);
|
|
2041
|
+
return envPath;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
async function connect(url: string): Promise<void> {
|
|
2045
|
+
const parsed = parseUrl(url);
|
|
2046
|
+
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech url <profile>");
|
|
2047
|
+
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
2048
|
+
.catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
|
|
2049
|
+
if (response.status === 401) throw new Error("The daemon rejected this key; ask the host for a fresh URL (keys change on rech listener rotate-key).");
|
|
2050
|
+
if (!response.ok) throw new Error(`${serviceUrl(url, "ping")} answered ${response.status}; is the proxy pointing at the listener?`);
|
|
2051
|
+
const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
|
|
2052
|
+
if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
|
|
2053
|
+
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
2054
|
+
const saved = await saveProjectUrl(url);
|
|
2055
|
+
console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
|
|
1863
2056
|
}
|
|
1864
2057
|
|
|
1865
2058
|
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
|
@@ -2179,10 +2372,10 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2179
2372
|
const serve = await detectTailscaleServe(exposure.port, prefix);
|
|
2180
2373
|
if (serve.routeUrl) {
|
|
2181
2374
|
console.log(` Tailscale Serve route found: ${serve.routeUrl} → ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
|
|
2182
|
-
console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${
|
|
2375
|
+
console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${rebaseConnectionUrl(serve.routeUrl, rechUrl.toString())}`);
|
|
2183
2376
|
} else {
|
|
2184
2377
|
console.log(` Tailscale Serve command (run separately): tailscale serve --bg --https=443 --set-path=${prefix.slice(0, -1)} ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
|
|
2185
|
-
if (serve.dnsName) console.log(` Then remote clients can use:\n ${
|
|
2378
|
+
if (serve.dnsName) console.log(` Then remote clients can use:\n ${rebaseConnectionUrl(`https://${serve.dnsName}${prefix}`, rechUrl.toString())}`);
|
|
2186
2379
|
else console.log(` For remote clients, use https://<your-machine-tailnet-name>${prefix} with this listener's bearer key and profile query.`);
|
|
2187
2380
|
}
|
|
2188
2381
|
console.log(` The URL saved below is the local loopback one.`);
|
|
@@ -2263,113 +2456,45 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2263
2456
|
async function status(): Promise<void> {
|
|
2264
2457
|
const url = process.env[ENV_KEY];
|
|
2265
2458
|
if (!url) {
|
|
2266
|
-
console.log(`serve: not configured
|
|
2459
|
+
console.log(`serve: not configured`);
|
|
2460
|
+
console.log(notConnectedMessage().split("\n").slice(1).join("\n"));
|
|
2267
2461
|
return;
|
|
2268
2462
|
}
|
|
2269
|
-
const { host, port, protocol } = parseUrl(url);
|
|
2270
2463
|
const parsed = parseUrl(url);
|
|
2271
2464
|
const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
2272
|
-
//
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2465
|
+
// The authenticated /ping reports which listener answered, its bind, and the profiles it allows.
|
|
2466
|
+
const pingResponse = ping
|
|
2467
|
+
? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
|
|
2468
|
+
: null;
|
|
2469
|
+
const pingBody = pingResponse?.ok
|
|
2470
|
+
? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2279
2471
|
: null;
|
|
2280
|
-
|
|
2281
|
-
const
|
|
2282
|
-
console.log(`serve: ${ping ? `running ${
|
|
2472
|
+
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
2473
|
+
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
2474
|
+
console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
|
|
2475
|
+
if (pingResponse?.status === 401)
|
|
2476
|
+
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech url <profile>\`), then \`rech connect '<url>'\``);
|
|
2283
2477
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
2284
2478
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
2285
2479
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
|
2286
2480
|
// printing a stack trace instead of the restart hint.
|
|
2287
2481
|
if (pingBody?.degraded)
|
|
2288
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}\``);
|
|
2289
|
-
|
|
2290
|
-
const
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
}
|
|
2302
|
-
} else if (parsed.profileDirectory) {
|
|
2303
|
-
// Legacy: no registry yet, show from RECHROME_URL
|
|
2304
|
-
const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
|
|
2305
|
-
console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
|
|
2306
|
-
}
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
function printHelp(): void {
|
|
2310
|
-
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
2311
|
-
|
|
2312
|
-
Usage:
|
|
2313
|
-
rech [--profile <email|name|folder>] <playwright-args...>
|
|
2314
|
-
Run Playwright CLI command with the given registered
|
|
2315
|
-
Chrome profile. --profile selects the profile by exact
|
|
2316
|
-
registered email (e.g. you@gmail.com), exact Chrome
|
|
2317
|
-
profile name, or exact profile folder name. The profile
|
|
2318
|
-
must already be registered (see \`rech setup\`). Place
|
|
2319
|
-
--profile before the playwright subcommand. Requires
|
|
2320
|
-
${ENV_KEY}.
|
|
2321
|
-
rech setup [--listen <local|lan|tailscale|IP>] [--profile <email|name|folder>] [--token <tok>] [--prefix <path>] [--port <port>] [--yes]
|
|
2322
|
-
First-time setup: daemon + Chrome extension + config
|
|
2323
|
-
--prefix=rechrome mounts at /rechrome/ on a scoped listener.
|
|
2324
|
-
Prefixed setup defaults to the management port + 1; override with --port.
|
|
2325
|
-
Offers to install missing oxmgr globally (y/N).
|
|
2326
|
-
--yes approves installation without prompting.
|
|
2327
|
-
--profile selects the Chrome profile non-interactively.
|
|
2328
|
-
Menu numbers are not accepted. Resolution order is exact
|
|
2329
|
-
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
2330
|
-
then exact profile folder name (e.g. "Profile 1"). See
|
|
2331
|
-
available values with \`rech profile\`.
|
|
2332
|
-
--token (or RECH_TOKEN) supplies the auth token for
|
|
2333
|
-
non-TTY/agent runs, skipping the interactive paste
|
|
2334
|
-
rech provision-profile <name> --experimental [--headed]
|
|
2335
|
-
(experimental) Auto-provision a managed QA profile on
|
|
2336
|
-
Chrome for Testing — branded Chrome 149+ rejects
|
|
2337
|
-
--load-extension, so this is a clean browser, not your
|
|
2338
|
-
real Chrome. For your real Chrome, use \`rech setup\`
|
|
2339
|
-
rech status Show current configuration and serve health
|
|
2340
|
-
rech tray [show|hide|stop] Native menu-bar/tray icon for the serve daemon
|
|
2341
|
-
(show=start, hide/show toggle, stop=quit). Auto-
|
|
2342
|
-
starts after \`rech setup\`; skipped with no GUI
|
|
2343
|
-
rech uninstall Remove the serve daemon and clear config
|
|
2344
|
-
rech serve Start the serve server manually (foreground)
|
|
2345
|
-
rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
|
|
2346
|
-
rech profile [ls|list]
|
|
2347
|
-
List Chrome + managed test profiles and connection status
|
|
2348
|
-
rech profile [name] --print-uri [--listener <name>]
|
|
2349
|
-
Print a connection URI (includes the daemon key).
|
|
2350
|
-
Uses RECHROME_URL, or an explicitly selected local listener.
|
|
2351
|
-
rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
|
|
2352
|
-
rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
|
|
2353
|
-
fragile single-shot flow (OAuth/login) never shares tabs
|
|
2354
|
-
with the worktree's default session
|
|
2355
|
-
|
|
2356
|
-
Environment:
|
|
2357
|
-
${ENV_KEY} Server URL set by \`rech setup\`
|
|
2358
|
-
RECH_TOKEN Auth token for \`rech setup\` (same as --token)
|
|
2359
|
-
RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
|
|
2360
|
-
client reuses is keyed on the worktree root path; \`branch\` restores the
|
|
2361
|
-
old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
|
|
2362
|
-
RECH_SETUP_AGENT Setup hints: codex | claude | none (otherwise auto-detected)
|
|
2363
|
-
|
|
2364
|
-
Examples:
|
|
2365
|
-
rech setup
|
|
2366
|
-
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
2367
|
-
rech --profile you@gmail.com open https://example.com
|
|
2368
|
-
rech eval "() => document.title"
|
|
2369
|
-
rech open https://example.com
|
|
2370
|
-
rech screenshot`);
|
|
2483
|
+
// The daemon line is about this machine; a client of a remote host has no local daemon to report.
|
|
2484
|
+
const isHost = !!(await readListeners().catch(() => null));
|
|
2485
|
+
if (isHost) {
|
|
2486
|
+
const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
|
|
2487
|
+
console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
2488
|
+
}
|
|
2489
|
+
// Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
|
|
2490
|
+
const effective = resolveEffectiveProfile(parsed.profileDirectory);
|
|
2491
|
+
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
2492
|
+
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
2493
|
+
console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
|
|
2494
|
+
if (isHost) console.log(`\nMore: rech profile (profiles) · rech url ls (who can connect, and where)`);
|
|
2371
2495
|
}
|
|
2372
2496
|
|
|
2497
|
+
|
|
2373
2498
|
export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
|
|
2374
2499
|
export type RechHandlers = {
|
|
2375
2500
|
serve(): Promise<void> | void;
|
|
@@ -2378,7 +2503,14 @@ export type RechHandlers = {
|
|
|
2378
2503
|
addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
|
|
2379
2504
|
removeListener(name: string): Promise<void>;
|
|
2380
2505
|
listProfiles(): Promise<void>;
|
|
2381
|
-
printProfileUri(selector?: string, listener?: string): Promise<void>;
|
|
2506
|
+
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean }): Promise<void>;
|
|
2507
|
+
urlList(): Promise<void>;
|
|
2508
|
+
connect(url: string): Promise<void>;
|
|
2509
|
+
listenerPort(name?: string): Promise<void>;
|
|
2510
|
+
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
2511
|
+
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
2512
|
+
rotateKey(name: string): Promise<void>;
|
|
2513
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
|
|
2382
2514
|
setup(opts: SetupOptions): Promise<void>;
|
|
2383
2515
|
tray(action?: string): Promise<void>;
|
|
2384
2516
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -2386,54 +2518,115 @@ export type RechHandlers = {
|
|
|
2386
2518
|
};
|
|
2387
2519
|
|
|
2388
2520
|
/** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
|
|
2389
|
-
export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "setup", "tray", "provision-profile", "uninstall"]);
|
|
2521
|
+
export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "url", "urls", "connect", "setup", "tray", "provision-profile", "uninstall"]);
|
|
2390
2522
|
|
|
2391
2523
|
const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
|
|
2392
2524
|
|
|
2525
|
+
// yargs trims indentation in .usage(), so the indented browser-command block lives in the epilogue.
|
|
2526
|
+
const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from scripts, agents and other machines
|
|
2527
|
+
|
|
2528
|
+
Usage: rech <command> [options] · rech <browser-command> [args]`;
|
|
2529
|
+
|
|
2530
|
+
const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
2531
|
+
rech [--profile <p>] [--isolate] <browser-command> [args]
|
|
2532
|
+
open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
|
|
2533
|
+
--profile <p> as another registered profile (email, name or folder); put it first
|
|
2534
|
+
--isolate in a throwaway session, e.g. for a login flow
|
|
2535
|
+
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
2536
|
+
rech --version rechrome's version
|
|
2537
|
+
|
|
2538
|
+
Environment:
|
|
2539
|
+
${ENV_KEY} connection URL; read from the nearest .rechrome/.env.local or .env.local
|
|
2540
|
+
RECH_IDENTITY session key: worktree (default) | branch | cwd
|
|
2541
|
+
RECH_TOKEN extension token for \`rech setup\` (same as --token)
|
|
2542
|
+
RECH_SETUP_AGENT setup hints: codex | claude | none (auto-detected)
|
|
2543
|
+
|
|
2544
|
+
Examples:
|
|
2545
|
+
rech setup --profile you@example.com set up Chrome on this machine
|
|
2546
|
+
rech open https://example.com open a page in this project's session
|
|
2547
|
+
rech screenshot saved to <project>/.rechrome/output/
|
|
2548
|
+
rech url you@example.com --listener share URL to give another machine (secret)
|
|
2549
|
+
rech connect '<url>' use that URL in this project
|
|
2550
|
+
|
|
2551
|
+
Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
|
|
2552
|
+
|
|
2393
2553
|
export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
2394
2554
|
return yargs(argv)
|
|
2395
2555
|
.scriptName("rech")
|
|
2556
|
+
.usage(HELP_USAGE)
|
|
2557
|
+
.epilogue(HELP_EPILOGUE)
|
|
2558
|
+
.wrap(Math.min(110, process.stdout.columns || 110))
|
|
2396
2559
|
.parserConfiguration({ "parse-numbers": false, "parse-positional-numbers": false })
|
|
2397
|
-
|
|
2398
|
-
.command("
|
|
2399
|
-
|
|
2400
|
-
.
|
|
2401
|
-
.
|
|
2560
|
+
// Set up and inspect this machine
|
|
2561
|
+
.command("setup", "Set up this machine: daemon, Chrome extension, connection", y => y
|
|
2562
|
+
.option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: exact email, Chrome profile name, or folder (e.g. \"Profile 1\"); not menu numbers" })
|
|
2563
|
+
.option("token", { type: "string", requiresArg: true, describe: "Extension token, for headless runs (default: read from the profile, or RECH_TOKEN)" })
|
|
2564
|
+
.option("listen", { type: "string", requiresArg: true, describe: "Who can reach this profile: local (default) | lan | tailscale | <detected IP>" })
|
|
2565
|
+
.option("prefix", { type: "string", requiresArg: true, describe: "URL path for a proxied listener, e.g. rechrome (port defaults to the management port + 1)" })
|
|
2566
|
+
.option("port", portOption)
|
|
2567
|
+
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2568
|
+
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 }))
|
|
2569
|
+
.command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
|
|
2570
|
+
.command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
|
|
2571
|
+
.positional("name", { type: "string", describe: "ls/list lists all (the default)" })
|
|
2572
|
+
.option("print-uri", { type: "boolean", describe: "Same as `rech url <name>`" })
|
|
2573
|
+
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
|
|
2574
|
+
a => {
|
|
2575
|
+
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
|
|
2576
|
+
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2577
|
+
throw new Error(`To print "${a.name}"'s connection URL: rech url ${JSON.stringify(a.name)}. To list profiles: rech profile`);
|
|
2578
|
+
})
|
|
2579
|
+
// Share with and connect from other machines
|
|
2580
|
+
.command(["url [profile]", "urls [profile]"], "Print a connection URL to share (contains a secret key); `url ls` lists all", y => y
|
|
2581
|
+
.positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists every listener's URLs" })
|
|
2582
|
+
.option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
|
|
2583
|
+
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2584
|
+
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
|
|
2585
|
+
a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
|
|
2586
|
+
? handlers.urlList()
|
|
2587
|
+
: handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
|
|
2588
|
+
.command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
|
|
2589
|
+
.positional("url", { type: "string", demandOption: true }),
|
|
2590
|
+
a => handlers.connect(a.url))
|
|
2591
|
+
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
2592
|
+
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
2593
|
+
.command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
|
|
2402
2594
|
.positional("name", { type: "string", demandOption: true })
|
|
2403
2595
|
.option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2404
2596
|
.option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
|
|
2405
2597
|
.option("port", portOption)
|
|
2406
2598
|
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
|
|
2407
2599
|
a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
|
|
2600
|
+
.command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
|
|
2601
|
+
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2602
|
+
a => handlers.allowListener(a.name, a.profiles))
|
|
2603
|
+
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
2604
|
+
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2605
|
+
a => handlers.denyListener(a.name, a.profiles))
|
|
2606
|
+
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
2607
|
+
.positional("name", { type: "string", demandOption: true })
|
|
2608
|
+
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
|
|
2609
|
+
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
2610
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
|
|
2611
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
|
|
2612
|
+
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
2613
|
+
a => handlers.listenerPort(a.name))
|
|
2614
|
+
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2615
|
+
a => handlers.rotateKey(a.name))
|
|
2408
2616
|
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2409
2617
|
a => handlers.removeListener(a.name))
|
|
2410
2618
|
.demandCommand(1).strict())
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
.option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
|
|
2414
|
-
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
|
|
2415
|
-
a => {
|
|
2416
|
-
if (a.printUri) return handlers.printProfileUri(a.name, a.listener);
|
|
2417
|
-
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2418
|
-
throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
|
|
2419
|
-
})
|
|
2420
|
-
.command("setup", "Install the daemon and connect a Chrome profile", y => y
|
|
2421
|
-
.option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: email, name or folder" })
|
|
2422
|
-
.option("token", { type: "string", requiresArg: true, describe: "Extension token (default: read from the profile, or RECH_TOKEN)" })
|
|
2423
|
-
.option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2424
|
-
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix for a scoped listener, e.g. rechrome" })
|
|
2425
|
-
.option("port", portOption)
|
|
2426
|
-
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2427
|
-
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 }))
|
|
2428
|
-
.command("tray [action]", "Show or hide the tray icon", y => y
|
|
2619
|
+
// Daemon and extras
|
|
2620
|
+
.command("tray [action]", "Menu-bar icon for the daemon (starts after setup)", y => y
|
|
2429
2621
|
.positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
|
|
2430
2622
|
a => handlers.tray(a.action))
|
|
2431
|
-
.command("provision-profile <name>", "
|
|
2623
|
+
.command("provision-profile <name>", "(experimental) Clean Chrome-for-Testing profile, fully automated; not your real Chrome", y => y
|
|
2432
2624
|
.positional("name", { type: "string", demandOption: true })
|
|
2433
2625
|
.option("experimental", { type: "boolean", default: false })
|
|
2434
2626
|
.option("headed", { type: "boolean", default: false }),
|
|
2435
2627
|
a => handlers.provisionProfile(a.name, { headed: a.headed, experimental: a.experimental }))
|
|
2436
|
-
.command("uninstall", "Stop and remove the
|
|
2628
|
+
.command("uninstall", "Stop and remove the daemon", {}, () => handlers.uninstall())
|
|
2629
|
+
.command("serve", "Run the daemon in the foreground (normally managed by oxmgr)", {}, () => handlers.serve())
|
|
2437
2630
|
.demandCommand(1)
|
|
2438
2631
|
.strict()
|
|
2439
2632
|
.help()
|
|
@@ -2445,31 +2638,33 @@ if (import.meta.main) {
|
|
|
2445
2638
|
let args = process.argv.slice(2);
|
|
2446
2639
|
const cmd = args[0]?.toLowerCase();
|
|
2447
2640
|
|
|
2641
|
+
const handlers: RechHandlers = {
|
|
2642
|
+
serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2643
|
+
status,
|
|
2644
|
+
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2645
|
+
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
2646
|
+
setup: async (opts) => {
|
|
2647
|
+
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2648
|
+
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
2649
|
+
await startTray({ quiet: true }).catch(() => {});
|
|
2650
|
+
},
|
|
2651
|
+
tray: trayCommand,
|
|
2652
|
+
provisionProfile: async (name, { headed, experimental }) => {
|
|
2653
|
+
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
2654
|
+
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
2655
|
+
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
2656
|
+
if (!experimental) throw new Error([
|
|
2657
|
+
`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
|
|
2658
|
+
`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
|
|
2659
|
+
`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
|
|
2660
|
+
`To proceed anyway, re-run with --experimental.`,
|
|
2661
|
+
].join("\n"));
|
|
2662
|
+
await provisionProfile(name, { headed });
|
|
2663
|
+
},
|
|
2664
|
+
uninstall: daemonUninstall,
|
|
2665
|
+
};
|
|
2666
|
+
|
|
2448
2667
|
if (cmd && RECH_COMMANDS.has(cmd)) {
|
|
2449
|
-
const handlers: RechHandlers = {
|
|
2450
|
-
serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2451
|
-
status,
|
|
2452
|
-
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2453
|
-
setup: async (opts) => {
|
|
2454
|
-
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2455
|
-
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
2456
|
-
await startTray({ quiet: true }).catch(() => {});
|
|
2457
|
-
},
|
|
2458
|
-
tray: trayCommand,
|
|
2459
|
-
provisionProfile: async (name, { headed, experimental }) => {
|
|
2460
|
-
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
2461
|
-
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
2462
|
-
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
2463
|
-
if (!experimental) throw new Error([
|
|
2464
|
-
`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
|
|
2465
|
-
`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
|
|
2466
|
-
`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
|
|
2467
|
-
`To proceed anyway, re-run with --experimental.`,
|
|
2468
|
-
].join("\n"));
|
|
2469
|
-
await provisionProfile(name, { headed });
|
|
2470
|
-
},
|
|
2471
|
-
uninstall: daemonUninstall,
|
|
2472
|
-
};
|
|
2473
2668
|
try {
|
|
2474
2669
|
await rechCli([cmd, ...args.slice(1)], handlers).parseAsync();
|
|
2475
2670
|
} catch (error) {
|
|
@@ -2478,20 +2673,26 @@ if (import.meta.main) {
|
|
|
2478
2673
|
} finally {
|
|
2479
2674
|
if (cmd !== "serve") envWatcher?.close();
|
|
2480
2675
|
}
|
|
2481
|
-
} else if (cmd === "
|
|
2482
|
-
|
|
2676
|
+
} else if (cmd === "--version") {
|
|
2677
|
+
console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
|
|
2483
2678
|
envWatcher?.close();
|
|
2679
|
+
} else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
|
|
2680
|
+
try { await rechCli(["--help"], handlers).parseAsync(); }
|
|
2681
|
+
finally { envWatcher?.close(); }
|
|
2484
2682
|
} else {
|
|
2485
2683
|
const url = process.env[ENV_KEY];
|
|
2486
2684
|
if (!url) {
|
|
2487
|
-
console.error(
|
|
2488
|
-
printHelp();
|
|
2685
|
+
console.error(notConnectedMessage());
|
|
2489
2686
|
process.exit(1);
|
|
2490
2687
|
}
|
|
2491
2688
|
// --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
|
|
2492
2689
|
// the leading-flags-only rule that protects the forwarded CLI's own --profile).
|
|
2493
2690
|
let profileSelector: string | undefined;
|
|
2494
2691
|
let overrideEnv: Record<string, string> | undefined;
|
|
2692
|
+
// Everything after rech's `pw` (or `--`) goes to playwright-cli untouched; only the flags before it are rech's.
|
|
2693
|
+
const separator = rechSeparatorIndex(args);
|
|
2694
|
+
const forwarded = separator === -1 ? [] : args.slice(separator + 1);
|
|
2695
|
+
if (separator !== -1) args = args.slice(0, separator);
|
|
2495
2696
|
try {
|
|
2496
2697
|
const extracted = extractGlobalProfileArg(args);
|
|
2497
2698
|
profileSelector = extracted.selector;
|
|
@@ -2531,7 +2732,8 @@ if (import.meta.main) {
|
|
|
2531
2732
|
args.splice(isolateIdx, 1);
|
|
2532
2733
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
2533
2734
|
}
|
|
2534
|
-
|
|
2735
|
+
args = [...forwarded, ...args];
|
|
2736
|
+
await run(url, args, overrideEnv, { verbatim: separator !== -1 });
|
|
2535
2737
|
envWatcher?.close();
|
|
2536
2738
|
}
|
|
2537
2739
|
}
|