rechrome 1.23.4 → 1.24.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 +5 -0
- package/package.json +1 -1
- package/rech.js +238 -24
- package/rech.ts +238 -24
- package/serve.js +37 -0
- package/serve.ts +37 -0
package/README.md
CHANGED
|
@@ -46,6 +46,11 @@ Now `rechrome` (or `rech`) is available globally.
|
|
|
46
46
|
|
|
47
47
|
`rech setup` configures the daemon, Chrome extension, and connection URL in one pass:
|
|
48
48
|
|
|
49
|
+
If no supported daemon manager is available, setup asks before installing `oxmgr` globally
|
|
50
|
+
(default: No). It uses `bun i -g oxmgr` when launched with bunx and `npm i -g oxmgr`
|
|
51
|
+
when launched with npx. Pass `--yes` to approve this installation without prompting,
|
|
52
|
+
for example `bunx rechrome setup --profile Default --yes`.
|
|
53
|
+
|
|
49
54
|
```bash
|
|
50
55
|
rech setup # interactive: pick a profile, follow the prompts
|
|
51
56
|
rech setup --profile you@email.com # non-interactive profile selection
|
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
|
|
|
6
6
|
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
import { spawn as cpSpawn } from "child_process";
|
|
9
|
-
import { pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
|
|
9
|
+
import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
|
|
10
10
|
|
|
11
11
|
export const ENV_KEY = "RECHROME_URL";
|
|
12
12
|
export const DEFAULT_PORT = 13775;
|
|
@@ -174,17 +174,19 @@ export function parseUrl(raw: string) {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
export async function getOrCreateUrl(): Promise<string> {
|
|
177
|
+
export async function getOrCreateUrl(persist = true): Promise<string> {
|
|
178
178
|
// Treat a URL without a bearer key as missing — it cannot authenticate
|
|
179
179
|
try { if (process.env[ENV_KEY] && new URL(process.env[ENV_KEY]!).username) return process.env[ENV_KEY]!; } catch {}
|
|
180
180
|
const key = randomBytes(12).toString("base64url"); // 16 chars
|
|
181
181
|
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
182
|
+
if (persist) {
|
|
183
|
+
const newLine = `${ENV_KEY}=${url}`;
|
|
184
|
+
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
185
|
+
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
186
|
+
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
187
|
+
const content = [...lines, newLine, ""].join("\n");
|
|
188
|
+
await Bun.write(globalEnvFile, content);
|
|
189
|
+
}
|
|
188
190
|
process.env[ENV_KEY] = url;
|
|
189
191
|
return url;
|
|
190
192
|
}
|
|
@@ -400,6 +402,59 @@ export function validateChromeProfileSelector(selector: string): void {
|
|
|
400
402
|
);
|
|
401
403
|
}
|
|
402
404
|
|
|
405
|
+
export async function resolveGlobalProfile(
|
|
406
|
+
registry: Record<string, TokenEntry>,
|
|
407
|
+
chromeProfiles: Record<string, ChromeProfileInfo> | null,
|
|
408
|
+
selector: string,
|
|
409
|
+
): Promise<{ email: string; entry: TokenEntry }> {
|
|
410
|
+
const value = selector.trim();
|
|
411
|
+
if (!value) throw new Error("--profile requires a non-empty value");
|
|
412
|
+
|
|
413
|
+
const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
|
|
414
|
+
if (registryKey) return { email: registryKey, entry: registry[registryKey] };
|
|
415
|
+
|
|
416
|
+
if (!chromeProfiles) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
|
|
419
|
+
`Run \`rech setup --profile "${value}"\` to register this profile.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const profiles = Object.entries(chromeProfiles);
|
|
424
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
425
|
+
try {
|
|
426
|
+
match = resolveChromeProfileSelector(profiles, value);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!match) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`--profile "${value}" does not match any Chrome profile. ` +
|
|
434
|
+
`See available profiles with \`rech profiles\`.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const [dir, info] = match;
|
|
439
|
+
const email = info.user_name;
|
|
440
|
+
if (!email) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
|
|
443
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const entry = registry[email];
|
|
448
|
+
if (!entry) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Profile "${email}" (${dir}) is not registered. ` +
|
|
451
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return { email, entry };
|
|
456
|
+
}
|
|
457
|
+
|
|
403
458
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
404
459
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
405
460
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -545,7 +600,10 @@ async function callServe(
|
|
|
545
600
|
// and run() has already done so for its log line. Recomputing here would double those git
|
|
546
601
|
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
547
602
|
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
548
|
-
|
|
603
|
+
// A global `--profile` override must win for the session key too: the daemon hashes
|
|
604
|
+
// identity.profile into the session id, so without this, `rech --profile other open` would
|
|
605
|
+
// reuse the default profile's session (and its browser) instead of opening its own.
|
|
606
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
549
607
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
550
608
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
551
609
|
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
@@ -588,12 +646,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
|
|
|
588
646
|
return normalized;
|
|
589
647
|
}
|
|
590
648
|
|
|
591
|
-
|
|
649
|
+
// Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
|
|
650
|
+
// Only flags before the first positional (the playwright subcommand) are rech globals — a
|
|
651
|
+
// --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
|
|
652
|
+
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
653
|
+
// missing value; accepts multiple occurrences (last one wins).
|
|
654
|
+
export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
|
|
655
|
+
const rest = [...args];
|
|
656
|
+
let selector: string | undefined;
|
|
657
|
+
for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
|
|
658
|
+
const a = rest[i];
|
|
659
|
+
if (a === "--profile") {
|
|
660
|
+
const value = rest[i + 1];
|
|
661
|
+
if (!value || value.startsWith("--"))
|
|
662
|
+
throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
663
|
+
selector = value;
|
|
664
|
+
rest.splice(i, 2);
|
|
665
|
+
i--;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (a.startsWith("--profile=")) {
|
|
669
|
+
const value = a.slice("--profile=".length);
|
|
670
|
+
if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
671
|
+
selector = value;
|
|
672
|
+
rest.splice(i, 1);
|
|
673
|
+
i--;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return { args: rest, selector };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
|
|
592
681
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
682
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
683
|
args = normalizeCommandArgs(args);
|
|
595
684
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
596
|
-
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
685
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
597
686
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
598
687
|
const identity = await getClientIdentity();
|
|
599
688
|
const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
|
|
@@ -602,18 +691,19 @@ async function run(url: string, args: string[]) {
|
|
|
602
691
|
);
|
|
603
692
|
|
|
604
693
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
605
|
-
const
|
|
694
|
+
const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
|
|
695
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
|
|
606
696
|
|
|
607
697
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
608
698
|
if (existingSession && isOpenWithUrl) {
|
|
609
|
-
return run(url, ["goto", ...args.slice(1)]);
|
|
699
|
+
return run(url, ["goto", ...args.slice(1)], overrideEnv);
|
|
610
700
|
}
|
|
611
701
|
|
|
612
702
|
if (existingSession)
|
|
613
703
|
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
614
704
|
if (stderr) {
|
|
615
705
|
if (stderr.includes('Extension connection timeout')) {
|
|
616
|
-
const hasToken = !!
|
|
706
|
+
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
617
707
|
const last = hasToken
|
|
618
708
|
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
619
709
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
@@ -810,6 +900,7 @@ export function resolvePlaywrightCli(): string {
|
|
|
810
900
|
}
|
|
811
901
|
|
|
812
902
|
export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
903
|
+
const mgr = daemonManager();
|
|
813
904
|
// Persist the URL to ~/.env.local before starting the daemon. The daemon's
|
|
814
905
|
// loadEnv() walks CWD→root reading .env.local files and unconditionally
|
|
815
906
|
// overwrites process.env.RECHROME_URL from whichever file it finds first.
|
|
@@ -846,8 +937,6 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
846
937
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
847
938
|
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
848
939
|
|
|
849
|
-
const mgr = daemonManager();
|
|
850
|
-
|
|
851
940
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
852
941
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
853
942
|
|
|
@@ -923,12 +1012,51 @@ function findTrayBinary(): string | undefined {
|
|
|
923
1012
|
return Bun.which(`rechrome-tray${ext}`) ?? undefined;
|
|
924
1013
|
}
|
|
925
1014
|
|
|
1015
|
+
// Verify a PID is actually a rechrome-tray process, not an unrelated process that
|
|
1016
|
+
// happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
|
|
1017
|
+
// Windows); there we fall back to trusting the liveness probe alone (best-effort).
|
|
1018
|
+
function isPidRechromeTray(pid: number): boolean {
|
|
1019
|
+
if (IS_WINDOWS) return true;
|
|
1020
|
+
try {
|
|
1021
|
+
const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1022
|
+
return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
|
|
1023
|
+
} catch {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
|
|
1029
|
+
// outside `rech tray show` that never touch the pidfile — so a singleton guard can't
|
|
1030
|
+
// spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
|
|
1031
|
+
// pidfile liveness check alone.
|
|
1032
|
+
function listTrayPids(): number[] {
|
|
1033
|
+
if (IS_WINDOWS) return [];
|
|
1034
|
+
try {
|
|
1035
|
+
const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1036
|
+
const out = p.stdout?.toString() ?? "";
|
|
1037
|
+
const pids: number[] = [];
|
|
1038
|
+
for (const line of out.split("\n")) {
|
|
1039
|
+
const m = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
1040
|
+
// macOS reports the full launch path in `comm`, so match the basename via a
|
|
1041
|
+
// trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
|
|
1042
|
+
if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
|
|
1043
|
+
}
|
|
1044
|
+
return pids;
|
|
1045
|
+
} catch {
|
|
1046
|
+
return [];
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
926
1050
|
function isTrayRunning(): boolean {
|
|
1051
|
+
// Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
|
|
1052
|
+
// is a single slot that only ever records the most-recent spawn, so it cannot see
|
|
1053
|
+
// earlier/untracked instances on its own.
|
|
1054
|
+
if (listTrayPids().length > 0) return true;
|
|
927
1055
|
try {
|
|
928
1056
|
const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
|
|
929
1057
|
if (!Number.isFinite(pid)) return false;
|
|
930
1058
|
process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
|
|
931
|
-
return
|
|
1059
|
+
return isPidRechromeTray(pid);
|
|
932
1060
|
} catch {
|
|
933
1061
|
return false;
|
|
934
1062
|
}
|
|
@@ -958,7 +1086,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
|
|
|
958
1086
|
}
|
|
959
1087
|
|
|
960
1088
|
function stopTray(): void {
|
|
961
|
-
|
|
1089
|
+
// Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
|
|
1090
|
+
// tray should never have more than one instance, but a crashed/overwritten pidfile
|
|
1091
|
+
// can leave orphans the single-slot pidfile no longer points at — reap them too.
|
|
1092
|
+
const pids = listTrayPids();
|
|
1093
|
+
if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
|
|
1094
|
+
for (const pid of pids) { try { process.kill(pid); } catch {} }
|
|
962
1095
|
try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
|
|
963
1096
|
try { unlinkSync(TRAY_PID_FILE); } catch {}
|
|
964
1097
|
console.log("tray: stopped. Run `rech tray show` to restore.");
|
|
@@ -1187,7 +1320,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1187
1320
|
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
1188
1321
|
}
|
|
1189
1322
|
|
|
1190
|
-
async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
|
|
1323
|
+
async function setup(opts: { profile?: string; token?: string; yes?: boolean } = {}): Promise<void> {
|
|
1191
1324
|
if (opts.profile !== undefined) {
|
|
1192
1325
|
try {
|
|
1193
1326
|
validateChromeProfileSelector(opts.profile);
|
|
@@ -1234,7 +1367,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1234
1367
|
if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
|
|
1235
1368
|
} catch {}
|
|
1236
1369
|
}
|
|
1237
|
-
|
|
1370
|
+
// Defer persistence until daemon prerequisites have passed.
|
|
1371
|
+
const url = await getOrCreateUrl(false);
|
|
1238
1372
|
const { host, port, protocol } = parseUrl(url);
|
|
1239
1373
|
|
|
1240
1374
|
const { key: serveKey } = parseUrl(url);
|
|
@@ -1272,6 +1406,40 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1272
1406
|
console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
|
|
1273
1407
|
}
|
|
1274
1408
|
const bindChanged = desiredBind !== currentBind;
|
|
1409
|
+
if (!daemonHealthy || bindChanged) {
|
|
1410
|
+
try {
|
|
1411
|
+
try {
|
|
1412
|
+
daemonManager();
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
// Respect an explicit pm2 choice; installing oxmgr would not satisfy it.
|
|
1415
|
+
if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
|
|
1416
|
+
const command = oxmgrInstallCommand(process.env);
|
|
1417
|
+
const answer = opts.yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
|
|
1418
|
+
if (!/^(y|yes)$/i.test(answer)) {
|
|
1419
|
+
throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
|
|
1420
|
+
}
|
|
1421
|
+
console.log(` Installing oxmgr: ${command.join(" ")}`);
|
|
1422
|
+
const installer = Bun.which(command[0]) ?? (command[0] === "bun" ? process.execPath : null);
|
|
1423
|
+
if (!installer) throw new Error(`${command[0]} is not on PATH. Install it or run \`${command.join(" ")}\` in your terminal, then rerun setup.`);
|
|
1424
|
+
const proc = Bun.spawn([installer, ...command.slice(1)], {
|
|
1425
|
+
stdin: "inherit", stdout: "inherit", stderr: "inherit", windowsHide: true,
|
|
1426
|
+
});
|
|
1427
|
+
const code = await proc.exited;
|
|
1428
|
+
if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
|
|
1429
|
+
if (!Bun.which("oxmgr")) {
|
|
1430
|
+
throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
|
|
1431
|
+
}
|
|
1432
|
+
_daemonMgr = undefined;
|
|
1433
|
+
_oxmgrVersion = undefined;
|
|
1434
|
+
daemonManager();
|
|
1435
|
+
}
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1438
|
+
rl?.close();
|
|
1439
|
+
envWatcher?.close();
|
|
1440
|
+
process.exit(1);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1275
1443
|
const persistedChanged = desiredBind !== persistedBind;
|
|
1276
1444
|
if (persistedChanged) {
|
|
1277
1445
|
const lines = globalEnvRaw.trimEnd().split("\n").filter(l => !/^\s*RECH_HOST\s*=/.test(l));
|
|
@@ -1623,8 +1791,18 @@ function printHelp(): void {
|
|
|
1623
1791
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1624
1792
|
|
|
1625
1793
|
Usage:
|
|
1626
|
-
rech
|
|
1794
|
+
rech [--profile <email|name|folder>] <playwright-args...>
|
|
1795
|
+
Run Playwright CLI command with the given registered
|
|
1796
|
+
Chrome profile. --profile selects the profile by exact
|
|
1797
|
+
registered email (e.g. you@gmail.com), exact Chrome
|
|
1798
|
+
profile name, or exact profile folder name. The profile
|
|
1799
|
+
must already be registered (see \`rech setup\`). Place
|
|
1800
|
+
--profile before the playwright subcommand. Requires
|
|
1801
|
+
${ENV_KEY}.
|
|
1802
|
+
rech setup [--profile <email|name|folder>] [--token <tok>] [--yes]
|
|
1627
1803
|
First-time setup: daemon + Chrome extension + config
|
|
1804
|
+
Offers to install missing oxmgr globally (y/N).
|
|
1805
|
+
--yes approves installation without prompting.
|
|
1628
1806
|
--profile selects the Chrome profile non-interactively.
|
|
1629
1807
|
Menu numbers are not accepted. Resolution order is exact
|
|
1630
1808
|
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
@@ -1659,13 +1837,14 @@ Environment:
|
|
|
1659
1837
|
Examples:
|
|
1660
1838
|
rech setup
|
|
1661
1839
|
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1840
|
+
rech --profile you@gmail.com open https://example.com
|
|
1662
1841
|
rech eval "() => document.title"
|
|
1663
1842
|
rech open https://example.com
|
|
1664
1843
|
rech screenshot`);
|
|
1665
1844
|
}
|
|
1666
1845
|
|
|
1667
1846
|
if (import.meta.main) {
|
|
1668
|
-
|
|
1847
|
+
let args = process.argv.slice(2);
|
|
1669
1848
|
const cmd = args[0]?.toLowerCase();
|
|
1670
1849
|
|
|
1671
1850
|
if (cmd === "serve") {
|
|
@@ -1687,7 +1866,7 @@ if (import.meta.main) {
|
|
|
1687
1866
|
? args[tokenIdx + 1]
|
|
1688
1867
|
: args.find(a => a.startsWith("--token="))?.slice("--token=".length))
|
|
1689
1868
|
?? process.env.RECH_TOKEN;
|
|
1690
|
-
await setup({ profile, token }); // setup closes envWatcher itself before printing Done
|
|
1869
|
+
await setup({ profile, token, yes: args.includes("--yes") }); // setup closes envWatcher itself before printing Done
|
|
1691
1870
|
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
1692
1871
|
await startTray({ quiet: true }).catch(() => {});
|
|
1693
1872
|
} else if (cmd === "tray") {
|
|
@@ -1723,6 +1902,41 @@ if (import.meta.main) {
|
|
|
1723
1902
|
printHelp();
|
|
1724
1903
|
process.exit(1);
|
|
1725
1904
|
}
|
|
1905
|
+
// --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
|
|
1906
|
+
// the leading-flags-only rule that protects the forwarded CLI's own --profile).
|
|
1907
|
+
let profileSelector: string | undefined;
|
|
1908
|
+
let overrideEnv: Record<string, string> | undefined;
|
|
1909
|
+
try {
|
|
1910
|
+
const extracted = extractGlobalProfileArg(args);
|
|
1911
|
+
profileSelector = extracted.selector;
|
|
1912
|
+
args = extracted.args;
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1915
|
+
envWatcher?.close();
|
|
1916
|
+
process.exit(1);
|
|
1917
|
+
}
|
|
1918
|
+
if (profileSelector !== undefined) {
|
|
1919
|
+
try {
|
|
1920
|
+
const registry = await readTokenRegistry();
|
|
1921
|
+
const cache = await readChromeProfileCache();
|
|
1922
|
+
const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
|
|
1923
|
+
// Use the registry key (email, or the managed profile name) as the profile identity:
|
|
1924
|
+
// the daemon already resolves email/name → profile dir for the default URL-param path,
|
|
1925
|
+
// so this keeps `--profile <email>` on the SAME session as the default path and only
|
|
1926
|
+
// opens a separate session when the profile really differs.
|
|
1927
|
+
overrideEnv = {
|
|
1928
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
|
|
1929
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
|
|
1930
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
|
|
1931
|
+
};
|
|
1932
|
+
if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
|
|
1933
|
+
if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1936
|
+
envWatcher?.close();
|
|
1937
|
+
process.exit(1);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1726
1940
|
// --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
|
|
1727
1941
|
// flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
|
|
1728
1942
|
// marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.js), so an
|
|
@@ -1732,7 +1946,7 @@ if (import.meta.main) {
|
|
|
1732
1946
|
args.splice(isolateIdx, 1);
|
|
1733
1947
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
1734
1948
|
}
|
|
1735
|
-
await run(url, args);
|
|
1949
|
+
await run(url, args, overrideEnv);
|
|
1736
1950
|
envWatcher?.close();
|
|
1737
1951
|
}
|
|
1738
1952
|
}
|
package/rech.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
|
|
|
6
6
|
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
import { spawn as cpSpawn } from "child_process";
|
|
9
|
-
import { pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
|
|
9
|
+
import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
|
|
10
10
|
|
|
11
11
|
export const ENV_KEY = "RECHROME_URL";
|
|
12
12
|
export const DEFAULT_PORT = 13775;
|
|
@@ -174,17 +174,19 @@ export function parseUrl(raw: string) {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
export async function getOrCreateUrl(): Promise<string> {
|
|
177
|
+
export async function getOrCreateUrl(persist = true): Promise<string> {
|
|
178
178
|
// Treat a URL without a bearer key as missing — it cannot authenticate
|
|
179
179
|
try { if (process.env[ENV_KEY] && new URL(process.env[ENV_KEY]!).username) return process.env[ENV_KEY]!; } catch {}
|
|
180
180
|
const key = randomBytes(12).toString("base64url"); // 16 chars
|
|
181
181
|
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
182
|
+
if (persist) {
|
|
183
|
+
const newLine = `${ENV_KEY}=${url}`;
|
|
184
|
+
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
185
|
+
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
186
|
+
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
187
|
+
const content = [...lines, newLine, ""].join("\n");
|
|
188
|
+
await Bun.write(globalEnvFile, content);
|
|
189
|
+
}
|
|
188
190
|
process.env[ENV_KEY] = url;
|
|
189
191
|
return url;
|
|
190
192
|
}
|
|
@@ -400,6 +402,59 @@ export function validateChromeProfileSelector(selector: string): void {
|
|
|
400
402
|
);
|
|
401
403
|
}
|
|
402
404
|
|
|
405
|
+
export async function resolveGlobalProfile(
|
|
406
|
+
registry: Record<string, TokenEntry>,
|
|
407
|
+
chromeProfiles: Record<string, ChromeProfileInfo> | null,
|
|
408
|
+
selector: string,
|
|
409
|
+
): Promise<{ email: string; entry: TokenEntry }> {
|
|
410
|
+
const value = selector.trim();
|
|
411
|
+
if (!value) throw new Error("--profile requires a non-empty value");
|
|
412
|
+
|
|
413
|
+
const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
|
|
414
|
+
if (registryKey) return { email: registryKey, entry: registry[registryKey] };
|
|
415
|
+
|
|
416
|
+
if (!chromeProfiles) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
|
|
419
|
+
`Run \`rech setup --profile "${value}"\` to register this profile.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const profiles = Object.entries(chromeProfiles);
|
|
424
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
425
|
+
try {
|
|
426
|
+
match = resolveChromeProfileSelector(profiles, value);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!match) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`--profile "${value}" does not match any Chrome profile. ` +
|
|
434
|
+
`See available profiles with \`rech profiles\`.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const [dir, info] = match;
|
|
439
|
+
const email = info.user_name;
|
|
440
|
+
if (!email) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
|
|
443
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const entry = registry[email];
|
|
448
|
+
if (!entry) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Profile "${email}" (${dir}) is not registered. ` +
|
|
451
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return { email, entry };
|
|
456
|
+
}
|
|
457
|
+
|
|
403
458
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
404
459
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
405
460
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -545,7 +600,10 @@ async function callServe(
|
|
|
545
600
|
// and run() has already done so for its log line. Recomputing here would double those git
|
|
546
601
|
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
547
602
|
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
548
|
-
|
|
603
|
+
// A global `--profile` override must win for the session key too: the daemon hashes
|
|
604
|
+
// identity.profile into the session id, so without this, `rech --profile other open` would
|
|
605
|
+
// reuse the default profile's session (and its browser) instead of opening its own.
|
|
606
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
549
607
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
550
608
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
551
609
|
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
@@ -588,12 +646,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
|
|
|
588
646
|
return normalized;
|
|
589
647
|
}
|
|
590
648
|
|
|
591
|
-
|
|
649
|
+
// Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
|
|
650
|
+
// Only flags before the first positional (the playwright subcommand) are rech globals — a
|
|
651
|
+
// --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
|
|
652
|
+
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
653
|
+
// missing value; accepts multiple occurrences (last one wins).
|
|
654
|
+
export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
|
|
655
|
+
const rest = [...args];
|
|
656
|
+
let selector: string | undefined;
|
|
657
|
+
for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
|
|
658
|
+
const a = rest[i];
|
|
659
|
+
if (a === "--profile") {
|
|
660
|
+
const value = rest[i + 1];
|
|
661
|
+
if (!value || value.startsWith("--"))
|
|
662
|
+
throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
663
|
+
selector = value;
|
|
664
|
+
rest.splice(i, 2);
|
|
665
|
+
i--;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (a.startsWith("--profile=")) {
|
|
669
|
+
const value = a.slice("--profile=".length);
|
|
670
|
+
if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
671
|
+
selector = value;
|
|
672
|
+
rest.splice(i, 1);
|
|
673
|
+
i--;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return { args: rest, selector };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
|
|
592
681
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
682
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
683
|
args = normalizeCommandArgs(args);
|
|
595
684
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
596
|
-
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
685
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
597
686
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
598
687
|
const identity = await getClientIdentity();
|
|
599
688
|
const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
|
|
@@ -602,18 +691,19 @@ async function run(url: string, args: string[]) {
|
|
|
602
691
|
);
|
|
603
692
|
|
|
604
693
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
605
|
-
const
|
|
694
|
+
const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
|
|
695
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
|
|
606
696
|
|
|
607
697
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
608
698
|
if (existingSession && isOpenWithUrl) {
|
|
609
|
-
return run(url, ["goto", ...args.slice(1)]);
|
|
699
|
+
return run(url, ["goto", ...args.slice(1)], overrideEnv);
|
|
610
700
|
}
|
|
611
701
|
|
|
612
702
|
if (existingSession)
|
|
613
703
|
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
614
704
|
if (stderr) {
|
|
615
705
|
if (stderr.includes('Extension connection timeout')) {
|
|
616
|
-
const hasToken = !!
|
|
706
|
+
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
617
707
|
const last = hasToken
|
|
618
708
|
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
619
709
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
@@ -810,6 +900,7 @@ export function resolvePlaywrightCli(): string {
|
|
|
810
900
|
}
|
|
811
901
|
|
|
812
902
|
export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
903
|
+
const mgr = daemonManager();
|
|
813
904
|
// Persist the URL to ~/.env.local before starting the daemon. The daemon's
|
|
814
905
|
// loadEnv() walks CWD→root reading .env.local files and unconditionally
|
|
815
906
|
// overwrites process.env.RECHROME_URL from whichever file it finds first.
|
|
@@ -846,8 +937,6 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
846
937
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
847
938
|
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
848
939
|
|
|
849
|
-
const mgr = daemonManager();
|
|
850
|
-
|
|
851
940
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
852
941
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
853
942
|
|
|
@@ -923,12 +1012,51 @@ function findTrayBinary(): string | undefined {
|
|
|
923
1012
|
return Bun.which(`rechrome-tray${ext}`) ?? undefined;
|
|
924
1013
|
}
|
|
925
1014
|
|
|
1015
|
+
// Verify a PID is actually a rechrome-tray process, not an unrelated process that
|
|
1016
|
+
// happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
|
|
1017
|
+
// Windows); there we fall back to trusting the liveness probe alone (best-effort).
|
|
1018
|
+
function isPidRechromeTray(pid: number): boolean {
|
|
1019
|
+
if (IS_WINDOWS) return true;
|
|
1020
|
+
try {
|
|
1021
|
+
const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1022
|
+
return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
|
|
1023
|
+
} catch {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
|
|
1029
|
+
// outside `rech tray show` that never touch the pidfile — so a singleton guard can't
|
|
1030
|
+
// spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
|
|
1031
|
+
// pidfile liveness check alone.
|
|
1032
|
+
function listTrayPids(): number[] {
|
|
1033
|
+
if (IS_WINDOWS) return [];
|
|
1034
|
+
try {
|
|
1035
|
+
const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1036
|
+
const out = p.stdout?.toString() ?? "";
|
|
1037
|
+
const pids: number[] = [];
|
|
1038
|
+
for (const line of out.split("\n")) {
|
|
1039
|
+
const m = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
1040
|
+
// macOS reports the full launch path in `comm`, so match the basename via a
|
|
1041
|
+
// trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
|
|
1042
|
+
if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
|
|
1043
|
+
}
|
|
1044
|
+
return pids;
|
|
1045
|
+
} catch {
|
|
1046
|
+
return [];
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
926
1050
|
function isTrayRunning(): boolean {
|
|
1051
|
+
// Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
|
|
1052
|
+
// is a single slot that only ever records the most-recent spawn, so it cannot see
|
|
1053
|
+
// earlier/untracked instances on its own.
|
|
1054
|
+
if (listTrayPids().length > 0) return true;
|
|
927
1055
|
try {
|
|
928
1056
|
const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
|
|
929
1057
|
if (!Number.isFinite(pid)) return false;
|
|
930
1058
|
process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
|
|
931
|
-
return
|
|
1059
|
+
return isPidRechromeTray(pid);
|
|
932
1060
|
} catch {
|
|
933
1061
|
return false;
|
|
934
1062
|
}
|
|
@@ -958,7 +1086,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
|
|
|
958
1086
|
}
|
|
959
1087
|
|
|
960
1088
|
function stopTray(): void {
|
|
961
|
-
|
|
1089
|
+
// Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
|
|
1090
|
+
// tray should never have more than one instance, but a crashed/overwritten pidfile
|
|
1091
|
+
// can leave orphans the single-slot pidfile no longer points at — reap them too.
|
|
1092
|
+
const pids = listTrayPids();
|
|
1093
|
+
if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
|
|
1094
|
+
for (const pid of pids) { try { process.kill(pid); } catch {} }
|
|
962
1095
|
try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
|
|
963
1096
|
try { unlinkSync(TRAY_PID_FILE); } catch {}
|
|
964
1097
|
console.log("tray: stopped. Run `rech tray show` to restore.");
|
|
@@ -1187,7 +1320,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1187
1320
|
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
1188
1321
|
}
|
|
1189
1322
|
|
|
1190
|
-
async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
|
|
1323
|
+
async function setup(opts: { profile?: string; token?: string; yes?: boolean } = {}): Promise<void> {
|
|
1191
1324
|
if (opts.profile !== undefined) {
|
|
1192
1325
|
try {
|
|
1193
1326
|
validateChromeProfileSelector(opts.profile);
|
|
@@ -1234,7 +1367,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1234
1367
|
if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
|
|
1235
1368
|
} catch {}
|
|
1236
1369
|
}
|
|
1237
|
-
|
|
1370
|
+
// Defer persistence until daemon prerequisites have passed.
|
|
1371
|
+
const url = await getOrCreateUrl(false);
|
|
1238
1372
|
const { host, port, protocol } = parseUrl(url);
|
|
1239
1373
|
|
|
1240
1374
|
const { key: serveKey } = parseUrl(url);
|
|
@@ -1272,6 +1406,40 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1272
1406
|
console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
|
|
1273
1407
|
}
|
|
1274
1408
|
const bindChanged = desiredBind !== currentBind;
|
|
1409
|
+
if (!daemonHealthy || bindChanged) {
|
|
1410
|
+
try {
|
|
1411
|
+
try {
|
|
1412
|
+
daemonManager();
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
// Respect an explicit pm2 choice; installing oxmgr would not satisfy it.
|
|
1415
|
+
if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
|
|
1416
|
+
const command = oxmgrInstallCommand(process.env);
|
|
1417
|
+
const answer = opts.yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
|
|
1418
|
+
if (!/^(y|yes)$/i.test(answer)) {
|
|
1419
|
+
throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
|
|
1420
|
+
}
|
|
1421
|
+
console.log(` Installing oxmgr: ${command.join(" ")}`);
|
|
1422
|
+
const installer = Bun.which(command[0]) ?? (command[0] === "bun" ? process.execPath : null);
|
|
1423
|
+
if (!installer) throw new Error(`${command[0]} is not on PATH. Install it or run \`${command.join(" ")}\` in your terminal, then rerun setup.`);
|
|
1424
|
+
const proc = Bun.spawn([installer, ...command.slice(1)], {
|
|
1425
|
+
stdin: "inherit", stdout: "inherit", stderr: "inherit", windowsHide: true,
|
|
1426
|
+
});
|
|
1427
|
+
const code = await proc.exited;
|
|
1428
|
+
if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
|
|
1429
|
+
if (!Bun.which("oxmgr")) {
|
|
1430
|
+
throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
|
|
1431
|
+
}
|
|
1432
|
+
_daemonMgr = undefined;
|
|
1433
|
+
_oxmgrVersion = undefined;
|
|
1434
|
+
daemonManager();
|
|
1435
|
+
}
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1438
|
+
rl?.close();
|
|
1439
|
+
envWatcher?.close();
|
|
1440
|
+
process.exit(1);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1275
1443
|
const persistedChanged = desiredBind !== persistedBind;
|
|
1276
1444
|
if (persistedChanged) {
|
|
1277
1445
|
const lines = globalEnvRaw.trimEnd().split("\n").filter(l => !/^\s*RECH_HOST\s*=/.test(l));
|
|
@@ -1623,8 +1791,18 @@ function printHelp(): void {
|
|
|
1623
1791
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1624
1792
|
|
|
1625
1793
|
Usage:
|
|
1626
|
-
rech
|
|
1794
|
+
rech [--profile <email|name|folder>] <playwright-args...>
|
|
1795
|
+
Run Playwright CLI command with the given registered
|
|
1796
|
+
Chrome profile. --profile selects the profile by exact
|
|
1797
|
+
registered email (e.g. you@gmail.com), exact Chrome
|
|
1798
|
+
profile name, or exact profile folder name. The profile
|
|
1799
|
+
must already be registered (see \`rech setup\`). Place
|
|
1800
|
+
--profile before the playwright subcommand. Requires
|
|
1801
|
+
${ENV_KEY}.
|
|
1802
|
+
rech setup [--profile <email|name|folder>] [--token <tok>] [--yes]
|
|
1627
1803
|
First-time setup: daemon + Chrome extension + config
|
|
1804
|
+
Offers to install missing oxmgr globally (y/N).
|
|
1805
|
+
--yes approves installation without prompting.
|
|
1628
1806
|
--profile selects the Chrome profile non-interactively.
|
|
1629
1807
|
Menu numbers are not accepted. Resolution order is exact
|
|
1630
1808
|
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
@@ -1659,13 +1837,14 @@ Environment:
|
|
|
1659
1837
|
Examples:
|
|
1660
1838
|
rech setup
|
|
1661
1839
|
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1840
|
+
rech --profile you@gmail.com open https://example.com
|
|
1662
1841
|
rech eval "() => document.title"
|
|
1663
1842
|
rech open https://example.com
|
|
1664
1843
|
rech screenshot`);
|
|
1665
1844
|
}
|
|
1666
1845
|
|
|
1667
1846
|
if (import.meta.main) {
|
|
1668
|
-
|
|
1847
|
+
let args = process.argv.slice(2);
|
|
1669
1848
|
const cmd = args[0]?.toLowerCase();
|
|
1670
1849
|
|
|
1671
1850
|
if (cmd === "serve") {
|
|
@@ -1687,7 +1866,7 @@ if (import.meta.main) {
|
|
|
1687
1866
|
? args[tokenIdx + 1]
|
|
1688
1867
|
: args.find(a => a.startsWith("--token="))?.slice("--token=".length))
|
|
1689
1868
|
?? process.env.RECH_TOKEN;
|
|
1690
|
-
await setup({ profile, token }); // setup closes envWatcher itself before printing Done
|
|
1869
|
+
await setup({ profile, token, yes: args.includes("--yes") }); // setup closes envWatcher itself before printing Done
|
|
1691
1870
|
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
1692
1871
|
await startTray({ quiet: true }).catch(() => {});
|
|
1693
1872
|
} else if (cmd === "tray") {
|
|
@@ -1723,6 +1902,41 @@ if (import.meta.main) {
|
|
|
1723
1902
|
printHelp();
|
|
1724
1903
|
process.exit(1);
|
|
1725
1904
|
}
|
|
1905
|
+
// --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
|
|
1906
|
+
// the leading-flags-only rule that protects the forwarded CLI's own --profile).
|
|
1907
|
+
let profileSelector: string | undefined;
|
|
1908
|
+
let overrideEnv: Record<string, string> | undefined;
|
|
1909
|
+
try {
|
|
1910
|
+
const extracted = extractGlobalProfileArg(args);
|
|
1911
|
+
profileSelector = extracted.selector;
|
|
1912
|
+
args = extracted.args;
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1915
|
+
envWatcher?.close();
|
|
1916
|
+
process.exit(1);
|
|
1917
|
+
}
|
|
1918
|
+
if (profileSelector !== undefined) {
|
|
1919
|
+
try {
|
|
1920
|
+
const registry = await readTokenRegistry();
|
|
1921
|
+
const cache = await readChromeProfileCache();
|
|
1922
|
+
const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
|
|
1923
|
+
// Use the registry key (email, or the managed profile name) as the profile identity:
|
|
1924
|
+
// the daemon already resolves email/name → profile dir for the default URL-param path,
|
|
1925
|
+
// so this keeps `--profile <email>` on the SAME session as the default path and only
|
|
1926
|
+
// opens a separate session when the profile really differs.
|
|
1927
|
+
overrideEnv = {
|
|
1928
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
|
|
1929
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
|
|
1930
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
|
|
1931
|
+
};
|
|
1932
|
+
if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
|
|
1933
|
+
if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1936
|
+
envWatcher?.close();
|
|
1937
|
+
process.exit(1);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1726
1940
|
// --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
|
|
1727
1941
|
// flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
|
|
1728
1942
|
// marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
|
|
@@ -1732,7 +1946,7 @@ if (import.meta.main) {
|
|
|
1732
1946
|
args.splice(isolateIdx, 1);
|
|
1733
1947
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
1734
1948
|
}
|
|
1735
|
-
await run(url, args);
|
|
1949
|
+
await run(url, args, overrideEnv);
|
|
1736
1950
|
envWatcher?.close();
|
|
1737
1951
|
}
|
|
1738
1952
|
}
|
package/serve.js
CHANGED
|
@@ -278,6 +278,27 @@ async function freeStalePort(port: number): Promise<void> {
|
|
|
278
278
|
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
282
|
+
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
283
|
+
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
284
|
+
// the orphan to init (ppid 1) and it lives forever — the resource leak behind
|
|
285
|
+
// PERFORMANCE-EVENT.md. The managed daemon (oxmgr `--restart always`) keeps the
|
|
286
|
+
// process as its own child, so its ppid is stable and non-1 and it is never flagged.
|
|
287
|
+
// Once a serve is orphaned AND has had no real /run for the idle timeout, it exits so
|
|
288
|
+
// the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
|
|
289
|
+
// and would otherwise keep an orphan alive forever).
|
|
290
|
+
const ORPHAN_POLL_INTERVAL_MS = 15_000;
|
|
291
|
+
const ORPHAN_IDLE_EXIT_MS = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
|
|
292
|
+
|
|
293
|
+
// Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
|
|
294
|
+
export function shouldExitOrphanedServe(opts: {
|
|
295
|
+
orphaned: boolean;
|
|
296
|
+
idleMs: number;
|
|
297
|
+
idleTimeoutMs: number;
|
|
298
|
+
}): boolean {
|
|
299
|
+
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
300
|
+
}
|
|
301
|
+
|
|
281
302
|
export async function serve() {
|
|
282
303
|
const url = await getOrCreateUrl();
|
|
283
304
|
const { key, port } = parseUrl(url);
|
|
@@ -285,6 +306,21 @@ export async function serve() {
|
|
|
285
306
|
const workDir = join(RECH_DIR, "output");
|
|
286
307
|
mkdirSync(workDir, { recursive: true });
|
|
287
308
|
|
|
309
|
+
// Foreground/orphan self-exit: a serve whose parent has been re-parented to init
|
|
310
|
+
// (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
|
|
311
|
+
// and exit once orphaned + idle so an agent that died without shutting us down
|
|
312
|
+
// doesn't leave a daemon behind.
|
|
313
|
+
let orphaned = false;
|
|
314
|
+
let idleSince = Date.now();
|
|
315
|
+
const markActivity = () => { idleSince = Date.now(); };
|
|
316
|
+
setInterval(() => {
|
|
317
|
+
if (process.ppid === 1) orphaned = true;
|
|
318
|
+
if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
319
|
+
log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
}, ORPHAN_POLL_INTERVAL_MS);
|
|
323
|
+
|
|
288
324
|
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
289
325
|
adoptOrphanedIsoSessions();
|
|
290
326
|
setInterval(() => {
|
|
@@ -365,6 +401,7 @@ export async function serve() {
|
|
|
365
401
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
366
402
|
const denied = authCheck(req, key);
|
|
367
403
|
if (denied) return denied;
|
|
404
|
+
markActivity(); // a real command: this serve is not idle
|
|
368
405
|
|
|
369
406
|
const body = await req.json();
|
|
370
407
|
let args: string[];
|
package/serve.ts
CHANGED
|
@@ -278,6 +278,27 @@ async function freeStalePort(port: number): Promise<void> {
|
|
|
278
278
|
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
282
|
+
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
283
|
+
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
284
|
+
// the orphan to init (ppid 1) and it lives forever — the resource leak behind
|
|
285
|
+
// PERFORMANCE-EVENT.md. The managed daemon (oxmgr `--restart always`) keeps the
|
|
286
|
+
// process as its own child, so its ppid is stable and non-1 and it is never flagged.
|
|
287
|
+
// Once a serve is orphaned AND has had no real /run for the idle timeout, it exits so
|
|
288
|
+
// the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
|
|
289
|
+
// and would otherwise keep an orphan alive forever).
|
|
290
|
+
const ORPHAN_POLL_INTERVAL_MS = 15_000;
|
|
291
|
+
const ORPHAN_IDLE_EXIT_MS = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
|
|
292
|
+
|
|
293
|
+
// Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
|
|
294
|
+
export function shouldExitOrphanedServe(opts: {
|
|
295
|
+
orphaned: boolean;
|
|
296
|
+
idleMs: number;
|
|
297
|
+
idleTimeoutMs: number;
|
|
298
|
+
}): boolean {
|
|
299
|
+
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
300
|
+
}
|
|
301
|
+
|
|
281
302
|
export async function serve() {
|
|
282
303
|
const url = await getOrCreateUrl();
|
|
283
304
|
const { key, port } = parseUrl(url);
|
|
@@ -285,6 +306,21 @@ export async function serve() {
|
|
|
285
306
|
const workDir = join(RECH_DIR, "output");
|
|
286
307
|
mkdirSync(workDir, { recursive: true });
|
|
287
308
|
|
|
309
|
+
// Foreground/orphan self-exit: a serve whose parent has been re-parented to init
|
|
310
|
+
// (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
|
|
311
|
+
// and exit once orphaned + idle so an agent that died without shutting us down
|
|
312
|
+
// doesn't leave a daemon behind.
|
|
313
|
+
let orphaned = false;
|
|
314
|
+
let idleSince = Date.now();
|
|
315
|
+
const markActivity = () => { idleSince = Date.now(); };
|
|
316
|
+
setInterval(() => {
|
|
317
|
+
if (process.ppid === 1) orphaned = true;
|
|
318
|
+
if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
319
|
+
log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
}, ORPHAN_POLL_INTERVAL_MS);
|
|
323
|
+
|
|
288
324
|
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
289
325
|
adoptOrphanedIsoSessions();
|
|
290
326
|
setInterval(() => {
|
|
@@ -365,6 +401,7 @@ export async function serve() {
|
|
|
365
401
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
366
402
|
const denied = authCheck(req, key);
|
|
367
403
|
if (denied) return denied;
|
|
404
|
+
markActivity(); // a real command: this serve is not idle
|
|
368
405
|
|
|
369
406
|
const body = await req.json();
|
|
370
407
|
let args: string[];
|