rechrome 1.6.0 → 1.7.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/package.json +1 -1
- package/rech.js +413 -106
- package/rech.ts +413 -106
- package/serve.js +11 -6
- package/serve.ts +11 -6
package/rech.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { file } from "bun";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
|
-
import { mkdirSync, appendFileSync, existsSync } from "fs";
|
|
5
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, constants as fsConstants } from "fs";
|
|
6
6
|
import { hostname } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
@@ -11,33 +11,58 @@ export const DEFAULT_PORT = 13775;
|
|
|
11
11
|
export const RECH_DIR = join(import.meta.dir, ".rech");
|
|
12
12
|
export const LOG_DIR = join(RECH_DIR, "logs");
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const RECH_HOME_DIR = join(process.env.HOME!, ".rech");
|
|
15
|
+
const TOKENS_FILE = join(RECH_HOME_DIR, "tokens.json");
|
|
16
|
+
|
|
17
|
+
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string };
|
|
15
18
|
|
|
16
|
-
async function
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
async function readTokenRegistry(): Promise<Record<string, TokenEntry>> {
|
|
20
|
+
const raw = await file(TOKENS_FILE).text().catch(() => "{}");
|
|
21
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
|
|
25
|
+
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
26
|
+
const registry = await readTokenRegistry();
|
|
27
|
+
registry[profileEmail] = entry;
|
|
28
|
+
await Bun.write(TOKENS_FILE, JSON.stringify(registry, null, 2) + "\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const envFile = join(import.meta.dir, ".env.local");
|
|
32
|
+
const globalEnvFile = join(process.env.HOME || "~", ".env.local");
|
|
33
|
+
|
|
34
|
+
// Walk CWD→root loading env files nearest-first; per-key: closest file wins, farther files skip.
|
|
35
|
+
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
36
|
+
export async function loadNearestEnv(extraFallbacks: string[] = []) {
|
|
37
|
+
const seen = new Set<string>();
|
|
38
|
+
const applyFile = async (path: string) => {
|
|
39
|
+
const raw = await file(path).text().catch(() => "");
|
|
40
|
+
for (const line of raw.split("\n")) {
|
|
41
|
+
const m = line.match(/^\s*([^#=\s][^#=]*?)\s*=\s*(.*?)\s*$/);
|
|
42
|
+
if (!m || m[1].startsWith("#")) continue;
|
|
43
|
+
if (seen.has(m[1])) continue;
|
|
44
|
+
seen.add(m[1]);
|
|
23
45
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
24
|
-
if (m[1] === ENV_KEY) hasKey = true;
|
|
25
46
|
}
|
|
26
|
-
}
|
|
27
|
-
return hasKey;
|
|
28
|
-
}
|
|
47
|
+
};
|
|
29
48
|
|
|
30
|
-
async function loadEnv() {
|
|
31
|
-
// Walk up from cwd first — project-local .env.local takes priority
|
|
32
49
|
let dir = process.cwd();
|
|
50
|
+
const dirs: string[] = [];
|
|
33
51
|
while (true) {
|
|
34
|
-
|
|
52
|
+
dirs.push(dir);
|
|
35
53
|
const parent = join(dir, "..");
|
|
36
54
|
if (parent === dir) break;
|
|
37
55
|
dir = parent;
|
|
38
56
|
}
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
for (const d of dirs) {
|
|
58
|
+
await applyFile(join(d, ".rechrome", ".env.local"));
|
|
59
|
+
await applyFile(join(d, ".env.local"));
|
|
60
|
+
}
|
|
61
|
+
for (const f of extraFallbacks) await applyFile(f);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function loadEnv() {
|
|
65
|
+
await loadNearestEnv();
|
|
41
66
|
}
|
|
42
67
|
// Shell-set passthrough vars survive .env.local loading
|
|
43
68
|
const _shellPassthrough: Record<string, string> = {};
|
|
@@ -48,12 +73,9 @@ await loadEnv();
|
|
|
48
73
|
Object.assign(process.env, _shellPassthrough);
|
|
49
74
|
|
|
50
75
|
import { watch } from "node:fs";
|
|
51
|
-
|
|
52
|
-
watch(envFile, async () => {
|
|
53
|
-
|
|
54
|
-
await loadEnv();
|
|
55
|
-
});
|
|
56
|
-
}
|
|
76
|
+
const envWatcher = existsSync(envFile)
|
|
77
|
+
? watch(envFile, async () => { log(".env.local changed, reloading"); await loadEnv(); })
|
|
78
|
+
: null;
|
|
57
79
|
|
|
58
80
|
|
|
59
81
|
export const PASSTHROUGH_ENV_KEYS = [
|
|
@@ -61,8 +83,14 @@ export const PASSTHROUGH_ENV_KEYS = [
|
|
|
61
83
|
"PLAYWRIGHT_MCP_EXTENSION_TOKEN",
|
|
62
84
|
"PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
|
|
63
85
|
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
86
|
+
"PWMCP_TEST_CONNECTION_TIMEOUT",
|
|
64
87
|
] as const;
|
|
65
88
|
|
|
89
|
+
function isReadable(p?: string): boolean {
|
|
90
|
+
if (!p) return false;
|
|
91
|
+
try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
export function log(msg: string) {
|
|
67
95
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
68
96
|
const ts = new Date().toISOString();
|
|
@@ -92,13 +120,13 @@ export function parseUrl(raw: string) {
|
|
|
92
120
|
export async function getOrCreateUrl(): Promise<string> {
|
|
93
121
|
if (process.env[ENV_KEY]) return process.env[ENV_KEY];
|
|
94
122
|
const key = randomBytes(9).toString("base64url"); // 12 chars
|
|
95
|
-
const url = `http://${key}
|
|
123
|
+
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
96
124
|
const newLine = `${ENV_KEY}=${url}`;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const content =
|
|
101
|
-
Bun.write(
|
|
125
|
+
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
126
|
+
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
127
|
+
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
128
|
+
const content = [...lines, newLine, ""].join("\n");
|
|
129
|
+
Bun.write(globalEnvFile, content);
|
|
102
130
|
process.env[ENV_KEY] = url;
|
|
103
131
|
return url;
|
|
104
132
|
}
|
|
@@ -153,19 +181,34 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
|
|
|
153
181
|
return { hostname: hostname(), cwd };
|
|
154
182
|
}
|
|
155
183
|
|
|
156
|
-
function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Record<string, string
|
|
184
|
+
async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Promise<Record<string, string>> {
|
|
157
185
|
const env: Record<string, string> = {};
|
|
158
186
|
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
159
187
|
if (process.env[key]) env[key] = process.env[key];
|
|
160
188
|
}
|
|
161
189
|
if (urlExtras?.extensionId)
|
|
162
190
|
env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
|
|
163
|
-
if (urlExtras?.extensionToken)
|
|
164
|
-
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
|
|
165
191
|
if (urlExtras?.profileDirectory)
|
|
166
192
|
env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
|
|
167
193
|
if (urlExtras?.userDataDir)
|
|
168
194
|
env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
|
|
195
|
+
// Token: shell env wins (explicit override), registry is fallback, URL param is last resort
|
|
196
|
+
const profileKey = urlExtras?.profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
197
|
+
if (profileKey) {
|
|
198
|
+
const registry = await readTokenRegistry();
|
|
199
|
+
const entry = registry[profileKey];
|
|
200
|
+
if (entry) {
|
|
201
|
+
if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
|
|
202
|
+
if (!env["PLAYWRIGHT_MCP_USER_DATA_DIR"] && entry.userDataDir) env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = entry.userDataDir;
|
|
203
|
+
if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
|
|
204
|
+
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
|
|
205
|
+
} else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
|
|
206
|
+
console.error(`[rech] warning: shell PLAYWRIGHT_MCP_EXTENSION_TOKEN differs from registry token for "${profileKey}" — using shell value. Run \`unset PLAYWRIGHT_MCP_EXTENSION_TOKEN\` to use the registry.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] && urlExtras?.extensionToken)
|
|
211
|
+
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
|
|
169
212
|
return env;
|
|
170
213
|
}
|
|
171
214
|
|
|
@@ -222,7 +265,12 @@ async function findInstalledExtension(
|
|
|
222
265
|
const data = JSON.parse(await f.text());
|
|
223
266
|
const settings = data?.extensions?.settings ?? {};
|
|
224
267
|
for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
|
|
225
|
-
if (info?.path ===
|
|
268
|
+
if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
|
|
269
|
+
let storedPath = info.path as string;
|
|
270
|
+
try { storedPath = realpathSync(storedPath); } catch {}
|
|
271
|
+
let distPath = EXTENSION_DIST_DIR;
|
|
272
|
+
try { distPath = realpathSync(distPath); } catch {}
|
|
273
|
+
if (storedPath === distPath) return { id: extId, profile: prof };
|
|
226
274
|
}
|
|
227
275
|
} catch {}
|
|
228
276
|
}
|
|
@@ -284,7 +332,7 @@ async function callServe(
|
|
|
284
332
|
const identity = await getClientIdentity();
|
|
285
333
|
const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
286
334
|
if (effectiveProfile) (identity as any).profile = effectiveProfile;
|
|
287
|
-
const env = { ...getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir }), ...overrideEnv };
|
|
335
|
+
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
|
|
288
336
|
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
289
337
|
method: "POST",
|
|
290
338
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
@@ -331,111 +379,370 @@ async function run(url: string, args: string[]) {
|
|
|
331
379
|
process.exit(status);
|
|
332
380
|
}
|
|
333
381
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
382
|
+
function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
383
|
+
return `<!DOCTYPE html>
|
|
384
|
+
<html lang="en">
|
|
385
|
+
<head>
|
|
386
|
+
<meta charset="UTF-8">
|
|
387
|
+
<title>rechrome — Extension Setup</title>
|
|
388
|
+
<style>
|
|
389
|
+
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; }
|
|
390
|
+
h1 { color: #1a73e8; }
|
|
391
|
+
.step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding: 12px 16px; margin: 16px 0; border-radius: 0 8px 8px 0; }
|
|
392
|
+
.step h3 { margin: 0 0 8px; }
|
|
393
|
+
code { background: #e8eaed; padding: 2px 6px; border-radius: 4px; font-size: 0.95em; word-break: break-all; }
|
|
394
|
+
.path { display: flex; align-items: center; gap: 8px; }
|
|
395
|
+
button { background: #1a73e8; color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.9em; }
|
|
396
|
+
button:active { background: #1558b0; }
|
|
397
|
+
.note { color: #666; font-size: 0.9em; }
|
|
398
|
+
</style>
|
|
399
|
+
</head>
|
|
400
|
+
<body>
|
|
401
|
+
<h1>rechrome — Extension Setup</h1>
|
|
402
|
+
<p>Install the multi-tab extension in Chrome profile: <strong>${profileDisplay}</strong></p>
|
|
403
|
+
|
|
404
|
+
<div class="step">
|
|
405
|
+
<h3>Step 1 — Open Chrome Extensions</h3>
|
|
406
|
+
<p>In the Chrome profile <strong>${profileDisplay}</strong>, navigate to:</p>
|
|
407
|
+
<code>chrome://extensions/</code>
|
|
408
|
+
<p class="note">Make sure you are in the correct profile (check the avatar in the top-right corner).</p>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div class="step">
|
|
412
|
+
<h3>Step 2 — Enable Developer Mode</h3>
|
|
413
|
+
<p>Toggle <strong>Developer mode</strong> on (top-right of the extensions page).</p>
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
<div class="step">
|
|
417
|
+
<h3>Step 3 — Load the extension</h3>
|
|
418
|
+
<p>Click <strong>Load unpacked</strong> and select this directory:</p>
|
|
419
|
+
<div class="path">
|
|
420
|
+
<code id="extPath">${extDistDir}</code>
|
|
421
|
+
<button onclick="navigator.clipboard.writeText(document.getElementById('extPath').textContent).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy path',1500)})">Copy path</button>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<div class="step">
|
|
426
|
+
<h3>Step 4 — Return to terminal</h3>
|
|
427
|
+
<p>Press <strong>Enter</strong> in the terminal to continue setup.</p>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
<div class="step">
|
|
431
|
+
<h3>Step 5 — Copy auth token</h3>
|
|
432
|
+
<p>Click the extension icon in the Chrome toolbar (or open the URL below):</p>
|
|
433
|
+
<code id="statusUrl">chrome-extension://(detected after install)/status.html</code>
|
|
434
|
+
<p>The page shows <strong>PLAYWRIGHT_MCP_EXTENSION_TOKEN=...</strong> — paste that into the terminal when prompted.</p>
|
|
435
|
+
</div>
|
|
436
|
+
</body>
|
|
437
|
+
</html>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const OXMGR_PROCESS_NAME = "rechrome-serve";
|
|
441
|
+
|
|
442
|
+
async function runOxmgr(args: string[]): Promise<number> {
|
|
443
|
+
const proc = Bun.spawn(["bunx", "oxmgr", ...args], { stdout: "inherit", stderr: "inherit" });
|
|
444
|
+
await proc.exited;
|
|
445
|
+
return proc.exitCode ?? 1;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function daemonInstall(serveUrl: string): Promise<void> {
|
|
449
|
+
const home = process.env.HOME!;
|
|
450
|
+
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
451
|
+
const rechScript = import.meta.filename;
|
|
452
|
+
|
|
453
|
+
const envArgs: string[] = [
|
|
454
|
+
"--env", `HOME=${home}`,
|
|
455
|
+
"--env", `PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}`,
|
|
456
|
+
"--env", `${ENV_KEY}=${serveUrl}`,
|
|
457
|
+
"--env", `PWMCP_TEST_CONNECTION_TIMEOUT=${process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000"}`,
|
|
458
|
+
];
|
|
459
|
+
if (process.env.PLAYWRIGHT_CLI) envArgs.push("--env", `PLAYWRIGHT_CLI=${process.env.PLAYWRIGHT_CLI}`);
|
|
460
|
+
if (process.env.RECH_HOST) envArgs.push("--env", `RECH_HOST=${process.env.RECH_HOST}`);
|
|
461
|
+
if (isReadable(process.env.RECH_TLS_CERT)) envArgs.push("--env", `RECH_TLS_CERT=${process.env.RECH_TLS_CERT}`);
|
|
462
|
+
if (isReadable(process.env.RECH_TLS_KEY)) envArgs.push("--env", `RECH_TLS_KEY=${process.env.RECH_TLS_KEY}`);
|
|
463
|
+
|
|
464
|
+
await runOxmgr(["delete", OXMGR_PROCESS_NAME]).catch(() => {});
|
|
465
|
+
await runOxmgr([
|
|
466
|
+
"start",
|
|
467
|
+
"--name", OXMGR_PROCESS_NAME,
|
|
468
|
+
"--restart", "always",
|
|
469
|
+
"--cwd", home,
|
|
470
|
+
...envArgs,
|
|
471
|
+
`${bunBin} ${rechScript} serve`,
|
|
472
|
+
]);
|
|
473
|
+
await runOxmgr(["service", "install"]);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function daemonUninstall(): Promise<void> {
|
|
477
|
+
await runOxmgr(["delete", OXMGR_PROCESS_NAME]);
|
|
478
|
+
await runOxmgr(["service", "uninstall"]);
|
|
479
|
+
console.log(`Removed oxmgr process: ${OXMGR_PROCESS_NAME}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
483
|
+
const { createInterface } = await import("readline");
|
|
484
|
+
const isTTY = process.stdin.isTTY ?? false;
|
|
485
|
+
const rl = isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
486
|
+
const ask = (q: string, def = "") => {
|
|
487
|
+
if (!rl) { process.stdout.write(`${q}${def}\n`); return Promise.resolve(def); }
|
|
488
|
+
return new Promise<string>(r => rl.question(q, r));
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// [1/4] Daemon
|
|
492
|
+
console.log("\n[1/4] Setting up serve daemon...");
|
|
493
|
+
// Clear stale hostname-based URL so we always use 127.0.0.1 locally
|
|
494
|
+
if (process.env[ENV_KEY]) {
|
|
495
|
+
try {
|
|
496
|
+
const u = new URL(process.env[ENV_KEY]);
|
|
497
|
+
if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
|
|
498
|
+
} catch {}
|
|
340
499
|
}
|
|
500
|
+
const url = await getOrCreateUrl();
|
|
341
501
|
const { host, port, protocol } = parseUrl(url);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
502
|
+
|
|
503
|
+
let ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
504
|
+
if (ping) {
|
|
505
|
+
console.log(` Already running at ${protocol}://${host}:${port}`);
|
|
506
|
+
await daemonInstall(url);
|
|
507
|
+
console.log(` Updated daemon: ${OXMGR_PROCESS_NAME}`);
|
|
508
|
+
} else {
|
|
509
|
+
await daemonInstall(url);
|
|
510
|
+
console.log(` Registered daemon: ${OXMGR_PROCESS_NAME}`);
|
|
511
|
+
process.stdout.write(" Starting");
|
|
512
|
+
for (let i = 0; i < 15; i++) {
|
|
513
|
+
await Bun.sleep(1000);
|
|
514
|
+
ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
515
|
+
if (ping) break;
|
|
516
|
+
process.stdout.write(".");
|
|
517
|
+
}
|
|
518
|
+
process.stdout.write("\n");
|
|
519
|
+
if (!ping) {
|
|
520
|
+
console.error(` Failed to start serve at ${host}:${port}`);
|
|
521
|
+
rl?.close();
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
console.log(` Serve running at ${protocol}://${host}:${port}`);
|
|
346
525
|
}
|
|
347
526
|
|
|
348
|
-
// 2. Interactive profile selection
|
|
349
527
|
const cache = await readChromeProfileCache();
|
|
350
|
-
if (!cache) { console.error("Chrome profiles not found"); process.exit(1); }
|
|
351
|
-
const
|
|
352
|
-
console.log("\nAvailable Chrome profiles:");
|
|
353
|
-
profiles.forEach(([dir, info], i) =>
|
|
354
|
-
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
355
|
-
);
|
|
356
|
-
const { createInterface } = await import("readline");
|
|
357
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
358
|
-
const answer = await new Promise<string>(r => rl.question("\nProfile number: ", r));
|
|
359
|
-
rl.close();
|
|
360
|
-
const idx = parseInt(answer.trim()) - 1;
|
|
361
|
-
if (isNaN(idx) || idx < 0 || idx >= profiles.length) { console.error("Invalid selection"); process.exit(1); }
|
|
362
|
-
const [profileDir, profileInfoSel] = profiles[idx];
|
|
363
|
-
const profileEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileDir };
|
|
364
|
-
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
528
|
+
if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
|
|
529
|
+
const userDataDir = await findChromeUserDataDir();
|
|
365
530
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (
|
|
531
|
+
async function pickProfile(exclude: Set<string>): Promise<[string, { user_name?: string; name?: string }] | null> {
|
|
532
|
+
const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
|
|
533
|
+
if (!available.length) return null;
|
|
534
|
+
available.forEach(([dir, info], i) =>
|
|
535
|
+
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
536
|
+
);
|
|
537
|
+
if (opts.profile !== undefined) {
|
|
538
|
+
const num = parseInt(opts.profile);
|
|
539
|
+
if (!isNaN(num)) return available[num - 1] ?? null;
|
|
540
|
+
return available.find(([, info]) =>
|
|
541
|
+
(info.user_name ?? "").toLowerCase().includes(opts.profile!.toLowerCase())
|
|
542
|
+
) ?? null;
|
|
543
|
+
}
|
|
544
|
+
if (!isTTY) {
|
|
545
|
+
console.log(" Non-TTY: auto-selecting first profile");
|
|
546
|
+
return available[0] ?? null;
|
|
547
|
+
}
|
|
548
|
+
const answer = await ask("\n Profile number: ");
|
|
549
|
+
const idx = parseInt(answer.trim()) - 1;
|
|
550
|
+
if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
|
|
551
|
+
return available[idx];
|
|
373
552
|
}
|
|
374
553
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
554
|
+
async function getExtAndToken(profileDir: string, profileDisplay: string): Promise<{ extId: string; token: string } | null> {
|
|
555
|
+
// Extension check
|
|
556
|
+
let extId: string | undefined;
|
|
557
|
+
while (true) {
|
|
558
|
+
const found = await findInstalledExtension(profileDir);
|
|
559
|
+
if (found) { extId = found.id; break; }
|
|
560
|
+
const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
|
|
561
|
+
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
562
|
+
await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
|
|
563
|
+
console.log(`\n Extension not found in profile: ${profileDisplay}`);
|
|
564
|
+
console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
|
|
565
|
+
console.log(`\n Opening install guide in your browser...`);
|
|
566
|
+
Bun.spawn(["open", setupHtmlPath], { stdout: "ignore", stderr: "ignore" });
|
|
567
|
+
await ask("\n Press Enter after loading the extension to retry...");
|
|
568
|
+
}
|
|
569
|
+
console.log(` Extension found: ${extId}`);
|
|
570
|
+
|
|
571
|
+
// Token
|
|
572
|
+
const statusUrl = `chrome-extension://${extId}/status.html`;
|
|
573
|
+
console.log(`\n Get auth token from the extension:`);
|
|
574
|
+
console.log(` ${statusUrl}`);
|
|
575
|
+
Bun.spawn(
|
|
576
|
+
["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
577
|
+
`--profile-directory=${profileDir}`, statusUrl],
|
|
578
|
+
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
579
|
+
);
|
|
580
|
+
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
581
|
+
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
582
|
+
const tokenInput = (await ask(" Paste token: ")).trim();
|
|
583
|
+
const token = tokenInput.replace(/^.*?=/, "").trim();
|
|
584
|
+
if (!token || token.length < 20) { console.error(" Invalid token (too short)"); return null; }
|
|
585
|
+
console.log(" Token accepted");
|
|
586
|
+
return { extId, token };
|
|
380
587
|
}
|
|
381
588
|
|
|
382
|
-
|
|
383
|
-
console.log(
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const evalResult = await callServe(url, ["eval", `() => localStorage.getItem('auth-token')`], profileEnv);
|
|
389
|
-
const tokenMatch = evalResult.stdout.match(/"([A-Za-z0-9_-]{20,})"/);
|
|
390
|
-
const token = tokenMatch?.[1];
|
|
391
|
-
if (!token) {
|
|
392
|
-
printInstallInstructions(profileDisplay);
|
|
393
|
-
console.error("Tried to read the auth token from the extension's status page but failed.");
|
|
394
|
-
console.error("This usually means the extension is not loaded in this profile.");
|
|
395
|
-
process.exit(1);
|
|
396
|
-
}
|
|
589
|
+
// [2/4] Primary profile
|
|
590
|
+
console.log("\n[2/4] Select Chrome profile:");
|
|
591
|
+
const picked = await pickProfile(new Set());
|
|
592
|
+
if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
|
|
593
|
+
const [profileDir, profileInfoSel] = picked;
|
|
594
|
+
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
397
595
|
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
const
|
|
596
|
+
// [3+4/4] Extension + token for primary profile
|
|
597
|
+
console.log("\n[3/4] Checking extension...");
|
|
598
|
+
const primary = await getExtAndToken(profileDir, profileDisplay);
|
|
599
|
+
if (!primary) { rl?.close(); process.exit(1); }
|
|
600
|
+
const { extId, token } = primary;
|
|
601
|
+
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
602
|
+
|
|
603
|
+
// Save RECHROME_URL
|
|
604
|
+
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
605
|
+
const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
|
|
606
|
+
const homeEnvPath = join(process.env.HOME!, ".env.local");
|
|
607
|
+
const saveChoice = (await ask(
|
|
608
|
+
`\n[4/4] Save RECHROME_URL to:\n 1. ${pwdEnvPath} (current dir) [default]\n 2. ${pwdRechPath} (current dir, rechrome-only)\n 3. ${homeEnvPath} (user home)\n\n Choice [1]: `
|
|
609
|
+
)).trim();
|
|
610
|
+
const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
|
|
611
|
+
if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
|
|
401
612
|
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
402
613
|
const rechUrl = new URL(url);
|
|
403
614
|
rechUrl.searchParams.set("extension_id", extId);
|
|
404
615
|
rechUrl.searchParams.set("token", token);
|
|
405
|
-
|
|
406
|
-
rechUrl.searchParams.set("profile", profileInfoSel.user_name || profileDir);
|
|
407
|
-
const userDataDir = await findChromeUserDataDir();
|
|
616
|
+
rechUrl.searchParams.set("profile", profileEmail);
|
|
408
617
|
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
409
618
|
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
410
|
-
// Remove old separate vars and update RECHROME_URL
|
|
411
619
|
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
412
620
|
let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
|
|
413
621
|
const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
|
|
414
622
|
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
415
623
|
else lines.push(newLine);
|
|
416
624
|
await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
|
|
417
|
-
console.log(`\nSaved to ${globalEnvPath}
|
|
418
|
-
console.log(
|
|
625
|
+
console.log(`\nSaved to ${globalEnvPath}`);
|
|
626
|
+
console.log(`\n ${newLine}`);
|
|
627
|
+
|
|
628
|
+
// Save primary to token registry
|
|
629
|
+
await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
|
|
630
|
+
|
|
631
|
+
// Additional profiles
|
|
632
|
+
const configured = new Set([profileDir]);
|
|
633
|
+
while (true) {
|
|
634
|
+
const more = (await ask("\nAdd another profile? [y/N]: ")).trim().toLowerCase();
|
|
635
|
+
if (more !== "y" && more !== "yes") break;
|
|
636
|
+
const remaining = Object.entries(cache!).filter(([dir]) => !configured.has(dir));
|
|
637
|
+
if (!remaining.length) { console.log(" No more profiles available."); break; }
|
|
638
|
+
console.log("\n Select additional profile:");
|
|
639
|
+
const extra = await pickProfile(configured);
|
|
640
|
+
if (!extra) { console.log(" Skipped."); continue; }
|
|
641
|
+
const [extraDir, extraInfo] = extra;
|
|
642
|
+
const extraDisplay = extraInfo.user_name || extraInfo.name || extraDir;
|
|
643
|
+
console.log(`\n Setting up: ${extraDisplay}`);
|
|
644
|
+
const result = await getExtAndToken(extraDir, extraDisplay);
|
|
645
|
+
if (!result) { console.log(" Skipped."); continue; }
|
|
646
|
+
const extraEmail = extraInfo.user_name || extraDir;
|
|
647
|
+
await saveTokenEntry(extraEmail, { extensionId: result.extId, token: result.token, profileDir: extraDir, userDataDir: userDataDir ?? undefined });
|
|
648
|
+
configured.add(extraDir);
|
|
649
|
+
console.log(` Saved token for ${extraDisplay}`);
|
|
650
|
+
}
|
|
651
|
+
rl?.close();
|
|
652
|
+
envWatcher?.close();
|
|
653
|
+
console.log(`\nDone! Test with:\n rech eval "() => document.title"`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function status(): Promise<void> {
|
|
657
|
+
const url = process.env[ENV_KEY];
|
|
658
|
+
if (!url) {
|
|
659
|
+
console.log(`serve: not configured (run \`rech setup\`)`);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const { host, port, protocol } = parseUrl(url);
|
|
663
|
+
const parsed = parseUrl(url);
|
|
664
|
+
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
665
|
+
// Check actual socket binding via lsof (shows * for 0.0.0.0, or exact IP for loopback-only)
|
|
666
|
+
const lsofProc = Bun.spawn(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], { stdout: "pipe", stderr: "ignore" });
|
|
667
|
+
const lsofOut = await new Response(lsofProc.stdout).text();
|
|
668
|
+
const listenLine = lsofOut.split("\n").find(l => l.includes(`:${port}`));
|
|
669
|
+
const listenAddr = listenLine?.match(/TCP\s+(\S+:\d+)/)?.[1] ?? (ping ? `${host}:${port}` : null);
|
|
670
|
+
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr ?? `${host}:${port}`}` : "not running"}`);
|
|
671
|
+
const oxmgrProc = Bun.spawn(["bunx", "oxmgr", "list"], { stdout: "pipe", stderr: "ignore" });
|
|
672
|
+
const oxmgrOut = await new Response(oxmgrProc.stdout).text();
|
|
673
|
+
const daemonRegistered = oxmgrOut.includes(OXMGR_PROCESS_NAME);
|
|
674
|
+
console.log(`daemon: ${daemonRegistered ? `oxmgr (${OXMGR_PROCESS_NAME})` : "not installed"}`);
|
|
675
|
+
const registry = await readTokenRegistry();
|
|
676
|
+
const entries = Object.entries(registry);
|
|
677
|
+
if (entries.length) {
|
|
678
|
+
console.log(`\nprofiles:`);
|
|
679
|
+
const primaryProfile = parsed.profileDirectory;
|
|
680
|
+
for (const [email, entry] of entries) {
|
|
681
|
+
const isPrimary = email === primaryProfile || entry.profileDir === primaryProfile;
|
|
682
|
+
const marker = isPrimary ? " (primary)" : "";
|
|
683
|
+
console.log(` ${email.padEnd(36)} [${entry.profileDir}] ext: ${entry.extensionId.slice(0, 8)}… token: ${entry.token.slice(0, 8)}…${marker}`);
|
|
684
|
+
}
|
|
685
|
+
} else if (parsed.profileDirectory) {
|
|
686
|
+
// Legacy: no registry yet, show from RECHROME_URL
|
|
687
|
+
const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
|
|
688
|
+
console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function printHelp(): void {
|
|
693
|
+
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
694
|
+
|
|
695
|
+
Usage:
|
|
696
|
+
rech setup First-time setup: daemon + Chrome extension + config
|
|
697
|
+
rech status Show current configuration and serve health
|
|
698
|
+
rech uninstall Remove the serve daemon and clear config
|
|
699
|
+
rech serve Start the serve server manually (foreground)
|
|
700
|
+
rech profiles List Chrome profiles
|
|
701
|
+
rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
|
|
702
|
+
|
|
703
|
+
Environment:
|
|
704
|
+
${ENV_KEY} Server URL set by \`rech setup\`
|
|
705
|
+
|
|
706
|
+
Examples:
|
|
707
|
+
rech setup
|
|
708
|
+
rech eval "() => document.title"
|
|
709
|
+
rech open https://example.com
|
|
710
|
+
rech screenshot`);
|
|
419
711
|
}
|
|
420
712
|
|
|
421
713
|
if (import.meta.main) {
|
|
422
714
|
const args = process.argv.slice(2);
|
|
715
|
+
const cmd = args[0]?.toLowerCase();
|
|
423
716
|
|
|
424
|
-
if (
|
|
717
|
+
if (cmd === "serve") {
|
|
425
718
|
const { serve } = await import("./serve.ts");
|
|
426
|
-
serve();
|
|
427
|
-
} else if (
|
|
719
|
+
serve(); // long-lived; watcher intentionally kept alive
|
|
720
|
+
} else if (cmd === "status") {
|
|
721
|
+
await status();
|
|
722
|
+
envWatcher?.close();
|
|
723
|
+
} else if (cmd === "profiles") {
|
|
428
724
|
await listProfiles();
|
|
429
|
-
|
|
430
|
-
|
|
725
|
+
envWatcher?.close();
|
|
726
|
+
} else if (cmd === "setup") {
|
|
727
|
+
const profileIdx = args.indexOf("--profile");
|
|
728
|
+
const profile = profileIdx !== -1
|
|
729
|
+
? args[profileIdx + 1]
|
|
730
|
+
: args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
|
|
731
|
+
await setup({ profile }); // setup closes envWatcher itself before printing Done
|
|
732
|
+
} else if (cmd === "uninstall") {
|
|
733
|
+
await daemonUninstall();
|
|
734
|
+
envWatcher?.close();
|
|
735
|
+
} else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
|
|
736
|
+
printHelp();
|
|
737
|
+
envWatcher?.close();
|
|
431
738
|
} else {
|
|
432
739
|
const url = process.env[ENV_KEY];
|
|
433
740
|
if (!url) {
|
|
434
|
-
console.error(
|
|
435
|
-
|
|
436
|
-
);
|
|
741
|
+
console.error(`${ENV_KEY} is not set. Run \`rech setup\` to configure.\n`);
|
|
742
|
+
printHelp();
|
|
437
743
|
process.exit(1);
|
|
438
744
|
}
|
|
439
|
-
run(url, args);
|
|
745
|
+
await run(url, args);
|
|
746
|
+
envWatcher?.close();
|
|
440
747
|
}
|
|
441
748
|
}
|