rechrome 1.25.0 → 1.27.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 +271 -40
- package/rechrome.ts +271 -40
- package/serve.js +9 -2
- package/serve.ts +9 -2
package/rechrome.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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";
|
|
6
6
|
import { readExtensionTokenFromProfile } from "./extension-token.js";
|
|
7
7
|
import { randomBytes } from "crypto";
|
|
8
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
|
|
8
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
|
|
9
9
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
10
10
|
import { isIPv4 } from "net";
|
|
11
11
|
import { join, basename, dirname } from "path";
|
|
@@ -16,14 +16,16 @@ import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./da
|
|
|
16
16
|
|
|
17
17
|
export const ENV_KEY = "RECHROME_URL";
|
|
18
18
|
export const DEFAULT_PORT = 13775;
|
|
19
|
-
export const RECH_DIR = join(import.meta.dir, ".rech");
|
|
20
|
-
export const LOG_DIR = join(RECH_DIR, "logs");
|
|
21
|
-
|
|
22
19
|
// Home dir: HOME on POSIX, USERPROFILE on Windows (handled by os.homedir()).
|
|
23
20
|
export const HOME = homedir();
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
// All rechrome state (registry, listeners, extension, logs, daemon output) lives in
|
|
23
|
+
// ~/.rechrome, never next to the code: a bunx/npx install dir is a disposable cache.
|
|
24
|
+
export const RECH_DIR = join(HOME, ".rechrome");
|
|
25
|
+
export const LOG_DIR = join(RECH_DIR, "logs");
|
|
26
|
+
// Before the rename, logs and daemon output lived in <install dir>/.rech.
|
|
27
|
+
export const LEGACY_RECH_DIR = join(import.meta.dir, ".rech");
|
|
28
|
+
const TOKENS_FILE = join(RECH_DIR, "profiles.yaml");
|
|
27
29
|
|
|
28
30
|
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
|
|
29
31
|
|
|
@@ -38,7 +40,7 @@ function validateTokenRegistry(value: unknown): Record<string, TokenEntry> {
|
|
|
38
40
|
return value as Record<string, TokenEntry>;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory =
|
|
43
|
+
export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_DIR): Promise<void> {
|
|
42
44
|
validateTokenRegistry(registry);
|
|
43
45
|
await mkdir(directory, { recursive: true });
|
|
44
46
|
const path = join(directory, "profiles.yaml");
|
|
@@ -51,7 +53,7 @@ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, d
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
export async function readTokenRegistry(directory =
|
|
56
|
+
export async function readTokenRegistry(directory = RECH_DIR): Promise<Record<string, TokenEntry>> {
|
|
55
57
|
const path = join(directory, "profiles.yaml");
|
|
56
58
|
let raw: string;
|
|
57
59
|
try { raw = await readFile(path, "utf8"); }
|
|
@@ -74,7 +76,7 @@ export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Reco
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
|
|
77
|
-
mkdirSync(
|
|
79
|
+
mkdirSync(RECH_DIR, { recursive: true });
|
|
78
80
|
const registry = await readTokenRegistry();
|
|
79
81
|
registry[profileEmail] = entry;
|
|
80
82
|
await writeTokenRegistry(registry);
|
|
@@ -241,6 +243,31 @@ export function log(msg: string) {
|
|
|
241
243
|
appendFileSync(logFile, line);
|
|
242
244
|
}
|
|
243
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Move logs and daemon output from the legacy <install dir>/.rech into RECH_DIR, entry by
|
|
248
|
+
* entry. An entry already present in RECH_DIR is kept and the legacy copy left in place,
|
|
249
|
+
* so nothing is overwritten. Best effort: a cross-device rename just leaves the old copy.
|
|
250
|
+
*/
|
|
251
|
+
export function migrateLegacyDataDir(legacy = LEGACY_RECH_DIR, target = RECH_DIR): string[] {
|
|
252
|
+
const moved: string[] = [];
|
|
253
|
+
if (!existsSync(legacy)) return moved;
|
|
254
|
+
mkdirSync(target, { recursive: true });
|
|
255
|
+
if (realpathSync(legacy) === realpathSync(target)) return moved;
|
|
256
|
+
for (const sub of ["logs", "output"]) {
|
|
257
|
+
const from = join(legacy, sub);
|
|
258
|
+
if (!existsSync(from)) continue;
|
|
259
|
+
mkdirSync(join(target, sub), { recursive: true });
|
|
260
|
+
for (const entry of readdirSync(from)) {
|
|
261
|
+
const to = join(target, sub, entry);
|
|
262
|
+
if (existsSync(to)) continue;
|
|
263
|
+
try { renameSync(join(from, entry), to); moved.push(`${sub}/${entry}`); } catch { /* keep the legacy copy */ }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Remove only directories left empty (the unused tls/ included); rmdir refuses non-empty ones.
|
|
267
|
+
for (const dir of ["logs", "output", "tls", ""]) { try { rmdirSync(join(legacy, dir)); } catch { /* not empty */ } }
|
|
268
|
+
return moved;
|
|
269
|
+
}
|
|
270
|
+
|
|
244
271
|
export function parseUrl(raw: string) {
|
|
245
272
|
const u = new URL(raw);
|
|
246
273
|
const fragment = new URLSearchParams(u.hash.slice(1).replace(/^\?/, ""));
|
|
@@ -368,8 +395,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
368
395
|
return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
|
|
369
396
|
}
|
|
370
397
|
|
|
371
|
-
/**
|
|
372
|
-
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 {
|
|
373
400
|
const local = new URL(localUrl);
|
|
374
401
|
const remote = new URL(routeUrl);
|
|
375
402
|
remote.username = parseUrl(localUrl).key;
|
|
@@ -482,7 +509,15 @@ export function deriveIdentity(opts: {
|
|
|
482
509
|
return { key, label };
|
|
483
510
|
}
|
|
484
511
|
|
|
485
|
-
|
|
512
|
+
/**
|
|
513
|
+
* A project's own rechrome folder: <root>/.rechrome, where root is the same one the session
|
|
514
|
+
* key uses (the worktree root, submodules rolled up), or cwd in `cwd` mode / outside git.
|
|
515
|
+
*/
|
|
516
|
+
export function projectDataDir(opts: { mode: string; cwd: string; root?: string | null }): string {
|
|
517
|
+
return join(opts.mode === "cwd" ? opts.cwd : opts.root || opts.cwd, ".rechrome");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function getClientIdentity(): Promise<{ key: string; label: string; dataDir: string; profile?: string }> {
|
|
486
521
|
const cwd = realpathSafe(process.cwd());
|
|
487
522
|
const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
|
|
488
523
|
let root: string | null = null;
|
|
@@ -508,7 +543,7 @@ async function getClientIdentity(): Promise<{ key: string; label: string; profil
|
|
|
508
543
|
if (remoteUrl) remote = normalizeRemote(remoteUrl);
|
|
509
544
|
}
|
|
510
545
|
|
|
511
|
-
return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
|
|
546
|
+
return { ...deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch }), dataDir: projectDataDir({ mode, cwd, root }) };
|
|
512
547
|
}
|
|
513
548
|
|
|
514
549
|
// Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
|
|
@@ -862,16 +897,22 @@ export function profileConnectionUri(profile: string, configuredUrl: string | un
|
|
|
862
897
|
return result.toString();
|
|
863
898
|
}
|
|
864
899
|
|
|
865
|
-
async function printProfileUri(selector?: string, listener?: string): Promise<void> {
|
|
900
|
+
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
|
|
866
901
|
const url = process.env[ENV_KEY];
|
|
867
902
|
selector ??= url ? parseUrl(url).profileDirectory : undefined;
|
|
868
|
-
if (!selector) throw new Error("Specify a profile: rech
|
|
903
|
+
if (!selector) throw new Error("Specify a profile: rech url <profile>");
|
|
869
904
|
const registry = await readTokenRegistry();
|
|
870
905
|
const cache = await readChromeProfileCache();
|
|
871
906
|
// A configured remote profile may not exist in this machine's local registry.
|
|
872
907
|
const profile = url && parseUrl(url).profileDirectory === selector && !listener
|
|
873
908
|
? selector : (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
874
|
-
|
|
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)}`);
|
|
875
916
|
}
|
|
876
917
|
|
|
877
918
|
export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
|
|
@@ -902,7 +943,8 @@ async function callServe(
|
|
|
902
943
|
const res = await fetch(serviceUrl(url, "run"), {
|
|
903
944
|
method: "POST",
|
|
904
945
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
905
|
-
|
|
946
|
+
// dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
|
|
947
|
+
body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env }),
|
|
906
948
|
signal: AbortSignal.timeout(70_000),
|
|
907
949
|
}).catch(async (e) => {
|
|
908
950
|
if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
|
|
@@ -948,6 +990,31 @@ export function normalizeCommandArgs(args: string[]): string[] {
|
|
|
948
990
|
// --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
|
|
949
991
|
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
950
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
|
+
|
|
951
1018
|
export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
|
|
952
1019
|
const rest = [...args];
|
|
953
1020
|
let selector: string | undefined;
|
|
@@ -1018,9 +1085,11 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
1018
1085
|
if (stdout) process.stdout.write(stdout);
|
|
1019
1086
|
|
|
1020
1087
|
if (files?.length) {
|
|
1021
|
-
|
|
1088
|
+
// Saved files belong to the project: <project>/.rechrome/output. The folder also holds
|
|
1089
|
+
// .env.local (secrets), so it is git-ignored as a whole when first created.
|
|
1090
|
+
const dlDir = join(identity.dataDir, "output");
|
|
1022
1091
|
mkdirSync(dlDir, { recursive: true });
|
|
1023
|
-
const gitignorePath = join(
|
|
1092
|
+
const gitignorePath = join(identity.dataDir, ".gitignore");
|
|
1024
1093
|
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
1025
1094
|
for (const name of files) {
|
|
1026
1095
|
const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
|
|
@@ -1467,7 +1536,7 @@ async function daemonUninstall(): Promise<void> {
|
|
|
1467
1536
|
// locate the binary and launch it detached (singleton via a pidfile).
|
|
1468
1537
|
// `rech tray hide` / the menu "Hide" item both kill the process;
|
|
1469
1538
|
// `rech tray show` starts a fresh one.
|
|
1470
|
-
const TRAY_PID_FILE = join(
|
|
1539
|
+
const TRAY_PID_FILE = join(RECH_DIR, "tray.pid");
|
|
1471
1540
|
|
|
1472
1541
|
// A desktop GUI must be present. Linux needs an X11/Wayland display; a headless
|
|
1473
1542
|
// box (SSH, CI, container) has neither, so the tray is skipped. macOS/Windows
|
|
@@ -1729,7 +1798,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1729
1798
|
process.exit(1);
|
|
1730
1799
|
}
|
|
1731
1800
|
const dist = await ensureExtensionDistInstalled();
|
|
1732
|
-
const userDataDir = join(
|
|
1801
|
+
const userDataDir = join(RECH_DIR, "profiles", name);
|
|
1733
1802
|
const token = randomBytes(32).toString("base64url");
|
|
1734
1803
|
|
|
1735
1804
|
console.log(`\n[1/3] Provisioning managed profile "${name}"`);
|
|
@@ -1811,17 +1880,123 @@ async function removeListener(name: string): Promise<void> {
|
|
|
1811
1880
|
console.log("Listener removed from configuration; daemon reloads automatically.");
|
|
1812
1881
|
}
|
|
1813
1882
|
|
|
1814
|
-
async function
|
|
1815
|
-
const config = await requireListeners();
|
|
1883
|
+
async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
|
|
1816
1884
|
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
1817
1885
|
const profiles: string[] = [];
|
|
1818
|
-
for (const selector of
|
|
1886
|
+
for (const selector of selectors) profiles.push((await resolveGlobalProfile(registry, cache, selector)).email);
|
|
1887
|
+
return [...new Set(profiles)];
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/** Next steps after exposing a listener: proxy it, record where, share. Plain text, so any shell works. */
|
|
1891
|
+
export function listenerNextSteps(listener: Listener, profile?: string): string[] {
|
|
1892
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
1893
|
+
const mount = prefix === "/" ? "" : prefix.slice(0, -1);
|
|
1894
|
+
return [
|
|
1895
|
+
`Expose it through any reverse proxy on port ${listener.port}${mount ? ` (mount ${mount}; stripping it is fine)` : ""}, e.g.:`,
|
|
1896
|
+
` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
|
|
1897
|
+
`Then record where it is reachable and print the URL to share:`,
|
|
1898
|
+
` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
|
|
1899
|
+
` rech url ${profile ? JSON.stringify(profile) : "<profile>"} --listener ${listener.name}`,
|
|
1900
|
+
];
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
async function addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void> {
|
|
1904
|
+
const config = await requireListeners();
|
|
1905
|
+
const profiles = await resolveProfileKeys(opts.profile);
|
|
1819
1906
|
const host = chooseListenAddress(await detectListenChoices(), opts.listen);
|
|
1820
1907
|
const port = opts.port ?? DEFAULT_PORT;
|
|
1821
|
-
if (config.listeners.some(l => l.name === name)) throw new Error(
|
|
1822
|
-
|
|
1908
|
+
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>`);
|
|
1909
|
+
const listener: Listener = { name, host, port, prefix: normalizePrefix(opts.prefix), key: randomBytes(24).toString("base64url"), profiles };
|
|
1910
|
+
config.listeners.push(listener);
|
|
1823
1911
|
await writeListeners(config);
|
|
1824
1912
|
console.log(`Listener saved for ${host}:${port}; daemon reloads automatically. Credentials are in ~/.rechrome/listeners.json (keep private).`);
|
|
1913
|
+
if (isLoopback(host)) for (const line of listenerNextSteps(listener, profiles[0])) console.log(line);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function findListener(config: { listeners: Listener[] }, name?: string): Listener {
|
|
1917
|
+
if (name) {
|
|
1918
|
+
const listener = config.listeners.find(l => l.name === name);
|
|
1919
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1920
|
+
return listener;
|
|
1921
|
+
}
|
|
1922
|
+
const scoped = config.listeners.filter(l => l.profiles !== "*");
|
|
1923
|
+
if (scoped.length !== 1) throw new Error(`Name a listener: ${config.listeners.map(l => l.name).join(", ")}`);
|
|
1924
|
+
return scoped[0];
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
async function listenerPort(name?: string): Promise<void> {
|
|
1928
|
+
console.log(findListener(await requireListeners(), name).port);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async function allowListener(name: string, selectors: string[]): Promise<void> {
|
|
1932
|
+
const config = await requireListeners();
|
|
1933
|
+
const added = allowProfiles(config, name, await resolveProfileKeys(selectors));
|
|
1934
|
+
await writeListeners(config);
|
|
1935
|
+
console.log(added.length ? `Allowed on ${name}: ${added.join(", ")}` : `Already allowed on ${name}; nothing changed.`);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
async function denyListener(name: string, selectors: string[]): Promise<void> {
|
|
1939
|
+
const config = await requireListeners();
|
|
1940
|
+
// Accept raw registry keys too, so a profile that no longer resolves can still be removed.
|
|
1941
|
+
const keys = [...selectors, ...await resolveProfileKeys(selectors).catch(() => [] as string[])];
|
|
1942
|
+
const removed = denyProfiles(config, name, keys);
|
|
1943
|
+
await writeListeners(config);
|
|
1944
|
+
console.log(removed.length ? `Removed from ${name}: ${removed.join(", ")}` : `Not on ${name}; nothing changed.`);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
async function rotateKey(name: string): Promise<void> {
|
|
1948
|
+
const config = await requireListeners();
|
|
1949
|
+
rotateListenerKey(config, name);
|
|
1950
|
+
await writeListeners(config);
|
|
1951
|
+
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech url <profile> --listener ${name}`);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
1955
|
+
const config = await requireListeners();
|
|
1956
|
+
const listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
|
|
1957
|
+
await writeListeners(config);
|
|
1958
|
+
console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
1962
|
+
|
|
1963
|
+
async function urlList(): Promise<void> {
|
|
1964
|
+
const config = await requireListeners();
|
|
1965
|
+
const rows = [["LISTENER", "PROFILE", "LOCAL", "PUBLIC"]];
|
|
1966
|
+
for (const l of config.listeners) {
|
|
1967
|
+
const local = `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`;
|
|
1968
|
+
for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
|
|
1969
|
+
}
|
|
1970
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => r[i].length)));
|
|
1971
|
+
for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
1972
|
+
console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
|
|
1976
|
+
async function saveProjectUrl(url: string): Promise<string> {
|
|
1977
|
+
const { dataDir } = await getClientIdentity();
|
|
1978
|
+
mkdirSync(dataDir, { recursive: true });
|
|
1979
|
+
const gitignore = join(dataDir, ".gitignore");
|
|
1980
|
+
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
1981
|
+
const envPath = join(dataDir, ".env.local");
|
|
1982
|
+
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
1983
|
+
await writeFile(envPath, [...lines, `${ENV_KEY}=${url}`, ""].join("\n"), { mode: 0o600 });
|
|
1984
|
+
await chmod(envPath, 0o600);
|
|
1985
|
+
return envPath;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
async function connect(url: string): Promise<void> {
|
|
1989
|
+
const parsed = parseUrl(url);
|
|
1990
|
+
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech url <profile>");
|
|
1991
|
+
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
1992
|
+
.catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
|
|
1993
|
+
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).");
|
|
1994
|
+
if (!response.ok) throw new Error(`${serviceUrl(url, "ping")} answered ${response.status}; is the proxy pointing at the listener?`);
|
|
1995
|
+
const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
|
|
1996
|
+
if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
|
|
1997
|
+
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
1998
|
+
const saved = await saveProjectUrl(url);
|
|
1999
|
+
console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
|
|
1825
2000
|
}
|
|
1826
2001
|
|
|
1827
2002
|
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
|
@@ -2141,10 +2316,10 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2141
2316
|
const serve = await detectTailscaleServe(exposure.port, prefix);
|
|
2142
2317
|
if (serve.routeUrl) {
|
|
2143
2318
|
console.log(` Tailscale Serve route found: ${serve.routeUrl} → ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
|
|
2144
|
-
console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${
|
|
2319
|
+
console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${rebaseConnectionUrl(serve.routeUrl, rechUrl.toString())}`);
|
|
2145
2320
|
} else {
|
|
2146
2321
|
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)}`);
|
|
2147
|
-
if (serve.dnsName) console.log(` Then remote clients can use:\n ${
|
|
2322
|
+
if (serve.dnsName) console.log(` Then remote clients can use:\n ${rebaseConnectionUrl(`https://${serve.dnsName}${prefix}`, rechUrl.toString())}`);
|
|
2148
2323
|
else console.log(` For remote clients, use https://<your-machine-tailnet-name>${prefix} with this listener's bearer key and profile query.`);
|
|
2149
2324
|
}
|
|
2150
2325
|
console.log(` The URL saved below is the local loopback one.`);
|
|
@@ -2228,7 +2403,6 @@ async function status(): Promise<void> {
|
|
|
2228
2403
|
console.log(`serve: not configured (run \`rech setup\`)`);
|
|
2229
2404
|
return;
|
|
2230
2405
|
}
|
|
2231
|
-
const { host, port, protocol } = parseUrl(url);
|
|
2232
2406
|
const parsed = parseUrl(url);
|
|
2233
2407
|
const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
2234
2408
|
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
@@ -2237,11 +2411,11 @@ async function status(): Promise<void> {
|
|
|
2237
2411
|
? await fetch(serviceUrl(url, "ping"), {
|
|
2238
2412
|
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
2239
2413
|
signal: AbortSignal.timeout(2000),
|
|
2240
|
-
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2414
|
+
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; listener?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
2241
2415
|
: null;
|
|
2242
|
-
|
|
2243
|
-
const
|
|
2244
|
-
console.log(`serve: ${ping ? `running ${
|
|
2416
|
+
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
2417
|
+
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})` : ""}` : "not running"}`);
|
|
2245
2419
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
2246
2420
|
// ReferenceError that took down the whole of `rech status`, so the one command
|
|
2247
2421
|
// that reports "the relay is wedged" died exactly when the relay was wedged,
|
|
@@ -2305,12 +2479,26 @@ Usage:
|
|
|
2305
2479
|
rech uninstall Remove the serve daemon and clear config
|
|
2306
2480
|
rech serve Start the serve server manually (foreground)
|
|
2307
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
|
|
2308
2489
|
rech profile [ls|list]
|
|
2309
2490
|
List Chrome + managed test profiles and connection status
|
|
2310
|
-
rech
|
|
2311
|
-
Print a connection
|
|
2312
|
-
|
|
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
|
|
2313
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
|
|
2314
2502
|
rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
|
|
2315
2503
|
fragile single-shot flow (OAuth/login) never shares tabs
|
|
2316
2504
|
with the worktree's default session
|
|
@@ -2340,7 +2528,14 @@ export type RechHandlers = {
|
|
|
2340
2528
|
addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
|
|
2341
2529
|
removeListener(name: string): Promise<void>;
|
|
2342
2530
|
listProfiles(): Promise<void>;
|
|
2343
|
-
printProfileUri(selector?: string, listener?: string): Promise<void>;
|
|
2531
|
+
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean }): Promise<void>;
|
|
2532
|
+
urlList(): Promise<void>;
|
|
2533
|
+
connect(url: string): Promise<void>;
|
|
2534
|
+
listenerPort(name?: string): Promise<void>;
|
|
2535
|
+
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
2536
|
+
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
2537
|
+
rotateKey(name: string): Promise<void>;
|
|
2538
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
|
|
2344
2539
|
setup(opts: SetupOptions): Promise<void>;
|
|
2345
2540
|
tray(action?: string): Promise<void>;
|
|
2346
2541
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -2348,7 +2543,7 @@ export type RechHandlers = {
|
|
|
2348
2543
|
};
|
|
2349
2544
|
|
|
2350
2545
|
/** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
|
|
2351
|
-
export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "setup", "tray", "provision-profile", "uninstall"]);
|
|
2546
|
+
export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "url", "urls", "connect", "setup", "tray", "provision-profile", "uninstall"]);
|
|
2352
2547
|
|
|
2353
2548
|
const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
|
|
2354
2549
|
|
|
@@ -2369,13 +2564,40 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
2369
2564
|
a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
|
|
2370
2565
|
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2371
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
|
+
.command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
|
|
2570
|
+
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2571
|
+
a => handlers.allowListener(a.name, a.profiles))
|
|
2572
|
+
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
2573
|
+
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
2574
|
+
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
|
+
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
2578
|
+
.positional("name", { type: "string", demandOption: true })
|
|
2579
|
+
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
|
|
2580
|
+
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
2581
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
|
|
2582
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
|
|
2372
2583
|
.demandCommand(1).strict())
|
|
2584
|
+
.command(["url [profile]", "urls [profile]"], "Print a profile's connection URL (contains a secret key); `url ls` lists them", y => y
|
|
2585
|
+
.positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists all listeners' URLs" })
|
|
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))
|
|
2373
2595
|
.command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
|
|
2374
2596
|
.positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
|
|
2375
2597
|
.option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
|
|
2376
2598
|
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
|
|
2377
2599
|
a => {
|
|
2378
|
-
if (a.printUri) return handlers.printProfileUri(a.name, a.listener);
|
|
2600
|
+
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
|
|
2379
2601
|
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2380
2602
|
throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
|
|
2381
2603
|
})
|
|
@@ -2412,6 +2634,7 @@ if (import.meta.main) {
|
|
|
2412
2634
|
serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2413
2635
|
status,
|
|
2414
2636
|
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2637
|
+
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
2415
2638
|
setup: async (opts) => {
|
|
2416
2639
|
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2417
2640
|
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
@@ -2440,6 +2663,9 @@ if (import.meta.main) {
|
|
|
2440
2663
|
} finally {
|
|
2441
2664
|
if (cmd !== "serve") envWatcher?.close();
|
|
2442
2665
|
}
|
|
2666
|
+
} else if (cmd === "--version") {
|
|
2667
|
+
console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
|
|
2668
|
+
envWatcher?.close();
|
|
2443
2669
|
} else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
|
|
2444
2670
|
printHelp();
|
|
2445
2671
|
envWatcher?.close();
|
|
@@ -2454,6 +2680,10 @@ if (import.meta.main) {
|
|
|
2454
2680
|
// the leading-flags-only rule that protects the forwarded CLI's own --profile).
|
|
2455
2681
|
let profileSelector: string | undefined;
|
|
2456
2682
|
let overrideEnv: Record<string, string> | undefined;
|
|
2683
|
+
// Everything after rech's `pw` (or `--`) goes to playwright-cli untouched; only the flags before it are rech's.
|
|
2684
|
+
const separator = rechSeparatorIndex(args);
|
|
2685
|
+
const forwarded = separator === -1 ? [] : args.slice(separator + 1);
|
|
2686
|
+
if (separator !== -1) args = args.slice(0, separator);
|
|
2457
2687
|
try {
|
|
2458
2688
|
const extracted = extractGlobalProfileArg(args);
|
|
2459
2689
|
profileSelector = extracted.selector;
|
|
@@ -2493,6 +2723,7 @@ if (import.meta.main) {
|
|
|
2493
2723
|
args.splice(isolateIdx, 1);
|
|
2494
2724
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
2495
2725
|
}
|
|
2726
|
+
args = [...forwarded, ...args];
|
|
2496
2727
|
await run(url, args, overrideEnv);
|
|
2497
2728
|
envWatcher?.close();
|
|
2498
2729
|
}
|