rechrome 1.24.1 → 1.26.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 +106 -12
- package/daemon-manager.js +71 -0
- package/daemon-manager.ts +99 -0
- package/extension/LICENSE +202 -0
- package/extension/MODIFICATIONS.md +19 -0
- package/extension/NOTICE +10 -0
- package/extension/THIRD_PARTY_NOTICES.txt +81 -0
- package/extension/lib/background.mjs +3 -0
- package/extension/lib/ui/authToken.js +3 -0
- package/extension/lib/ui/connect.js +3 -0
- package/extension/lib/ui/status.js +3 -0
- package/extension/manifest.json +1 -1
- package/extension-token.js +177 -0
- package/extension-token.ts +133 -0
- package/listeners.js +104 -0
- package/listeners.ts +84 -0
- package/package.json +19 -8
- package/{rech.js → rechrome.js} +1043 -458
- package/{rech.ts → rechrome.ts} +1043 -458
- package/serve.js +107 -89
- package/serve.ts +107 -89
- package/skills/rechrome-setup/SKILL.md +53 -0
package/{rech.js → rechrome.js}
RENAMED
|
@@ -1,45 +1,98 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, type Listener } from "./listeners.js";
|
|
2
3
|
|
|
3
4
|
import { file } from "bun";
|
|
5
|
+
import yargs from "yargs";
|
|
6
|
+
import { readExtensionTokenFromProfile } from "./extension-token.js";
|
|
4
7
|
import { randomBytes } from "crypto";
|
|
5
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
|
|
6
|
-
import { hostname, homedir } from "os";
|
|
8
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
|
|
9
|
+
import { hostname, homedir, networkInterfaces } from "os";
|
|
10
|
+
import { isIPv4 } from "net";
|
|
7
11
|
import { join, basename, dirname } from "path";
|
|
12
|
+
import { pathToFileURL } from "url";
|
|
8
13
|
import { spawn as cpSpawn } from "child_process";
|
|
14
|
+
import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
|
|
9
15
|
import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
|
|
10
16
|
|
|
11
17
|
export const ENV_KEY = "RECHROME_URL";
|
|
12
18
|
export const DEFAULT_PORT = 13775;
|
|
13
|
-
export const RECH_DIR = join(import.meta.dir, ".rech");
|
|
14
|
-
export const LOG_DIR = join(RECH_DIR, "logs");
|
|
15
|
-
|
|
16
19
|
// Home dir: HOME on POSIX, USERPROFILE on Windows (handled by os.homedir()).
|
|
17
20
|
export const HOME = homedir();
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
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");
|
|
21
29
|
|
|
22
30
|
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
function validateTokenRegistry(value: unknown): Record<string, TokenEntry> {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid profile registry: expected a mapping");
|
|
34
|
+
for (const entry of Object.values(value)) {
|
|
35
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry) ||
|
|
36
|
+
!["extensionId", "token", "profileDir"].every(k => typeof entry[k] === "string" && entry[k].length > 0) ||
|
|
37
|
+
!["userDataDir", "loadExtension"].every(k => entry[k] === undefined || typeof entry[k] === "string"))
|
|
38
|
+
throw new Error("Invalid profile registry entry");
|
|
39
|
+
}
|
|
40
|
+
return value as Record<string, TokenEntry>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_DIR): Promise<void> {
|
|
44
|
+
validateTokenRegistry(registry);
|
|
45
|
+
await mkdir(directory, { recursive: true });
|
|
46
|
+
const path = join(directory, "profiles.yaml");
|
|
47
|
+
const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
await writeFile(temporary, Bun.YAML.stringify(registry, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
50
|
+
await rename(temporary, path);
|
|
51
|
+
} finally {
|
|
52
|
+
try { unlinkSync(temporary); } catch {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readTokenRegistry(directory = RECH_DIR): Promise<Record<string, TokenEntry>> {
|
|
57
|
+
const path = join(directory, "profiles.yaml");
|
|
58
|
+
let raw: string;
|
|
59
|
+
try { raw = await readFile(path, "utf8"); }
|
|
60
|
+
catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
62
|
+
const legacyPath = join(directory, "profiles.json");
|
|
63
|
+
let legacy: string;
|
|
64
|
+
try { legacy = await readFile(legacyPath, "utf8"); }
|
|
65
|
+
catch (error) {
|
|
66
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
const registry = validateTokenRegistry(JSON.parse(legacy));
|
|
70
|
+
await writeTokenRegistry(registry, directory);
|
|
71
|
+
// Retain the original as a private migration backup; YAML is authoritative.
|
|
72
|
+
await chmod(legacyPath, 0o600);
|
|
73
|
+
return registry;
|
|
74
|
+
}
|
|
75
|
+
return validateTokenRegistry(Bun.YAML.parse(raw));
|
|
27
76
|
}
|
|
28
77
|
|
|
29
78
|
async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
|
|
30
|
-
mkdirSync(
|
|
79
|
+
mkdirSync(RECH_DIR, { recursive: true });
|
|
31
80
|
const registry = await readTokenRegistry();
|
|
32
81
|
registry[profileEmail] = entry;
|
|
33
|
-
await
|
|
82
|
+
await writeTokenRegistry(registry);
|
|
34
83
|
}
|
|
35
84
|
|
|
36
85
|
const envFile = join(import.meta.dir, ".env.local");
|
|
37
86
|
const globalEnvFile = join(HOME || "~", ".env.local");
|
|
38
87
|
|
|
39
|
-
//
|
|
88
|
+
// Capture inherited values once so explicit environment overrides survive reloads,
|
|
89
|
+
// while values loaded from files can still change when those files are edited.
|
|
90
|
+
const inheritedEnvKeys = new Set(Object.keys(process.env));
|
|
91
|
+
|
|
92
|
+
// Walk CWD→root loading env files nearest-first; inherited environment wins over files.
|
|
40
93
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
41
94
|
export async function loadNearestEnv(extraFallbacks: string[] = []) {
|
|
42
|
-
const seen = new Set<string>();
|
|
95
|
+
const seen = new Set<string>(inheritedEnvKeys);
|
|
43
96
|
const applyFile = async (path: string) => {
|
|
44
97
|
const raw = await file(path).text().catch(() => "");
|
|
45
98
|
for (const line of raw.split("\n")) {
|
|
@@ -69,13 +122,7 @@ export async function loadNearestEnv(extraFallbacks: string[] = []) {
|
|
|
69
122
|
async function loadEnv() {
|
|
70
123
|
await loadNearestEnv();
|
|
71
124
|
}
|
|
72
|
-
// Shell-set passthrough vars survive .env.local loading
|
|
73
|
-
const _shellPassthrough: Record<string, string> = {};
|
|
74
|
-
for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR","PLAYWRIGHT_MCP_LOAD_EXTENSION"] as const) {
|
|
75
|
-
if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
|
|
76
|
-
}
|
|
77
125
|
await loadEnv();
|
|
78
|
-
Object.assign(process.env, _shellPassthrough);
|
|
79
126
|
|
|
80
127
|
import { watch } from "node:fs";
|
|
81
128
|
const envWatcher = existsSync(envFile)
|
|
@@ -147,6 +194,46 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
|
|
|
147
194
|
}
|
|
148
195
|
}
|
|
149
196
|
|
|
197
|
+
export async function openSetupGuide(profileDir: string, setupHtmlPath: string): Promise<boolean> {
|
|
198
|
+
// A unique URL identifies the window opened in the requested profile, even if
|
|
199
|
+
// other profiles have an older copy of the setup guide open.
|
|
200
|
+
const guideUrl = /^https?:\/\//.test(setupHtmlPath) ? new URL(setupHtmlPath) : pathToFileURL(setupHtmlPath);
|
|
201
|
+
guideUrl.hash = `setup-${randomBytes(6).toString("hex")}`;
|
|
202
|
+
if (!openInChromeProfile(profileDir, guideUrl.toString())) return false;
|
|
203
|
+
if (process.platform !== "darwin") {
|
|
204
|
+
openInChromeProfile(profileDir, "chrome://extensions/");
|
|
205
|
+
return openInChromeProfile(profileDir, guideUrl.toString());
|
|
206
|
+
}
|
|
207
|
+
// Chrome may discard chrome:// URLs passed on its command line. AppleScript
|
|
208
|
+
// can create an internal-page tab in the specific window containing our guide.
|
|
209
|
+
const script = `on run argv
|
|
210
|
+
repeat 30 times
|
|
211
|
+
tell application "Google Chrome"
|
|
212
|
+
repeat with w in windows
|
|
213
|
+
repeat with tabNumber from 1 to count of tabs of w
|
|
214
|
+
set t to tab tabNumber of w
|
|
215
|
+
if URL of t is item 1 of argv then
|
|
216
|
+
make new tab at end of tabs of w with properties {URL:"chrome://extensions/"}
|
|
217
|
+
make new tab at end of tabs of w with properties {URL:item 1 of argv}
|
|
218
|
+
close t
|
|
219
|
+
set active tab index of w to count of tabs of w
|
|
220
|
+
set index of w to 1
|
|
221
|
+
return "opened"
|
|
222
|
+
end if
|
|
223
|
+
end repeat
|
|
224
|
+
end repeat
|
|
225
|
+
end tell
|
|
226
|
+
delay 0.1
|
|
227
|
+
end repeat
|
|
228
|
+
return "guide not found"
|
|
229
|
+
end run`;
|
|
230
|
+
try {
|
|
231
|
+
const proc = Bun.spawn(["osascript", "-e", script, guideUrl.toString()], { stdout: "pipe", stderr: "ignore" });
|
|
232
|
+
const output = await new Response(proc.stdout).text();
|
|
233
|
+
return await proc.exited === 0 && output.trim() === "opened";
|
|
234
|
+
} catch { return false; }
|
|
235
|
+
}
|
|
236
|
+
|
|
150
237
|
export function log(msg: string) {
|
|
151
238
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
152
239
|
const ts = new Date().toISOString();
|
|
@@ -156,27 +243,184 @@ export function log(msg: string) {
|
|
|
156
243
|
appendFileSync(logFile, line);
|
|
157
244
|
}
|
|
158
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
|
+
|
|
159
271
|
export function parseUrl(raw: string) {
|
|
160
272
|
const u = new URL(raw);
|
|
273
|
+
const fragment = new URLSearchParams(u.hash.slice(1).replace(/^\?/, ""));
|
|
274
|
+
const param = (name: string) => fragment.get(name) ?? u.searchParams.get(name) ?? undefined;
|
|
161
275
|
const scheme = u.protocol.replace(":", "");
|
|
162
276
|
const protocol = scheme === "https" ? "https" : "http";
|
|
163
277
|
const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
|
|
164
278
|
return {
|
|
165
|
-
key: u.username,
|
|
279
|
+
key: fragment.get("key") ?? u.username,
|
|
280
|
+
prefix: normalizePrefix(u.pathname),
|
|
166
281
|
host: u.hostname,
|
|
167
282
|
port: parseInt(u.port) || defaultPort,
|
|
168
283
|
protocol,
|
|
169
|
-
extensionId:
|
|
170
|
-
extensionToken:
|
|
171
|
-
profileDirectory:
|
|
172
|
-
userDataDir:
|
|
173
|
-
loadExtension:
|
|
284
|
+
extensionId: param("extension_id"),
|
|
285
|
+
extensionToken: param("token"),
|
|
286
|
+
profileDirectory: param("profile"),
|
|
287
|
+
userDataDir: param("user_data_dir"),
|
|
288
|
+
loadExtension: param("load_extension"),
|
|
174
289
|
};
|
|
175
290
|
}
|
|
176
291
|
|
|
292
|
+
// URLs for registered profiles need only the endpoint, profile selector and daemon
|
|
293
|
+
// key. Bridge credentials and local browser paths stay in the server registry.
|
|
294
|
+
export function registeredProfileUrl(raw: string): string {
|
|
295
|
+
const parsed = parseUrl(raw);
|
|
296
|
+
const url = new URL(serviceUrl(raw));
|
|
297
|
+
if (parsed.profileDirectory) url.searchParams.set("profile", parsed.profileDirectory);
|
|
298
|
+
url.hash = new URLSearchParams({ key: parsed.key }).toString();
|
|
299
|
+
return url.toString();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Setup edits parameters while probing before registration. Fold fragments into
|
|
303
|
+
// the legacy fields first so an old fragment cannot override the new selection.
|
|
304
|
+
function editableConnectionUrl(raw: string): URL {
|
|
305
|
+
const url = new URL(raw);
|
|
306
|
+
const parsed = parseUrl(raw);
|
|
307
|
+
url.username = parsed.key;
|
|
308
|
+
for (const [name, value] of Object.entries({ extension_id: parsed.extensionId, token: parsed.extensionToken, profile: parsed.profileDirectory, user_data_dir: parsed.userDataDir, load_extension: parsed.loadExtension })) {
|
|
309
|
+
if (value !== undefined) url.searchParams.set(name, value);
|
|
310
|
+
}
|
|
311
|
+
url.hash = "";
|
|
312
|
+
return url;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type ListenChoice = { kind: "local" | "lan" | "tailscale" | "other"; address: string; label: string };
|
|
316
|
+
|
|
317
|
+
export function buildListenChoices(interfaces: ReturnType<typeof networkInterfaces>, tailscaleIPs: string[] = []): ListenChoice[] {
|
|
318
|
+
const choices: ListenChoice[] = [{ kind: "local", address: "127.0.0.1", label: "Local — this computer only" }];
|
|
319
|
+
const seen = new Set(["127.0.0.1"]);
|
|
320
|
+
for (const [name, entries] of Object.entries(interfaces).sort(([a], [b]) => a.localeCompare(b))) {
|
|
321
|
+
for (const entry of entries ?? []) {
|
|
322
|
+
const address = entry.address;
|
|
323
|
+
if (entry.internal || !isIPv4(address) || address.startsWith("169.254.") || seen.has(address)) continue;
|
|
324
|
+
seen.add(address);
|
|
325
|
+
const tunnel = /^(utun|tun|tap|wg|zt|tailscale)|wireguard|zerotier|vpn/i.test(name);
|
|
326
|
+
if (tailscaleIPs.includes(address) || /^tailscale/i.test(name)) {
|
|
327
|
+
choices.push({ kind: "tailscale", address, label: `Tailscale — ${name}` });
|
|
328
|
+
} else if (tunnel) {
|
|
329
|
+
const provider = /^wg|wireguard/i.test(name) ? "WireGuard" : /^zt|zerotier/i.test(name) ? "ZeroTier" : "VPN / tunnel";
|
|
330
|
+
choices.push({ kind: "other", address, label: `${provider} — ${name}` });
|
|
331
|
+
} else {
|
|
332
|
+
const local = /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(address);
|
|
333
|
+
const virtual = /^(docker|veth|virbr|br-|bridge|vmnet|vbox)/i.test(name);
|
|
334
|
+
choices.push({ kind: local && !virtual ? "lan" : "other", address, label: `${virtual ? "Virtual network" : local ? "LAN" : "Network interface"} — ${name}` });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const rank = { local: 0, lan: 1, tailscale: 2, other: 3 };
|
|
339
|
+
return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
|
|
343
|
+
|
|
344
|
+
/** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
|
|
345
|
+
async function runTailscale(args: string[]): Promise<string | null> {
|
|
346
|
+
const binary = tailscaleBinary();
|
|
347
|
+
if (!binary) return null;
|
|
348
|
+
try {
|
|
349
|
+
const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
350
|
+
const timeout = setTimeout(() => proc.kill(), 2000);
|
|
351
|
+
try {
|
|
352
|
+
const output = await new Response(proc.stdout).text();
|
|
353
|
+
return await proc.exited === 0 ? output : null;
|
|
354
|
+
} finally { clearTimeout(timeout); }
|
|
355
|
+
} catch { return null; }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
359
|
+
const output = await runTailscale(["ip", "-4"]);
|
|
360
|
+
const ips = output ? output.trim().split(/\s+/).filter(isIPv4) : [];
|
|
361
|
+
return buildListenChoices(networkInterfaces(), ips);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type TailscaleServe = { dnsName: string | null; routeUrl: string | null };
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
|
|
368
|
+
* keeping the prefix (a route that strips it would 404 on the prefixed listener).
|
|
369
|
+
* Prefers this node's own DNS name when several hostnames carry the same route.
|
|
370
|
+
*/
|
|
371
|
+
export function findTailscaleServeRoute(dnsName: string | null, serveStatus: unknown, port: number, prefix: string): string | null {
|
|
372
|
+
const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
|
|
373
|
+
const mount = prefix.replace(/\/+$/, "");
|
|
374
|
+
const matches: string[] = [];
|
|
375
|
+
for (const [hostPort, config] of Object.entries(web)) {
|
|
376
|
+
for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
|
|
377
|
+
if (path.replace(/\/+$/, "") !== mount || !handler.Proxy) continue;
|
|
378
|
+
let target: URL;
|
|
379
|
+
try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
|
|
380
|
+
if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || Number(target.port) !== port) continue;
|
|
381
|
+
if (target.pathname.replace(/\/+$/, "") !== mount) continue;
|
|
382
|
+
const [host, httpsPort] = hostPort.split(/:(?=\d+$)/);
|
|
383
|
+
matches.push(`https://${host}${httpsPort && httpsPort !== "443" ? `:${httpsPort}` : ""}${prefix}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return matches.find(url => dnsName && new URL(url).hostname === dnsName) ?? matches[0] ?? null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function detectTailscaleServe(port: number, prefix: string): Promise<TailscaleServe> {
|
|
390
|
+
const [status, serve] = await Promise.all([runTailscale(["status", "--json"]), runTailscale(["serve", "status", "--json"])]);
|
|
391
|
+
let dnsName: string | null = null;
|
|
392
|
+
let serveStatus: unknown = null;
|
|
393
|
+
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
394
|
+
try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
|
|
395
|
+
return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Remote connection URL for a Serve route, carrying the listener key and profile like the local one. */
|
|
399
|
+
export function tailscaleConnectionUrl(routeUrl: string, localUrl: string): string {
|
|
400
|
+
const local = new URL(localUrl);
|
|
401
|
+
const remote = new URL(routeUrl);
|
|
402
|
+
remote.username = parseUrl(localUrl).key;
|
|
403
|
+
remote.search = local.search;
|
|
404
|
+
return registeredProfileUrl(remote.toString());
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function chooseListenAddress(choices: ListenChoice[], selector: string): string {
|
|
408
|
+
const matches = choices.filter(choice => choice.address === selector || choice.kind === selector.toLowerCase());
|
|
409
|
+
if (matches.length === 1) return matches[0].address;
|
|
410
|
+
if (matches.length > 1) throw new Error(`Multiple ${selector} addresses detected. Use --listen with one of: ${matches.map(c => c.address).join(", ")}`);
|
|
411
|
+
throw new Error(`Listen network "${selector}" was not detected. Choose local or a detected interface address.`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function listenUrl(rawUrl: string, address: string): string {
|
|
415
|
+
const url = new URL(rawUrl);
|
|
416
|
+
const connectAddress = address === "0.0.0.0" ? "127.0.0.1" : address === "::" ? "[::1]" : address;
|
|
417
|
+
url.hostname = connectAddress.includes(":") && !connectAddress.startsWith("[") ? `[${connectAddress}]` : connectAddress;
|
|
418
|
+
return url.toString();
|
|
419
|
+
}
|
|
420
|
+
|
|
177
421
|
export async function getOrCreateUrl(persist = true): Promise<string> {
|
|
178
422
|
// Treat a URL without a bearer key as missing — it cannot authenticate
|
|
179
|
-
try { if (process.env[ENV_KEY] &&
|
|
423
|
+
try { if (process.env[ENV_KEY] && parseUrl(process.env[ENV_KEY]!).key) return process.env[ENV_KEY]!; } catch {}
|
|
180
424
|
const key = randomBytes(12).toString("base64url"); // 16 chars
|
|
181
425
|
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
182
426
|
if (persist) {
|
|
@@ -265,7 +509,15 @@ export function deriveIdentity(opts: {
|
|
|
265
509
|
return { key, label };
|
|
266
510
|
}
|
|
267
511
|
|
|
268
|
-
|
|
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 }> {
|
|
269
521
|
const cwd = realpathSafe(process.cwd());
|
|
270
522
|
const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
|
|
271
523
|
let root: string | null = null;
|
|
@@ -291,7 +543,7 @@ async function getClientIdentity(): Promise<{ key: string; label: string; profil
|
|
|
291
543
|
if (remoteUrl) remote = normalizeRemote(remoteUrl);
|
|
292
544
|
}
|
|
293
545
|
|
|
294
|
-
return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
|
|
546
|
+
return { ...deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch }), dataDir: projectDataDir({ mode, cwd, root }) };
|
|
295
547
|
}
|
|
296
548
|
|
|
297
549
|
// Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
|
|
@@ -332,6 +584,22 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
|
|
|
332
584
|
if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
|
|
333
585
|
if (!env["PLAYWRIGHT_MCP_USER_DATA_DIR"] && entry.userDataDir) env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = entry.userDataDir;
|
|
334
586
|
if (!env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] && entry.loadExtension) env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = entry.loadExtension;
|
|
587
|
+
// The extension mints a fresh `auth-token` whenever its localStorage is cleared
|
|
588
|
+
// (reinstall, "Load unpacked" from a new path, site-data wipe). A registry token
|
|
589
|
+
// that predates that is silently rejected by connect.html ("Invalid token"), which
|
|
590
|
+
// surfaces only as an extension connection timeout. Re-read the live value from the
|
|
591
|
+
// profile and heal the registry instead of failing. Managed (provisioned) profiles are
|
|
592
|
+
// seeded over CDP, but their localStorage can be re-minted the same way, and they use the
|
|
593
|
+
// same <userDataDir>/<profileDir>/Local Storage layout — so they heal here too.
|
|
594
|
+
if (entry.userDataDir) {
|
|
595
|
+
const profileDir = entry.profileDir; // the folder name ("Profile 2"), not the email key
|
|
596
|
+
const live = profileDir ? readExtensionTokenFromProfile(entry.userDataDir, profileDir, entry.extensionId) : null;
|
|
597
|
+
if (live && live !== entry.token) {
|
|
598
|
+
console.error(`[rech] extension token for "${profileKey}" changed — refreshing registry`);
|
|
599
|
+
entry.token = live;
|
|
600
|
+
await saveTokenEntry(profileKey, entry);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
335
603
|
if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
|
|
336
604
|
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
|
|
337
605
|
} else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
|
|
@@ -385,7 +653,7 @@ export function resolveChromeProfileSelector(
|
|
|
385
653
|
if (matches.length > 1) {
|
|
386
654
|
throw new Error(
|
|
387
655
|
`--profile "${value}" matches multiple profiles by ${kind.label}. ` +
|
|
388
|
-
`Use a unique email or profile folder name from \`rech
|
|
656
|
+
`Use a unique email or profile folder name from \`rech profile\`.`,
|
|
389
657
|
);
|
|
390
658
|
}
|
|
391
659
|
if (matches.length === 1) return matches[0];
|
|
@@ -398,7 +666,7 @@ export function validateChromeProfileSelector(selector: string): void {
|
|
|
398
666
|
if (!/^\d+$/.test(value)) return;
|
|
399
667
|
throw new Error(
|
|
400
668
|
`--profile no longer accepts menu numbers (received "${value}"). ` +
|
|
401
|
-
`Use the profile email, Chrome profile name, or profile folder name from \`rech
|
|
669
|
+
`Use the profile email, Chrome profile name, or profile folder name from \`rech profile\`.`,
|
|
402
670
|
);
|
|
403
671
|
}
|
|
404
672
|
|
|
@@ -431,12 +699,12 @@ export async function resolveGlobalProfile(
|
|
|
431
699
|
if (!match) {
|
|
432
700
|
throw new Error(
|
|
433
701
|
`--profile "${value}" does not match any Chrome profile. ` +
|
|
434
|
-
`See available profiles with \`rech
|
|
702
|
+
`See available profiles with \`rech profile\`.`,
|
|
435
703
|
);
|
|
436
704
|
}
|
|
437
705
|
|
|
438
706
|
const [dir, info] = match;
|
|
439
|
-
const email = info.user_name;
|
|
707
|
+
const email = info.user_name || (registry[dir]?.profileDir === dir && !registry[dir].loadExtension ? dir : undefined);
|
|
440
708
|
if (!email) {
|
|
441
709
|
throw new Error(
|
|
442
710
|
`Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
|
|
@@ -559,34 +827,93 @@ async function resolveProfileEmail(dir: string): Promise<string> {
|
|
|
559
827
|
return dir;
|
|
560
828
|
}
|
|
561
829
|
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
830
|
+
export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null, registry: Record<string, TokenEntry>, chromeRoot: string | null) {
|
|
831
|
+
const entries = Object.entries(registry);
|
|
832
|
+
const used = new Set<string>();
|
|
833
|
+
const rows = Object.entries(cache ?? {}).map(([dir, info]) => {
|
|
834
|
+
const registrations = entries.filter(([, e]) => e.profileDir === dir && !e.loadExtension && (!e.userDataDir || e.userDataDir === chromeRoot));
|
|
835
|
+
registrations.forEach(([key]) => used.add(key));
|
|
836
|
+
return { selector: registrations[0]?.[0] || info.user_name || dir, name: info.name || "", email: info.user_name || "", dir,
|
|
837
|
+
kind: "Chrome", registered: registrations.length > 0 };
|
|
838
|
+
});
|
|
839
|
+
for (const [key, entry] of entries) {
|
|
840
|
+
if (used.has(key)) continue;
|
|
841
|
+
rows.push({ selector: key, name: key, email: "", dir: entry.profileDir,
|
|
842
|
+
kind: entry.loadExtension ? "Managed test" : "Registered", registered: true });
|
|
573
843
|
}
|
|
844
|
+
return rows;
|
|
845
|
+
}
|
|
574
846
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
847
|
+
async function listProfiles(): Promise<void> {
|
|
848
|
+
const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
|
|
849
|
+
const profiles = buildProfileRows(cache, registry, root);
|
|
850
|
+
const listeners = await readListeners();
|
|
851
|
+
const url = process.env[ENV_KEY];
|
|
852
|
+
const current = url ? resolveEffectiveProfile(parseUrl(url).profileDirectory) : process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
853
|
+
const identity = await getClientIdentity();
|
|
854
|
+
const states = await Promise.all(profiles.map(async p => {
|
|
855
|
+
if (!p.registered) return "Not set up";
|
|
856
|
+
if (!url) return "Registered / unknown";
|
|
857
|
+
try {
|
|
858
|
+
const { key, protocol, host, port } = parseUrl(url);
|
|
859
|
+
// tab-list only inspects this worktree's existing session; never opens Chrome.
|
|
860
|
+
const response = await fetch(serviceUrl(url, "run"), {
|
|
861
|
+
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
862
|
+
body: JSON.stringify({ args: ["tab-list"], identity: { ...identity, profile: p.selector }, env: {} }),
|
|
863
|
+
signal: AbortSignal.timeout(5000),
|
|
864
|
+
});
|
|
865
|
+
if (!response.ok) return "Registered / unknown";
|
|
866
|
+
const result = await response.json() as { status: number; stdout: string; stderr: string };
|
|
867
|
+
if (result.status === 0 && !/### Error|not open|not connected/i.test(result.stdout + result.stderr)) return "Connected";
|
|
868
|
+
if (/not open|not connected/i.test(result.stdout + result.stderr)) return "Registered / idle";
|
|
869
|
+
return "Registered / unknown";
|
|
870
|
+
} catch { return "Registered / unknown"; }
|
|
871
|
+
}));
|
|
872
|
+
const rows = [["TYPE", "EMAIL / SELECTOR", "PROFILE NAME", "FOLDER", "CONNECTION", "ACCESS", ""], ...profiles.map((p, i) => [
|
|
873
|
+
p.kind, p.selector, p.name, p.dir, states[i],
|
|
874
|
+
listeners ? listeners.listeners.filter(l => l.profiles === "*" || l.profiles.includes(p.selector)).map(l => (listenerAddress(l) + normalizePrefix(l.prefix))).join(", ") || "none" : "legacy daemon",
|
|
875
|
+
current && [p.selector, p.dir, p.name, p.email].some(v => v.toLowerCase() === current.toLowerCase()) ? "← current" : "",
|
|
876
|
+
])];
|
|
586
877
|
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
587
|
-
for (const row of rows)
|
|
588
|
-
|
|
878
|
+
for (const row of rows) console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
879
|
+
console.log("\nConnection checks this worktree's default session; other sessions may also be open.");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export function profileConnectionUri(profile: string, configuredUrl: string | undefined, listeners: Listener[], listenerName?: string): string {
|
|
883
|
+
let url = configuredUrl;
|
|
884
|
+
if (listenerName || !url) {
|
|
885
|
+
const candidates = listeners.filter(l => (l.profiles === "*" || l.profiles.includes(profile)) && (!listenerName || l.name === listenerName));
|
|
886
|
+
if (candidates.length !== 1) throw new Error("Choose a listener with --listener <name>, or set RECHROME_URL to the desired endpoint.");
|
|
887
|
+
const listener = candidates[0];
|
|
888
|
+
url = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
|
|
589
889
|
}
|
|
890
|
+
const parsed = parseUrl(url);
|
|
891
|
+
if (!parsed.key) throw new Error("The connection URL has no daemon key. Run rech setup or select --listener <name>.");
|
|
892
|
+
const listener = listeners.find(l => l.key === parsed.key);
|
|
893
|
+
if (listener && listener.profiles !== "*" && !listener.profiles.includes(profile)) throw new Error(`This listener does not allow profile "${profile}". Choose a different --listener.`);
|
|
894
|
+
const result = new URL(serviceUrl(url));
|
|
895
|
+
result.searchParams.set("profile", profile);
|
|
896
|
+
result.hash = new URLSearchParams({ key: parsed.key }).toString();
|
|
897
|
+
return result.toString();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function printProfileUri(selector?: string, listener?: string): Promise<void> {
|
|
901
|
+
const url = process.env[ENV_KEY];
|
|
902
|
+
selector ??= url ? parseUrl(url).profileDirectory : undefined;
|
|
903
|
+
if (!selector) throw new Error("Specify a profile: rech profile <name> --print-uri");
|
|
904
|
+
const registry = await readTokenRegistry();
|
|
905
|
+
const cache = await readChromeProfileCache();
|
|
906
|
+
// A configured remote profile may not exist in this machine's local registry.
|
|
907
|
+
const profile = url && parseUrl(url).profileDirectory === selector && !listener
|
|
908
|
+
? selector : (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
909
|
+
console.log(profileConnectionUri(profile, url, (await readListeners())?.listeners ?? [], listener));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
|
|
913
|
+
if (env.CODEX_SANDBOX_NETWORK_DISABLED !== "1" && !env.CODEX_SANDBOX) return null;
|
|
914
|
+
return "[rech] warning: running inside a sandbox; network restrictions may block access to the rechrome daemon, even on localhost. " +
|
|
915
|
+
"Retry this command outside the sandbox (with approval in your coding agent). " +
|
|
916
|
+
"If it still fails, check that the daemon is running and the host/port are correct.";
|
|
590
917
|
}
|
|
591
918
|
|
|
592
919
|
async function callServe(
|
|
@@ -594,6 +921,7 @@ async function callServe(
|
|
|
594
921
|
args: string[],
|
|
595
922
|
overrideEnv?: Record<string, string>,
|
|
596
923
|
precomputedIdentity?: { key: string; label: string; profile?: string },
|
|
924
|
+
throwOnFailure = false,
|
|
597
925
|
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
598
926
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
599
927
|
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
@@ -605,14 +933,18 @@ async function callServe(
|
|
|
605
933
|
// reuse the default profile's session (and its browser) instead of opening its own.
|
|
606
934
|
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
607
935
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
608
|
-
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
609
|
-
const res = await fetch(
|
|
936
|
+
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv };
|
|
937
|
+
const res = await fetch(serviceUrl(url, "run"), {
|
|
610
938
|
method: "POST",
|
|
611
939
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
612
|
-
|
|
940
|
+
// dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
|
|
941
|
+
body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env }),
|
|
613
942
|
signal: AbortSignal.timeout(70_000),
|
|
614
943
|
}).catch(async (e) => {
|
|
944
|
+
if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
|
|
615
945
|
console.error(`[rech] ${e.message}`);
|
|
946
|
+
const sandboxWarning = sandboxConnectionWarning();
|
|
947
|
+
if (sandboxWarning) console.error(sandboxWarning);
|
|
616
948
|
const dnsResult = await import("dns/promises").then(m => m.lookup(host)).catch(() => null);
|
|
617
949
|
if (!dnsResult) {
|
|
618
950
|
console.error(`[rech] rech-client\n -x: DNS failed -> ${host}[unknown] -> rech-server[unknown]`);
|
|
@@ -634,6 +966,7 @@ async function callServe(
|
|
|
634
966
|
process.exit(1);
|
|
635
967
|
});
|
|
636
968
|
if (res.status === 401) {
|
|
969
|
+
if (throwOnFailure) throw new Error("The daemon rejected its connection key. Run setup again to refresh it.");
|
|
637
970
|
console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
|
|
638
971
|
process.exit(1);
|
|
639
972
|
}
|
|
@@ -690,7 +1023,7 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
690
1023
|
`[rech] connecting to ${host}:${port} (identity: ${identity.label}${profileSuffix})`,
|
|
691
1024
|
);
|
|
692
1025
|
|
|
693
|
-
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
1026
|
+
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension });
|
|
694
1027
|
const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
|
|
695
1028
|
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
|
|
696
1029
|
|
|
@@ -721,12 +1054,14 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
721
1054
|
if (stdout) process.stdout.write(stdout);
|
|
722
1055
|
|
|
723
1056
|
if (files?.length) {
|
|
724
|
-
|
|
1057
|
+
// Saved files belong to the project: <project>/.rechrome/output. The folder also holds
|
|
1058
|
+
// .env.local (secrets), so it is git-ignored as a whole when first created.
|
|
1059
|
+
const dlDir = join(identity.dataDir, "output");
|
|
725
1060
|
mkdirSync(dlDir, { recursive: true });
|
|
726
|
-
const gitignorePath = join(
|
|
1061
|
+
const gitignorePath = join(identity.dataDir, ".gitignore");
|
|
727
1062
|
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
728
1063
|
for (const name of files) {
|
|
729
|
-
const fileRes = await fetch(
|
|
1064
|
+
const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
|
|
730
1065
|
headers: { Authorization: `Bearer ${parseUrl(url).key}` },
|
|
731
1066
|
});
|
|
732
1067
|
if (!fileRes.ok) continue;
|
|
@@ -739,60 +1074,214 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
739
1074
|
process.exit(status);
|
|
740
1075
|
}
|
|
741
1076
|
|
|
742
|
-
export
|
|
1077
|
+
export type SetupPhase = "extension" | "token" | "bridge" | "error" | "save" | "ready";
|
|
1078
|
+
|
|
1079
|
+
export function createSetupGuide(extDistDir: string, profileDisplay: string) {
|
|
1080
|
+
const route = `/setup/${randomBytes(24).toString("hex")}`;
|
|
1081
|
+
let state: { phase: SetupPhase; message: string; statusUrl?: string } = { phase: "extension", message: "Waiting for the extension in this profile…" };
|
|
1082
|
+
let manualToken: string | undefined;
|
|
1083
|
+
let retry = false;
|
|
1084
|
+
let readyDelivered = false;
|
|
1085
|
+
let checks = { extension: false, token: false, bridge: false, registration: false };
|
|
1086
|
+
const server = Bun.serve({
|
|
1087
|
+
hostname: "127.0.0.1", port: 0, maxRequestBodySize: 4096,
|
|
1088
|
+
async fetch(request) {
|
|
1089
|
+
const url = new URL(request.url);
|
|
1090
|
+
const headers = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
|
|
1091
|
+
if (url.host !== `127.0.0.1:${server.port}`) return new Response("Invalid host", { status: 403 });
|
|
1092
|
+
if (url.pathname === route && request.method === "GET")
|
|
1093
|
+
return new Response(buildSetupHtml(extDistDir, profileDisplay, `${route}/status`), { headers: { ...headers, "Content-Type": "text/html; charset=utf-8" } });
|
|
1094
|
+
if (url.pathname !== `${route}/status`) return new Response("Not found", { status: 404 });
|
|
1095
|
+
if (request.method === "POST") {
|
|
1096
|
+
if (request.headers.get("Origin") !== url.origin) return new Response("Invalid origin", { status: 403 });
|
|
1097
|
+
if (Number(request.headers.get("Content-Length")) > 4096) return new Response("Too large", { status: 413 });
|
|
1098
|
+
try {
|
|
1099
|
+
const body = await request.json() as { token?: string; retry?: boolean };
|
|
1100
|
+
if (body.token !== undefined) {
|
|
1101
|
+
const value = body.token.replace(/^PLAYWRIGHT_MCP_EXTENSION_TOKEN=/, "").trim();
|
|
1102
|
+
if (!/^[A-Za-z0-9_-]{20,256}$/.test(value)) return new Response("Invalid token", { status: 400 });
|
|
1103
|
+
manualToken = value;
|
|
1104
|
+
}
|
|
1105
|
+
if (body.retry || body.token) retry = true;
|
|
1106
|
+
} catch { return new Response("Invalid request", { status: 400 }); }
|
|
1107
|
+
} else if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
|
|
1108
|
+
if (state.phase === "ready") readyDelivered = true;
|
|
1109
|
+
return Response.json({ ...state, checks }, { headers });
|
|
1110
|
+
},
|
|
1111
|
+
});
|
|
1112
|
+
return {
|
|
1113
|
+
url: `http://127.0.0.1:${server.port}${route}`,
|
|
1114
|
+
update(phase: SetupPhase, message: string) {
|
|
1115
|
+
state = { ...state, phase, message };
|
|
1116
|
+
if (phase !== "error") {
|
|
1117
|
+
const rank = ["extension", "token", "bridge", "save", "ready"].indexOf(phase);
|
|
1118
|
+
checks = { extension: rank >= 1, token: rank >= 2, bridge: rank >= 3, registration: rank >= 4 };
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
setExtensionId(id: string) {
|
|
1122
|
+
if (!/^[a-p]{32}$/.test(id)) throw new Error("Invalid extension ID");
|
|
1123
|
+
state.statusUrl = `chrome-extension://${id}/status.html`;
|
|
1124
|
+
},
|
|
1125
|
+
takeToken() { const value = manualToken; manualToken = undefined; return value; },
|
|
1126
|
+
markRegistered() { checks.registration = true; },
|
|
1127
|
+
takeRetry() { const value = retry; retry = false; return value; },
|
|
1128
|
+
async deliverSuccess() {
|
|
1129
|
+
const deadline = Date.now() + 10_000;
|
|
1130
|
+
while (!readyDelivered && Date.now() < deadline) await Bun.sleep(100);
|
|
1131
|
+
await Bun.sleep(250);
|
|
1132
|
+
},
|
|
1133
|
+
close() { manualToken = undefined; server.stop(); },
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
export function buildSetupHtml(extDistDir: string, profileDisplay: string, checkUrl?: string): string {
|
|
1138
|
+
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({
|
|
1139
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
1140
|
+
})[char]!);
|
|
743
1141
|
return `<!DOCTYPE html>
|
|
744
1142
|
<html lang="en">
|
|
745
1143
|
<head>
|
|
746
1144
|
<meta charset="UTF-8">
|
|
1145
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
747
1146
|
<title>rechrome — Extension Setup</title>
|
|
748
1147
|
<style>
|
|
749
|
-
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; }
|
|
750
|
-
h1 { color: #1a73e8; }
|
|
751
|
-
.step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding:
|
|
752
|
-
.step
|
|
1148
|
+
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; line-height: 1.6; }
|
|
1149
|
+
h1, a { color: #1a73e8; }
|
|
1150
|
+
.step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding: 16px; margin: 16px 0; border-radius: 0 8px 8px 0; }
|
|
1151
|
+
.step h2 { margin: 0 0 8px; font-size: 1.2rem; }
|
|
753
1152
|
code { background: #e8eaed; padding: 2px 6px; border-radius: 4px; font-size: 0.95em; word-break: break-all; }
|
|
754
|
-
.path { display: flex; align-items: center; gap:
|
|
755
|
-
button { background: #1a73e8; color: white; border: none; padding:
|
|
756
|
-
button:active { background: #1558b0; }
|
|
1153
|
+
.path { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
|
1154
|
+
button, .button { display: inline-block; background: #1a73e8; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font: inherit; text-decoration: none; }
|
|
1155
|
+
button:active, .button:active { background: #1558b0; }
|
|
757
1156
|
.note { color: #666; font-size: 0.9em; }
|
|
1157
|
+
details.step > summary { cursor: pointer; font-size: 1.2rem; font-weight: 600; }
|
|
1158
|
+
details.step[data-complete="true"] > summary { color: #28743e; }
|
|
1159
|
+
details.step[data-complete="true"] > summary::after { content: " ✓"; }
|
|
758
1160
|
</style>
|
|
759
1161
|
</head>
|
|
760
1162
|
<body>
|
|
761
1163
|
<h1>rechrome — Extension Setup</h1>
|
|
762
|
-
<p>
|
|
763
|
-
|
|
764
|
-
<div class="step">
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
<
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
<
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
<
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
<
|
|
782
|
-
</
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
<
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
<
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1164
|
+
<p>Chrome profile: <strong>${escapeHtml(profileDisplay)}</strong></p>
|
|
1165
|
+
<p>The previous tab is <strong>Chrome Extensions</strong>. Keep this guide open while you install.</p>
|
|
1166
|
+
${checkUrl ? '<div class="step"><h2>Connection status</h2><p id="liveStatus" role="status" aria-live="polite">Connecting to setup…</p><ul id="verifiedChecks"><li>○ Extension detected — pending</li><li>○ Token detected — pending</li><li>○ Browser connection verified — pending</li><li>○ Profile registered — pending</li></ul></div>' : ''}
|
|
1167
|
+
|
|
1168
|
+
<main id="start">
|
|
1169
|
+
<details class="step" data-check="extension" open>
|
|
1170
|
+
<summary>Step 1 — Install the extension</summary>
|
|
1171
|
+
<p>Copy the extension path:</p>
|
|
1172
|
+
<div class="path">
|
|
1173
|
+
<code id="extPath">${escapeHtml(extDistDir)}</code>
|
|
1174
|
+
<button type="button" id="copyPath">Copy path</button>
|
|
1175
|
+
</div>
|
|
1176
|
+
<p id="copyStatus" class="note" role="status" aria-live="polite"></p>
|
|
1177
|
+
<ol>
|
|
1178
|
+
<li>Switch to the <strong>Extensions</strong> tab immediately to the left, or press <strong>Ctrl+Shift+Tab</strong>.</li>
|
|
1179
|
+
<li>Enable <strong>Developer mode</strong>, then click <strong>Load unpacked</strong>.</li>
|
|
1180
|
+
<li>Paste the path into the folder picker and select the folder. On macOS, press <strong>⌘⇧G</strong>, paste, press <strong>Enter</strong>, then click <strong>Select</strong>. On Windows or Linux, use the location field.</li>
|
|
1181
|
+
<li>Return to this guide. Installation is detected automatically.</li>
|
|
1182
|
+
</ol>
|
|
1183
|
+
<p class="note">If the Extensions tab did not open, type <code>chrome://extensions/</code> into a new tab’s address bar and press Enter.</p>
|
|
1184
|
+
</details>
|
|
1185
|
+
|
|
1186
|
+
<details class="step" data-check="bridge" open>
|
|
1187
|
+
<summary>Step 2 — Verify the connection</summary>
|
|
1188
|
+
<p>Switch back here. Setup watches for the extension and token automatically, then tests the connection.</p>
|
|
1189
|
+
${checkUrl ? `<button type="button" id="revalidate" data-url="${escapeHtml(checkUrl)}">Revalidate installation</button>` : '<p>Run your setup command again in the terminal to revalidate the installation.</p>'}
|
|
1190
|
+
<p id="validationStatus" role="status" aria-live="polite"></p>
|
|
1191
|
+
<p class="note">After installation is detected, setup continues in the terminal to read the token and verify the browser connection.</p>
|
|
1192
|
+
<p><a id="extensionStatusLink" href="chrome-extension://${EXTENSION_ID}/status.html" target="_blank" rel="noopener">Open extension status ↗</a></p>
|
|
1193
|
+
<p class="note">If Chrome blocks the link, copy this URL into the address bar of a new tab:</p>
|
|
1194
|
+
<div class="path"><code id="extensionStatusUrl">chrome-extension://${EXTENSION_ID}/status.html</code><button type="button" id="copyStatusUrl">Copy status URL</button></div>
|
|
1195
|
+
${checkUrl ? `<details><summary>Token not detected? Paste it manually</summary><p>Open the extension status page above, copy its auth token, and paste it here.</p><input id="manualToken" type="password" autocomplete="off" aria-label="Extension auth token"><button type="button" id="submitToken">Use token</button></details>` : ''}
|
|
1196
|
+
</details>
|
|
1197
|
+
|
|
1198
|
+
<details class="step" data-check="registration" open>
|
|
1199
|
+
<summary>Step 3 — Finish setup</summary>
|
|
1200
|
+
<p>Once the browser connection is verified, return to the terminal and choose where to save your connection URL. You can also skip saving the URL and just register the profile.</p>
|
|
1201
|
+
<p>This guide confirms when the profile is registered. You can then close the setup tabs.</p>
|
|
1202
|
+
</details>
|
|
1203
|
+
</main>
|
|
1204
|
+
<script>
|
|
1205
|
+
const checkButton = document.getElementById('revalidate');
|
|
1206
|
+
let finished = false;
|
|
1207
|
+
async function checkSetup(payload) {
|
|
1208
|
+
const status = document.getElementById('validationStatus');
|
|
1209
|
+
try {
|
|
1210
|
+
const response = await fetch(checkButton.dataset.url, payload ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } : {});
|
|
1211
|
+
if (response.status === 400) {
|
|
1212
|
+
status.textContent = 'That token looks incomplete. Copy the full token from the extension and try again.';
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (!response.ok) throw new Error('Check failed');
|
|
1216
|
+
const result = await response.json();
|
|
1217
|
+
document.getElementById('liveStatus').textContent = result.message;
|
|
1218
|
+
const checks = document.getElementById('verifiedChecks');
|
|
1219
|
+
checks.replaceChildren(...Object.entries({ extension: 'Extension detected', token: 'Token detected', bridge: 'Browser connection verified', registration: 'Profile registered' }).map(([key, label]) => {
|
|
1220
|
+
const item = document.createElement('li');
|
|
1221
|
+
item.textContent = (result.checks?.[key] ? '✓ ' : '○ ') + label + (result.checks?.[key] ? ' — verified' : ' — pending');
|
|
1222
|
+
return item;
|
|
1223
|
+
}));
|
|
1224
|
+
for (const step of document.querySelectorAll('details.step[data-check]')) {
|
|
1225
|
+
const complete = !!result.checks?.[step.dataset.check];
|
|
1226
|
+
// Collapse only on completion, preserving manual expansion on later polls.
|
|
1227
|
+
if (complete && step.dataset.complete !== 'true') step.open = false;
|
|
1228
|
+
if (!complete && step.dataset.complete === 'true') step.open = true;
|
|
1229
|
+
step.dataset.complete = String(complete);
|
|
1230
|
+
}
|
|
1231
|
+
if (result.statusUrl) {
|
|
1232
|
+
document.getElementById('extensionStatusLink').href = result.statusUrl;
|
|
1233
|
+
document.getElementById('extensionStatusUrl').textContent = result.statusUrl;
|
|
1234
|
+
}
|
|
1235
|
+
checkButton.textContent = result.phase === 'error' ? 'Retry connection' : 'Revalidate installation';
|
|
1236
|
+
finished = result.phase === 'ready';
|
|
1237
|
+
checkButton.disabled = finished;
|
|
1238
|
+
status.textContent = finished ? 'Setup complete. You can close these setup tabs.' : '';
|
|
1239
|
+
} catch {
|
|
1240
|
+
status.textContent = 'Cannot reach the setup process. This page works while rech setup is running, including with piped input. If setup was stopped or finished, run it again and use the new guide tab. Retrying…';
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
checkButton?.addEventListener('click', () => checkSetup({ retry: true }));
|
|
1244
|
+
document.getElementById('submitToken')?.addEventListener('click', async () => {
|
|
1245
|
+
const input = document.getElementById('manualToken');
|
|
1246
|
+
const token = input.value;
|
|
1247
|
+
input.value = '';
|
|
1248
|
+
await checkSetup({ token });
|
|
1249
|
+
});
|
|
1250
|
+
if (checkButton) {
|
|
1251
|
+
const poll = async () => { await checkSetup(); if (!finished) setTimeout(poll, 1000); };
|
|
1252
|
+
poll();
|
|
1253
|
+
}
|
|
1254
|
+
document.getElementById('copyStatusUrl').addEventListener('click', async function () {
|
|
1255
|
+
const url = document.getElementById('extensionStatusUrl');
|
|
1256
|
+
try {
|
|
1257
|
+
await navigator.clipboard.writeText(url.textContent);
|
|
1258
|
+
this.textContent = 'Copied!';
|
|
1259
|
+
} catch {
|
|
1260
|
+
const range = document.createRange();
|
|
1261
|
+
range.selectNodeContents(url);
|
|
1262
|
+
const selection = window.getSelection();
|
|
1263
|
+
selection.removeAllRanges();
|
|
1264
|
+
selection.addRange(range);
|
|
1265
|
+
this.textContent = 'Press ⌘C / Ctrl+C';
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
document.getElementById('copyPath').addEventListener('click', async function () {
|
|
1269
|
+
const path = document.getElementById('extPath');
|
|
1270
|
+
const status = document.getElementById('copyStatus');
|
|
1271
|
+
try {
|
|
1272
|
+
await navigator.clipboard.writeText(path.textContent);
|
|
1273
|
+
this.textContent = 'Copied!';
|
|
1274
|
+
status.textContent = 'Path copied. Switch to the Extensions tab.';
|
|
1275
|
+
} catch {
|
|
1276
|
+
const range = document.createRange();
|
|
1277
|
+
range.selectNodeContents(path);
|
|
1278
|
+
const selection = window.getSelection();
|
|
1279
|
+
selection.removeAllRanges();
|
|
1280
|
+
selection.addRange(range);
|
|
1281
|
+
status.textContent = 'Press ⌘C on Mac or Ctrl+C on Windows/Linux to copy the selected path, then switch to the Extensions tab.';
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
</script>
|
|
796
1285
|
</body>
|
|
797
1286
|
</html>`;
|
|
798
1287
|
}
|
|
@@ -899,15 +1388,44 @@ export function resolvePlaywrightCli(): string {
|
|
|
899
1388
|
return "playwright-cli-multi-tab";
|
|
900
1389
|
}
|
|
901
1390
|
|
|
1391
|
+
/**
|
|
1392
|
+
* Make sure a daemon process manager is available, offering to install oxmgr
|
|
1393
|
+
* (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
|
|
1394
|
+
* since installing oxmgr would not satisfy it.
|
|
1395
|
+
*/
|
|
1396
|
+
async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
|
|
1397
|
+
try {
|
|
1398
|
+
daemonManager();
|
|
1399
|
+
return;
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
|
|
1402
|
+
}
|
|
1403
|
+
const command = oxmgrInstallCommand(process.env);
|
|
1404
|
+
const answer = yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
|
|
1405
|
+
if (!/^(y|yes)$/i.test(answer)) {
|
|
1406
|
+
throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
|
|
1407
|
+
}
|
|
1408
|
+
console.log(` Installing oxmgr: ${command.join(" ")}`);
|
|
1409
|
+
const installer = Bun.which(command[0]) ?? (command[0] === "bun" ? process.execPath : null);
|
|
1410
|
+
if (!installer) throw new Error(`${command[0]} is not on PATH. Install it or run \`${command.join(" ")}\` in your terminal, then rerun setup.`);
|
|
1411
|
+
const proc = Bun.spawn([installer, ...command.slice(1)], {
|
|
1412
|
+
stdin: "inherit", stdout: "inherit", stderr: "inherit", windowsHide: true,
|
|
1413
|
+
});
|
|
1414
|
+
const code = await proc.exited;
|
|
1415
|
+
if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
|
|
1416
|
+
if (!Bun.which("oxmgr")) {
|
|
1417
|
+
throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
|
|
1418
|
+
}
|
|
1419
|
+
_daemonMgr = undefined;
|
|
1420
|
+
_oxmgrVersion = undefined;
|
|
1421
|
+
daemonManager();
|
|
1422
|
+
}
|
|
1423
|
+
|
|
902
1424
|
export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
1425
|
+
// Resolve the manager first so a missing dependency fails before config is mutated.
|
|
903
1426
|
const mgr = daemonManager();
|
|
904
|
-
// Persist the URL
|
|
905
|
-
//
|
|
906
|
-
// overwrites process.env.RECHROME_URL from whichever file it finds first.
|
|
907
|
-
// Without this write, oxmgr's --env RECHROME_URL=... gets clobbered by a
|
|
908
|
-
// stale ~/.env.local entry — the daemon then listens on a different bearer
|
|
909
|
-
// key than the one daemonInstall was called with, and every client request
|
|
910
|
-
// is rejected with "bearer key rejected".
|
|
1427
|
+
// Persist the URL for future clients without an explicit environment override.
|
|
1428
|
+
// The daemon's explicit environment takes precedence over this saved default.
|
|
911
1429
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
912
1430
|
const filtered = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
913
1431
|
await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${serveUrl}`, ""].join("\n"));
|
|
@@ -987,7 +1505,7 @@ async function daemonUninstall(): Promise<void> {
|
|
|
987
1505
|
// locate the binary and launch it detached (singleton via a pidfile).
|
|
988
1506
|
// `rech tray hide` / the menu "Hide" item both kill the process;
|
|
989
1507
|
// `rech tray show` starts a fresh one.
|
|
990
|
-
const TRAY_PID_FILE = join(
|
|
1508
|
+
const TRAY_PID_FILE = join(RECH_DIR, "tray.pid");
|
|
991
1509
|
|
|
992
1510
|
// A desktop GUI must be present. Linux needs an X11/Wayland display; a headless
|
|
993
1511
|
// box (SSH, CI, container) has neither, so the tray is skipped. macOS/Windows
|
|
@@ -1107,40 +1625,6 @@ async function trayCommand(sub?: string): Promise<void> {
|
|
|
1107
1625
|
}
|
|
1108
1626
|
}
|
|
1109
1627
|
|
|
1110
|
-
// Read the extension's auth token straight from a profile's localStorage LevelDB. Read-only
|
|
1111
|
-
// (we never take LevelDB's lock), so it's safe while the user's Chrome is running. The token is
|
|
1112
|
-
// the value of the `auth-token` key under the extension origin, stored as a 0x01 (Latin-1)
|
|
1113
|
-
// encoding byte followed by the 43-char base64url token. LevelDB prefix-compression can split the
|
|
1114
|
-
// origin string across block-restart points, so we anchor on the `auth-token` marker + token shape
|
|
1115
|
-
// and (when possible) require the extension id to appear in the same file to avoid a collision
|
|
1116
|
-
// with another extension's `auth-token`. Returns the newest token found, or null.
|
|
1117
|
-
function readExtensionTokenFromProfile(userDataDir: string, profileDir: string): string | null {
|
|
1118
|
-
const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
|
|
1119
|
-
let files: string[];
|
|
1120
|
-
try { files = readdirSync(dir).filter(f => f.endsWith(".ldb") || f.endsWith(".log")).sort(); }
|
|
1121
|
-
catch { return null; }
|
|
1122
|
-
const extIdChunk = EXTENSION_ID.slice(0, 20); // contiguous prefix survives the LevelDB split
|
|
1123
|
-
const scan = (requireExtId: boolean): string | null => {
|
|
1124
|
-
let found: string | null = null;
|
|
1125
|
-
for (const f of files) {
|
|
1126
|
-
let buf: Buffer;
|
|
1127
|
-
try { buf = readFileSync(join(dir, f)); } catch { continue; }
|
|
1128
|
-
if (requireExtId && !buf.includes(extIdChunk, 0, "latin1")) continue;
|
|
1129
|
-
let idx = 0;
|
|
1130
|
-
while (true) {
|
|
1131
|
-
const j = buf.indexOf("auth-token", idx, "latin1");
|
|
1132
|
-
if (j < 0) break;
|
|
1133
|
-
idx = j + 1;
|
|
1134
|
-
const win = buf.subarray(j, Math.min(buf.length, j + 200)).toString("latin1");
|
|
1135
|
-
const m = win.match(/\x01([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/);
|
|
1136
|
-
if (m) found = m[1]; // newest file / newest occurrence wins
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
return found;
|
|
1140
|
-
};
|
|
1141
|
-
return scan(true) ?? scan(false);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
1628
|
// Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
|
|
1145
1629
|
// Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
|
|
1146
1630
|
// --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
|
|
@@ -1283,7 +1767,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1283
1767
|
process.exit(1);
|
|
1284
1768
|
}
|
|
1285
1769
|
const dist = await ensureExtensionDistInstalled();
|
|
1286
|
-
const userDataDir = join(
|
|
1770
|
+
const userDataDir = join(RECH_DIR, "profiles", name);
|
|
1287
1771
|
const token = randomBytes(32).toString("base64url");
|
|
1288
1772
|
|
|
1289
1773
|
console.log(`\n[1/3] Provisioning managed profile "${name}"`);
|
|
@@ -1297,19 +1781,21 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1297
1781
|
console.log(`\n[2/3] Building RECHROME_URL`);
|
|
1298
1782
|
const url = await getOrCreateUrl();
|
|
1299
1783
|
const { host, port, protocol, key } = parseUrl(url);
|
|
1300
|
-
const healthy = await fetch(
|
|
1784
|
+
const healthy = await fetch(serviceUrl(url, "ping"), {
|
|
1301
1785
|
headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(2000),
|
|
1302
1786
|
}).then(r => r.ok).catch(() => false);
|
|
1303
1787
|
if (!healthy) console.log(` Note: daemon not reachable at ${host}:${port} — run \`rech setup\` once to start it.`);
|
|
1304
1788
|
|
|
1305
|
-
const rechUrl = new URL(
|
|
1789
|
+
const rechUrl = new URL(serviceUrl(url));
|
|
1306
1790
|
rechUrl.username = key || randomBytes(12).toString("base64url");
|
|
1307
1791
|
rechUrl.searchParams.set("extension_id", EXTENSION_ID);
|
|
1308
1792
|
rechUrl.searchParams.set("token", token);
|
|
1309
1793
|
rechUrl.searchParams.set("profile", name);
|
|
1310
1794
|
rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
1311
1795
|
rechUrl.searchParams.set("load_extension", dist);
|
|
1312
|
-
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
const newLine = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
|
|
1313
1799
|
|
|
1314
1800
|
// [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
|
|
1315
1801
|
await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
|
|
@@ -1320,7 +1806,94 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1320
1806
|
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
1321
1807
|
}
|
|
1322
1808
|
|
|
1323
|
-
async function
|
|
1809
|
+
async function exposeProfile(profile: string, host: string, port: number, prefix = "/"): Promise<Listener> {
|
|
1810
|
+
const config = await readListeners();
|
|
1811
|
+
if (!config) throw new Error("Run rech setup once to initialize listeners");
|
|
1812
|
+
// Setup selects this profile's exposure, not the daemon's global bind. Remove
|
|
1813
|
+
// only this profile from other scoped listeners; retain all other profiles.
|
|
1814
|
+
config.listeners = config.listeners.filter(l => {
|
|
1815
|
+
if (l.profiles === "*" || (l.host === host && l.port === port)) return true;
|
|
1816
|
+
l.profiles = l.profiles.filter(p => p !== profile);
|
|
1817
|
+
return l.profiles.length > 0;
|
|
1818
|
+
});
|
|
1819
|
+
let listener = config.listeners.find(l => l.host === host && l.port === port);
|
|
1820
|
+
if (listener && normalizePrefix(listener.prefix) !== prefix) throw new Error("This port already uses a different prefix; choose --port with a separate port");
|
|
1821
|
+
if (prefix !== "/" && listener?.profiles === "*") throw new Error("A prefixed proxy requires a separate scoped listener port");
|
|
1822
|
+
if (listener) {
|
|
1823
|
+
if (listener.profiles !== "*" && !listener.profiles.includes(profile)) listener.profiles.push(profile);
|
|
1824
|
+
} else {
|
|
1825
|
+
listener = { name: `listen-${randomBytes(4).toString("hex")}`, host, port, prefix, key: randomBytes(24).toString("base64url"), profiles: [profile] };
|
|
1826
|
+
config.listeners.push(listener);
|
|
1827
|
+
}
|
|
1828
|
+
await writeListeners(config);
|
|
1829
|
+
return listener;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
async function requireListeners() {
|
|
1833
|
+
const config = await readListeners();
|
|
1834
|
+
if (!config) throw new Error("No listener configuration yet. Run rech setup to migrate the daemon.");
|
|
1835
|
+
return config;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
async function listListeners(): Promise<void> {
|
|
1839
|
+
for (const l of (await requireListeners()).listeners) console.log(`${l.name} ${listenerAddress(l)}${normalizePrefix(l.prefix)} ${l.profiles === "*" ? "local management (all profiles)" : l.profiles.join(", ")}`);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
async function removeListener(name: string): Promise<void> {
|
|
1843
|
+
const config = await requireListeners();
|
|
1844
|
+
const listener = config.listeners.find(l => l.name === name);
|
|
1845
|
+
if (!listener) throw new Error("Unknown listener");
|
|
1846
|
+
if (listener.profiles === "*") throw new Error("Keep the local management listener for setup and recovery");
|
|
1847
|
+
config.listeners = config.listeners.filter(l => l !== listener);
|
|
1848
|
+
await writeListeners(config);
|
|
1849
|
+
console.log("Listener removed from configuration; daemon reloads automatically.");
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
async function addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void> {
|
|
1853
|
+
const config = await requireListeners();
|
|
1854
|
+
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
1855
|
+
const profiles: string[] = [];
|
|
1856
|
+
for (const selector of opts.profile) profiles.push((await resolveGlobalProfile(registry, cache, selector)).email);
|
|
1857
|
+
const host = chooseListenAddress(await detectListenChoices(), opts.listen);
|
|
1858
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
1859
|
+
if (config.listeners.some(l => l.name === name)) throw new Error("Listener name already exists; edit listeners.json to change its allowlist");
|
|
1860
|
+
config.listeners.push({ name, host, port, prefix: normalizePrefix(opts.prefix), key: randomBytes(24).toString("base64url"), profiles: [...new Set(profiles)] });
|
|
1861
|
+
await writeListeners(config);
|
|
1862
|
+
console.log(`Listener saved for ${host}:${port}; daemon reloads automatically. Credentials are in ~/.rechrome/listeners.json (keep private).`);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
|
|
1866
|
+
// These are hints, not proof of identity or permission to automate the desktop.
|
|
1867
|
+
if (env.RECH_SETUP_AGENT === "none") return null;
|
|
1868
|
+
if (env.RECH_SETUP_AGENT === "codex") return "Codex";
|
|
1869
|
+
if (env.RECH_SETUP_AGENT === "claude") return "Claude Code";
|
|
1870
|
+
const enabled = (value: string | undefined) => !!value && !["0", "false"].includes(value.toLowerCase());
|
|
1871
|
+
if (enabled(env.CODEX_THREAD_ID) || enabled(env.CODEX_CI) || enabled(env.CODEX_SANDBOX)) return "Codex";
|
|
1872
|
+
if (enabled(env.CLAUDECODE) || enabled(env.CLAUDE_CODE_ENTRYPOINT)) return "Claude Code";
|
|
1873
|
+
return null;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
export function setupAgentGuidance(agent: "Codex" | "Claude Code", platform = process.platform): string {
|
|
1877
|
+
const skillPath = join(import.meta.dir, "skills", "rechrome-setup", "SKILL.md");
|
|
1878
|
+
return [
|
|
1879
|
+
`[rech] ${agent} environment detected — setup guidance:`,
|
|
1880
|
+
` Read the setup skill: ${skillPath}`,
|
|
1881
|
+
" Keep this setup process alive while installing; retain and poll its terminal session.",
|
|
1882
|
+
" Piped stdin is supported: supply prompt answers and close stdin so setup can start.",
|
|
1883
|
+
" Use the available Computer Use skill for the selected profile's Load unpacked flow; follow its approval rules.",
|
|
1884
|
+
...(platform === "darwin" ? [" macOS: in the folder picker, use Command+Shift+G and the extension path shown in the guide."] : []),
|
|
1885
|
+
" If desktop control is unavailable, leave setup running and give the user the exact remaining step.",
|
|
1886
|
+
" Never restart the user's Chrome. Let setup detect the token; do not print credentials.",
|
|
1887
|
+
" Wait for bridge verification and registration before reporting success; preserve the default profile unless asked to change it.",
|
|
1888
|
+
" Agent detection does not grant permissions. Set RECH_SETUP_AGENT=none to hide these hints.",
|
|
1889
|
+
].join("\n");
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
1893
|
+
const prefix = normalizePrefix(opts.prefix);
|
|
1894
|
+
if (opts.port !== undefined && (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)) throw new Error("--port must be between 1 and 65535");
|
|
1895
|
+
const agent = detectSetupAgent();
|
|
1896
|
+
if (agent) console.error(setupAgentGuidance(agent));
|
|
1324
1897
|
if (opts.profile !== undefined) {
|
|
1325
1898
|
try {
|
|
1326
1899
|
validateChromeProfileSelector(opts.profile);
|
|
@@ -1336,6 +1909,7 @@ async function setup(opts: { profile?: string; token?: string; yes?: boolean } =
|
|
|
1336
1909
|
let stdinQueue: string[] | null = null;
|
|
1337
1910
|
if (isTTY) {
|
|
1338
1911
|
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1912
|
+
rl.on("SIGINT", () => { rl?.close(); envWatcher?.close(); process.exit(130); });
|
|
1339
1913
|
} else {
|
|
1340
1914
|
// Pre-read all piped stdin lines so readline close doesn't block later prompts
|
|
1341
1915
|
stdinQueue = await new Promise<string[]>(resolve => {
|
|
@@ -1352,140 +1926,65 @@ async function setup(opts: { profile?: string; token?: string; yes?: boolean } =
|
|
|
1352
1926
|
};
|
|
1353
1927
|
|
|
1354
1928
|
// [1/5] Daemon
|
|
1355
|
-
console.log("\n[1/5]
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
//
|
|
1359
|
-
const
|
|
1360
|
-
const
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1929
|
+
console.log("\n○ [1/5] Detecting networks and configuring the daemon...");
|
|
1930
|
+
const listenChoices = await detectListenChoices();
|
|
1931
|
+
|
|
1932
|
+
// Defer persistence until daemon prerequisites have passed; daemonInstall saves the URL.
|
|
1933
|
+
const currentUrl = await getOrCreateUrl(false);
|
|
1934
|
+
const previous = parseUrl(currentUrl);
|
|
1935
|
+
let config = await readListeners();
|
|
1936
|
+
if (!config) {
|
|
1937
|
+
const previousHost = process.env.RECH_HOST || "127.0.0.1";
|
|
1938
|
+
const local: Listener = { name: "local", host: "127.0.0.1", port: previous.port, key: previous.key, profiles: "*" };
|
|
1939
|
+
config = { version: 1, listeners: [local] };
|
|
1940
|
+
const registered = Object.keys(await readTokenRegistry());
|
|
1941
|
+
if (!isLoopback(previousHost) && registered.length && previousHost !== "0.0.0.0" && previousHost !== "::") {
|
|
1942
|
+
config.listeners.push({ name: "existing-remote", host: previousHost, port: previous.port, key: randomBytes(24).toString("base64url"), profiles: registered });
|
|
1943
|
+
console.log(" Existing remote exposure retained for registered profiles with a new scoped listener key.");
|
|
1944
|
+
}
|
|
1945
|
+
await writeListeners(config);
|
|
1369
1946
|
}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
const
|
|
1373
|
-
|
|
1374
|
-
const { key: serveKey } = parseUrl(url);
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
const
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
// Non-TTY honors explicit process.env.RECH_HOST (shell or merged env stack) — matches the documented `RECH_HOST=0.0.0.0 rech setup` flow.
|
|
1395
|
-
let desiredBind = process.env.RECH_HOST || currentBind;
|
|
1396
|
-
// Only prompt to (re)configure the bind when we actually need to set up the daemon. A running
|
|
1397
|
-
// daemon is left alone unless the user explicitly asks for a different bind via RECH_HOST.
|
|
1398
|
-
if (isTTY && (!daemonHealthy || explicitRebind)) {
|
|
1399
|
-
console.log(`\n Bind address (current: ${currentBind}):`);
|
|
1400
|
-
console.log(` 1. 127.0.0.1 (localhost only)`);
|
|
1401
|
-
console.log(` 2. 0.0.0.0 (all interfaces — HTTP plaintext, trust your network)`);
|
|
1402
|
-
const defaultBindChoice = currentBind === "0.0.0.0" ? "2" : "1";
|
|
1403
|
-
const bindAns = (await ask(` Choice [${defaultBindChoice}]: `, defaultBindChoice)).trim();
|
|
1404
|
-
desiredBind = bindAns === "2" || bindAns === "0.0.0.0" ? "0.0.0.0" : "127.0.0.1";
|
|
1405
|
-
} else if (daemonHealthy) {
|
|
1406
|
-
console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
|
|
1407
|
-
}
|
|
1408
|
-
const bindChanged = desiredBind !== currentBind;
|
|
1409
|
-
if (!daemonHealthy || bindChanged) {
|
|
1947
|
+
const management = config.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
|
|
1948
|
+
if (!management) throw new Error("Setup requires a loopback management listener in ~/.rechrome/listeners.json");
|
|
1949
|
+
const scheme = previous.protocol;
|
|
1950
|
+
let url = `${scheme}://${management.key}@${listenerAddress(management)}${normalizePrefix(management.prefix)}`;
|
|
1951
|
+
const { host, port, protocol, key: serveKey } = parseUrl(url);
|
|
1952
|
+
let desiredBind = management.host;
|
|
1953
|
+
if (opts.listen) desiredBind = chooseListenAddress(listenChoices, opts.listen);
|
|
1954
|
+
else if (isTTY) {
|
|
1955
|
+
console.log("\n Where should this profile be accessible? (Other profiles keep their listeners.)");
|
|
1956
|
+
listenChoices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.label} — ${choice.address}`));
|
|
1957
|
+
while (true) {
|
|
1958
|
+
const answer = (await ask(" Choice [1]: ", "1")).trim();
|
|
1959
|
+
const choice = /^\d+$/.test(answer) ? listenChoices[Number(answer) - 1] : undefined;
|
|
1960
|
+
if (choice) { desiredBind = choice.address; break; }
|
|
1961
|
+
console.log(" Enter one of the numbers shown above.");
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
const pingManagement = () => fetch(serviceUrl(url, "ping"), {
|
|
1965
|
+
headers: { Authorization: `Bearer ${serveKey}` }, signal: AbortSignal.timeout(2000),
|
|
1966
|
+
}).then(async r => r.ok && (await r.json()).multiListener === true).catch(() => false);
|
|
1967
|
+
if (!await pingManagement()) {
|
|
1968
|
+
// A one-time source upgrade restarts only the daemon, never Chrome. Subsequent
|
|
1969
|
+
// listener changes are reloaded in place by the running daemon.
|
|
1410
1970
|
try {
|
|
1411
|
-
|
|
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
|
-
}
|
|
1971
|
+
await ensureDaemonManager(ask, opts.yes);
|
|
1436
1972
|
} catch (error) {
|
|
1437
1973
|
console.error(error instanceof Error ? error.message : String(error));
|
|
1438
1974
|
rl?.close();
|
|
1439
1975
|
envWatcher?.close();
|
|
1440
1976
|
process.exit(1);
|
|
1441
1977
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
const lines = globalEnvRaw.trimEnd().split("\n").filter(l => !/^\s*RECH_HOST\s*=/.test(l));
|
|
1446
|
-
await Bun.write(globalEnvFile, [...lines, `RECH_HOST=${desiredBind}`, ""].join("\n"));
|
|
1447
|
-
console.log(` Saved RECH_HOST=${desiredBind} to ~/.env.local`);
|
|
1448
|
-
}
|
|
1449
|
-
// Always align process.env with the desired bind — a nearer .env.local may have shadowed it.
|
|
1450
|
-
process.env.RECH_HOST = desiredBind;
|
|
1451
|
-
|
|
1452
|
-
const waitForServe = async () => {
|
|
1453
|
-
process.stdout.write(" Starting");
|
|
1454
|
-
let ping = null;
|
|
1978
|
+
process.env.RECH_HOST = management.host;
|
|
1979
|
+
await daemonInstall(url);
|
|
1980
|
+
let ready = false;
|
|
1455
1981
|
for (let i = 0; i < 15; i++) {
|
|
1456
1982
|
await Bun.sleep(1000);
|
|
1457
|
-
|
|
1458
|
-
if (ping) break;
|
|
1459
|
-
process.stdout.write(".");
|
|
1983
|
+
if (await pingManagement()) { ready = true; break; }
|
|
1460
1984
|
}
|
|
1461
|
-
|
|
1462
|
-
if (!ping) {
|
|
1463
|
-
console.error(` Failed to start serve at ${host}:${port}`);
|
|
1464
|
-
rl?.close();
|
|
1465
|
-
process.exit(1);
|
|
1466
|
-
}
|
|
1467
|
-
console.log(` Serve running at ${protocol}://${host}:${port}`);
|
|
1468
|
-
};
|
|
1469
|
-
|
|
1470
|
-
if (anonPing && authPing?.ok && !bindChanged && !liveBindUnknown) {
|
|
1471
|
-
console.log(` Already running at ${protocol}://${host}:${port} — skipping reinstall`);
|
|
1472
|
-
} else if (anonPing && authPing?.ok && liveBindUnknown) {
|
|
1473
|
-
console.log(` Pre-patch daemon detected (no live bind info) — reinstalling to verify bind`);
|
|
1474
|
-
await daemonInstall(url);
|
|
1475
|
-
await waitForServe();
|
|
1476
|
-
} else if (anonPing && bindChanged) {
|
|
1477
|
-
console.log(` Bind changed (${currentBind} → ${desiredBind}) — reinstalling`);
|
|
1478
|
-
await daemonInstall(url);
|
|
1479
|
-
await waitForServe();
|
|
1480
|
-
} else if (anonPing && !authPing?.ok) {
|
|
1481
|
-
console.log(` Server running but key mismatch — reinstalling with new key`);
|
|
1482
|
-
await daemonInstall(url);
|
|
1483
|
-
await waitForServe();
|
|
1484
|
-
} else {
|
|
1485
|
-
await daemonInstall(url);
|
|
1486
|
-
console.log(` Registered daemon: ${PM_PROCESS_NAME}`);
|
|
1487
|
-
await waitForServe();
|
|
1985
|
+
if (!ready) throw new Error("Local management listener did not start; inspect rech status and daemon logs");
|
|
1488
1986
|
}
|
|
1987
|
+
console.log(`✓ [1/5] Local management daemon verified; requested profile access: ${desiredBind}`);
|
|
1489
1988
|
|
|
1490
1989
|
const cache = await readChromeProfileCache();
|
|
1491
1990
|
if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
|
|
@@ -1519,141 +2018,86 @@ async function setup(opts: { profile?: string; token?: string; yes?: boolean } =
|
|
|
1519
2018
|
return available[idx];
|
|
1520
2019
|
}
|
|
1521
2020
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
let extId: string | undefined;
|
|
1525
|
-
// Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
|
|
2021
|
+
let guide: ReturnType<typeof createSetupGuide> | undefined;
|
|
2022
|
+
async function getExtAndToken(profileDir: string, profileDisplay: string, _profileKey: string, providedToken?: string): Promise<{ extId: string; token: string } | null> {
|
|
1526
2023
|
await ensureExtensionDistInstalled();
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
console.log(
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
// so an installed extension needs no manual paste (works the same in TTY and non-TTY). The
|
|
1568
|
-
// token is minted lazily the first time the status/connect page loads, so if it isn't there
|
|
1569
|
-
// yet, open status.html in this profile to mint it (a new tab — never a restart) and re-scan.
|
|
1570
|
-
if (userDataDir) {
|
|
1571
|
-
let auto = readExtensionTokenFromProfile(userDataDir, profileDir);
|
|
1572
|
-
if (!auto) {
|
|
1573
|
-
console.log(` No token in profile yet — minting via chrome-extension://${extId}/status.html …`);
|
|
1574
|
-
openInChromeProfile(profileDir, `chrome-extension://${extId}/status.html`);
|
|
1575
|
-
for (let i = 0; i < 10 && !auto; i++) { await Bun.sleep(500); auto = readExtensionTokenFromProfile(userDataDir, profileDir); }
|
|
1576
|
-
}
|
|
1577
|
-
if (auto) {
|
|
1578
|
-
console.log(` Auto-read token from profile localStorage: ${auto.slice(0, 6)}…`);
|
|
1579
|
-
return { extId, token: auto };
|
|
1580
|
-
}
|
|
1581
|
-
console.log(` Could not auto-read token from localStorage — falling back to manual entry`);
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
// Check for existing token in registry
|
|
1585
|
-
const registry = await readTokenRegistry();
|
|
1586
|
-
const existing = registry[profileKey];
|
|
1587
|
-
if (existing && existing.extensionId === extId && existing.token) {
|
|
1588
|
-
console.log(` Existing token found: ${existing.token.slice(0, 6)}…`);
|
|
1589
|
-
if (!isTTY) console.log(` [agent] Provide y to keep existing token, n to refresh on next stdin line`);
|
|
1590
|
-
const keep = (await ask(" Keep existing token? [Y/n]: ")).trim().toLowerCase();
|
|
1591
|
-
if (keep !== "n") {
|
|
1592
|
-
console.log(" Keeping existing token");
|
|
1593
|
-
return { extId, token: existing.token };
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// Token
|
|
1598
|
-
const statusUrl = `chrome-extension://${extId}/status.html`;
|
|
1599
|
-
console.log(`\n Get auth token from the extension:`);
|
|
1600
|
-
console.log(` ${statusUrl}`);
|
|
1601
|
-
if (isTTY) {
|
|
1602
|
-
openInChromeProfile(profileDir, statusUrl);
|
|
1603
|
-
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
1604
|
-
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
1605
|
-
} else {
|
|
1606
|
-
console.log(`\n [agent] Open the URL above in Chrome (profile: ${profileDisplay})`);
|
|
1607
|
-
console.log(` [agent] Find PLAYWRIGHT_MCP_EXTENSION_TOKEN=... on that page`);
|
|
1608
|
-
console.log(` [agent] Provide the token value on next stdin line:\n`);
|
|
1609
|
-
}
|
|
1610
|
-
// Retry on empty/too-short paste — a truncated copy or a stale token shouldn't
|
|
1611
|
-
// abort the whole setup. Bounded so a non-TTY agent with exhausted stdin can't spin.
|
|
1612
|
-
const maxTries = isTTY ? 5 : 3;
|
|
1613
|
-
for (let attempt = 1; attempt <= maxTries; attempt++) {
|
|
1614
|
-
const tokenInput = (await ask(" Paste token: ")).trim();
|
|
1615
|
-
const token = tokenInput.replace(/^.*?=/, "").trim();
|
|
1616
|
-
const retriesLeft = maxTries - attempt;
|
|
1617
|
-
if (!token) {
|
|
1618
|
-
console.error(` No token entered.${retriesLeft ? " Copy the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value and try again." : ""}`);
|
|
1619
|
-
} else if (token.length < 20) {
|
|
1620
|
-
console.error(` Token too short (${token.length} chars) — likely truncated when copying.${retriesLeft ? " Re-copy the full value and try again." : ""}`);
|
|
1621
|
-
} else {
|
|
1622
|
-
console.log(" Token accepted");
|
|
1623
|
-
return { extId, token };
|
|
2024
|
+
guide?.close();
|
|
2025
|
+
guide = createSetupGuide(EXTENSION_DIST_DIR, profileDisplay);
|
|
2026
|
+
console.log(` Live setup guide: ${guide.url}`);
|
|
2027
|
+
if (!await openSetupGuide(profileDir, guide.url))
|
|
2028
|
+
console.log(" Open chrome://extensions/ in the selected profile if the tab did not open.");
|
|
2029
|
+
const deadline = Date.now() + 15 * 60_000;
|
|
2030
|
+
let statusOpenedFor: string | undefined;
|
|
2031
|
+
let terminalToken: string | undefined;
|
|
2032
|
+
let manualPromptStarted = false;
|
|
2033
|
+
const promptAbort = new AbortController();
|
|
2034
|
+
try {
|
|
2035
|
+
while (Date.now() < deadline) {
|
|
2036
|
+
const found = await findInstalledExtension(profileDir);
|
|
2037
|
+
if (!found) {
|
|
2038
|
+
guide.update("extension", "Waiting for the extension. Load it in the previous tab; this page updates automatically.");
|
|
2039
|
+
await Bun.sleep(1000);
|
|
2040
|
+
continue;
|
|
2041
|
+
}
|
|
2042
|
+
const extId = found.id;
|
|
2043
|
+
guide.setExtensionId(extId);
|
|
2044
|
+
const manual = guide.takeToken() || terminalToken || providedToken;
|
|
2045
|
+
const candidate = manual?.replace(/^PLAYWRIGHT_MCP_EXTENSION_TOKEN=/, "").trim();
|
|
2046
|
+
const automatic = userDataDir ? readExtensionTokenFromProfile(userDataDir, profileDir, extId) : null;
|
|
2047
|
+
const token = candidate && /^[A-Za-z0-9_-]{20,256}$/.test(candidate) ? candidate : automatic;
|
|
2048
|
+
if (token) {
|
|
2049
|
+
guide.update("bridge", "Extension and token detected. Testing the browser connection…");
|
|
2050
|
+
console.log(` Extension ${extId} and auth token detected${candidate ? " (provided token)" : " automatically"}`);
|
|
2051
|
+
return { extId, token };
|
|
2052
|
+
}
|
|
2053
|
+
guide.update("token", "Extension installed. Waiting for its auth token. Open the extension status page using the link below to initialize it, or paste a token manually.");
|
|
2054
|
+
if (statusOpenedFor !== extId) {
|
|
2055
|
+
statusOpenedFor = extId;
|
|
2056
|
+
openInChromeProfile(profileDir, `chrome-extension://${extId}/status.html`);
|
|
2057
|
+
console.log(" Watching for the token automatically. You can also paste it in the live guide.");
|
|
2058
|
+
}
|
|
2059
|
+
if (rl && !manualPromptStarted) {
|
|
2060
|
+
manualPromptStarted = true;
|
|
2061
|
+
rl.question(" Optional fallback — paste token here (automatic detection continues): ", { signal: promptAbort.signal }, answer => { terminalToken = answer.trim(); });
|
|
2062
|
+
}
|
|
2063
|
+
await Bun.sleep(1000);
|
|
1624
2064
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
2065
|
+
guide.update("error", "Setup timed out after 15 minutes. Run setup again to resume.");
|
|
2066
|
+
console.error(" Extension/token detection timed out; run setup again to resume.");
|
|
2067
|
+
return null;
|
|
2068
|
+
} finally {
|
|
2069
|
+
promptAbort.abort();
|
|
1627
2070
|
}
|
|
1628
|
-
console.error(" No valid token provided — aborting");
|
|
1629
|
-
return null;
|
|
1630
2071
|
}
|
|
1631
2072
|
|
|
1632
2073
|
// [2/5] Primary profile
|
|
1633
|
-
console.log("\n[2/5] Select Chrome profile:");
|
|
2074
|
+
console.log("\n○ [2/5] Select Chrome profile:");
|
|
1634
2075
|
const picked = await pickProfile(new Set());
|
|
1635
2076
|
if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
|
|
1636
2077
|
const [profileDir, profileInfoSel] = picked;
|
|
1637
2078
|
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
2079
|
+
console.log(`✓ [2/5] Profile resolved: ${profileDisplay} [${profileDir}]`);
|
|
1638
2080
|
|
|
1639
2081
|
// [3/5] Extension + token for primary profile
|
|
1640
|
-
console.log("\n[3/5] Checking extension...");
|
|
2082
|
+
console.log("\n○ [3/5] Checking extension...");
|
|
1641
2083
|
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
1642
2084
|
const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
|
|
1643
|
-
if (!primary) { rl?.close(); process.exit(1); }
|
|
1644
|
-
const { extId
|
|
2085
|
+
if (!primary) { await Bun.sleep(1500); guide?.close(); rl?.close(); process.exit(1); }
|
|
2086
|
+
const { extId } = primary;
|
|
2087
|
+
let token = primary.token;
|
|
2088
|
+
console.log("✓ [3/5] Extension and token detected");
|
|
1645
2089
|
|
|
1646
2090
|
// Build RECHROME_URL, verify the selected profile can complete a real extension
|
|
1647
2091
|
// handshake, then show it before asking where to save.
|
|
1648
|
-
const rechUrl =
|
|
2092
|
+
const rechUrl = editableConnectionUrl(url);
|
|
1649
2093
|
if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
|
|
1650
2094
|
rechUrl.searchParams.set("extension_id", extId);
|
|
1651
2095
|
rechUrl.searchParams.set("token", token);
|
|
1652
2096
|
rechUrl.searchParams.set("profile", profileEmail);
|
|
1653
2097
|
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
1654
|
-
|
|
1655
|
-
console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
|
|
1656
|
-
const probeSession = `
|
|
2098
|
+
|
|
2099
|
+
console.log(`\n○ [4/5] Verifying extension bridge for ${profileDisplay}...`);
|
|
2100
|
+
const probeSession = `s${randomBytes(3).toString("hex")}`;
|
|
1657
2101
|
const probeIdentity = await getClientIdentity();
|
|
1658
2102
|
probeIdentity.profile = profileEmail;
|
|
1659
2103
|
const probeEnv: Record<string, string> = {
|
|
@@ -1663,28 +2107,94 @@ async function setup(opts: { profile?: string; token?: string; yes?: boolean } =
|
|
|
1663
2107
|
...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
|
|
1664
2108
|
};
|
|
1665
2109
|
let bridgeVerified = false;
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
2110
|
+
let bridgeError = "";
|
|
2111
|
+
const bridgeDeadline = Date.now() + 15 * 60_000;
|
|
2112
|
+
do {
|
|
2113
|
+
guide?.takeRetry();
|
|
2114
|
+
guide?.update("bridge", "Extension and token detected. Testing the browser connection…");
|
|
2115
|
+
try {
|
|
2116
|
+
const probe = await callServe(
|
|
2117
|
+
rechUrl.toString(),
|
|
2118
|
+
[`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
|
|
2119
|
+
probeEnv,
|
|
2120
|
+
probeIdentity,
|
|
2121
|
+
true,
|
|
2122
|
+
);
|
|
2123
|
+
bridgeVerified = probe.status === 0;
|
|
2124
|
+
if (bridgeVerified) {
|
|
2125
|
+
console.log("✓ [4/5] Extension bridge connected successfully");
|
|
2126
|
+
} else {
|
|
2127
|
+
const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
|
|
2128
|
+
bridgeError = diagnostic;
|
|
2129
|
+
console.error(` Extension bridge verification failed: ${diagnostic}`);
|
|
2130
|
+
}
|
|
2131
|
+
} catch (error) {
|
|
2132
|
+
bridgeError = error instanceof Error ? error.message : String(error);
|
|
2133
|
+
console.error(` Extension bridge verification failed: ${bridgeError}`);
|
|
2134
|
+
} finally {
|
|
2135
|
+
// The isolated probe must never claim or close an existing worktree session.
|
|
2136
|
+
await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity, true).catch(() => {});
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
if (bridgeVerified) break;
|
|
2140
|
+
const safeError = bridgeError.replaceAll(token, "[redacted]").replaceAll(serveKey, "[redacted]").slice(0, 700);
|
|
2141
|
+
guide?.update("error", `Connection failed: ${safeError}. Check the daemon and reload the extension if needed, then click Retry connection. A replacement token can also be pasted below.`);
|
|
2142
|
+
while (Date.now() < bridgeDeadline && !guide?.takeRetry()) await Bun.sleep(500);
|
|
2143
|
+
const replacement = guide?.takeToken() || (userDataDir ? readExtensionTokenFromProfile(userDataDir, profileDir, extId) : null);
|
|
2144
|
+
if (replacement) {
|
|
2145
|
+
token = replacement;
|
|
2146
|
+
rechUrl.searchParams.set("token", token);
|
|
2147
|
+
probeEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN = token;
|
|
2148
|
+
}
|
|
2149
|
+
} while (Date.now() < bridgeDeadline);
|
|
2150
|
+
if (!bridgeVerified) {
|
|
2151
|
+
guide?.update("error", "Connection check timed out. Run setup again after reloading the extension.");
|
|
2152
|
+
await Bun.sleep(1500);
|
|
2153
|
+
guide?.close();
|
|
2154
|
+
rl?.close();
|
|
2155
|
+
envWatcher?.close();
|
|
2156
|
+
process.exitCode = 1;
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
guide?.update("save", "Browser connection verified! Finish the configuration prompts in the terminal.");
|
|
2160
|
+
await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
|
|
2161
|
+
const existingPrefix = prefix !== "/" ? (await readListeners())?.listeners.find(l => l.host === desiredBind && l.profiles !== "*" && normalizePrefix(l.prefix) === prefix) : undefined;
|
|
2162
|
+
const exposurePort = opts.port ?? existingPrefix?.port ?? (prefix !== "/" ? port + 1 : port);
|
|
2163
|
+
const exposure = await exposeProfile(profileEmail, desiredBind, exposurePort, prefix);
|
|
2164
|
+
const tailscaleChoice = listenChoices.find(c => c.kind === "tailscale")?.address;
|
|
2165
|
+
rechUrl.hostname = exposure.host;
|
|
2166
|
+
rechUrl.port = String(exposure.port);
|
|
2167
|
+
rechUrl.username = exposure.key;
|
|
2168
|
+
rechUrl.pathname = prefix;
|
|
2169
|
+
// Ensure the newly bound listener is live before offering its connection URL.
|
|
2170
|
+
let exposed = false;
|
|
2171
|
+
for (let i = 0; i < 20; i++) {
|
|
2172
|
+
const response = await fetch(serviceUrl(rechUrl.toString(), "ping"), { headers: { Authorization: `Bearer ${exposure.key}` }, signal: AbortSignal.timeout(1000) }).catch(() => null);
|
|
2173
|
+
if (response?.ok) { exposed = true; break; }
|
|
2174
|
+
await Bun.sleep(250);
|
|
2175
|
+
}
|
|
2176
|
+
if (!exposed) throw new Error("Profile registered, but its listener did not become reachable. Check for an occupied port or an unavailable interface.");
|
|
2177
|
+
if (prefix !== "/" && exposure.host === "127.0.0.1") {
|
|
2178
|
+
// Detect only; never change the Serve config without the user running the command themselves.
|
|
2179
|
+
const serve = await detectTailscaleServe(exposure.port, prefix);
|
|
2180
|
+
if (serve.routeUrl) {
|
|
2181
|
+
console.log(` Tailscale Serve route found: ${serve.routeUrl} → ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
|
|
2182
|
+
console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${tailscaleConnectionUrl(serve.routeUrl, rechUrl.toString())}`);
|
|
1676
2183
|
} else {
|
|
1677
|
-
|
|
1678
|
-
console.
|
|
2184
|
+
console.log(` Tailscale Serve command (run separately): tailscale serve --bg --https=443 --set-path=${prefix.slice(0, -1)} ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
|
|
2185
|
+
if (serve.dnsName) console.log(` Then remote clients can use:\n ${tailscaleConnectionUrl(`https://${serve.dnsName}${prefix}`, rechUrl.toString())}`);
|
|
2186
|
+
else console.log(` For remote clients, use https://<your-machine-tailnet-name>${prefix} with this listener's bearer key and profile query.`);
|
|
1679
2187
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
2188
|
+
console.log(` The URL saved below is the local loopback one.`);
|
|
2189
|
+
} else if (tailscaleChoice && exposure.host === tailscaleChoice) {
|
|
2190
|
+
console.log(` This binds the raw Tailscale IP over plain HTTP. For an HTTPS tailnet URL instead, run:`);
|
|
2191
|
+
console.log(` rech setup --profile ${JSON.stringify(profileEmail)} --listen local --prefix=rechrome`);
|
|
2192
|
+
console.log(` then expose it with tailscale serve (setup prints the command and the remote URL).`);
|
|
1685
2193
|
}
|
|
2194
|
+
const newLine = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
|
|
1686
2195
|
|
|
1687
|
-
console.log(
|
|
2196
|
+
console.log("\n○ [5/5] Connection verified. Save connection configuration:");
|
|
2197
|
+
console.log(`\n${newLine}\n`);
|
|
1688
2198
|
if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
|
|
1689
2199
|
|
|
1690
2200
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
@@ -1694,7 +2204,7 @@ async function setup(opts: { profile?: string; token?: string; yes?: boolean } =
|
|
|
1694
2204
|
const tag = async (p: string) => (await file(p).exists()) ? "exists → will update" : "new file";
|
|
1695
2205
|
const [pwdTag, pwdRechTag, homeTag] = await Promise.all([tag(pwdEnvPath), tag(pwdRechPath), tag(homeEnvPath)]);
|
|
1696
2206
|
const saveChoice = (await ask(
|
|
1697
|
-
`Save to:\n 1. ${pwdEnvPath} (current dir) [${pwdTag}] [default]\n 2. ${pwdRechPath} (current dir, rechrome-only) [${pwdRechTag}]\n 3. ${homeEnvPath} (user home) [${homeTag}]\n 4. Skip (
|
|
2207
|
+
`Save to:\n 1. ${pwdEnvPath} (current dir) [${pwdTag}] [default]\n 2. ${pwdRechPath} (current dir, rechrome-only) [${pwdRechTag}]\n 3. ${homeEnvPath} (user home) [${homeTag}]\n 4. Skip saving URL (register profile only)\n\n Choice [1]: `
|
|
1698
2208
|
)).trim();
|
|
1699
2209
|
if (saveChoice !== "4") {
|
|
1700
2210
|
const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
|
|
@@ -1713,6 +2223,11 @@ async function setup(opts: { profile?: string; token?: string; yes?: boolean } =
|
|
|
1713
2223
|
// Save primary to token registry
|
|
1714
2224
|
await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
|
|
1715
2225
|
|
|
2226
|
+
console.log("✓ [5/5] Profile registration saved");
|
|
2227
|
+
guide?.update("ready", "Success — extension installed, token detected, browser connected, and profile registered.");
|
|
2228
|
+
await guide?.deliverSuccess();
|
|
2229
|
+
guide?.close();
|
|
2230
|
+
|
|
1716
2231
|
// Additional profiles
|
|
1717
2232
|
const configured = new Set([profileDir]);
|
|
1718
2233
|
while (true) {
|
|
@@ -1728,9 +2243,13 @@ async function setup(opts: { profile?: string; token?: string; yes?: boolean } =
|
|
|
1728
2243
|
const extraEmail = extraInfo.user_name || extraDir;
|
|
1729
2244
|
console.log(`\n Setting up: ${extraDisplay}`);
|
|
1730
2245
|
const result = await getExtAndToken(extraDir, extraDisplay, extraEmail);
|
|
1731
|
-
if (!result) { console.log(" Skipped."); continue; }
|
|
2246
|
+
if (!result) { guide?.close(); console.log(" Skipped."); continue; }
|
|
1732
2247
|
await saveTokenEntry(extraEmail, { extensionId: result.extId, token: result.token, profileDir: extraDir, userDataDir: userDataDir ?? undefined });
|
|
1733
2248
|
configured.add(extraDir);
|
|
2249
|
+
guide?.update("bridge", "Token registered. Run setup for this profile to verify its browser connection.");
|
|
2250
|
+
guide?.markRegistered();
|
|
2251
|
+
await Bun.sleep(1500);
|
|
2252
|
+
guide?.close();
|
|
1734
2253
|
console.log(` Saved token for ${extraDisplay}`);
|
|
1735
2254
|
}
|
|
1736
2255
|
rl?.close();
|
|
@@ -1749,11 +2268,11 @@ async function status(): Promise<void> {
|
|
|
1749
2268
|
}
|
|
1750
2269
|
const { host, port, protocol } = parseUrl(url);
|
|
1751
2270
|
const parsed = parseUrl(url);
|
|
1752
|
-
const ping = await fetch(
|
|
2271
|
+
const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
1753
2272
|
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
1754
2273
|
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
1755
2274
|
const pingBody = ping
|
|
1756
|
-
? await fetch(
|
|
2275
|
+
? await fetch(serviceUrl(url, "ping"), {
|
|
1757
2276
|
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
1758
2277
|
signal: AbortSignal.timeout(2000),
|
|
1759
2278
|
}).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
@@ -1799,15 +2318,17 @@ Usage:
|
|
|
1799
2318
|
must already be registered (see \`rech setup\`). Place
|
|
1800
2319
|
--profile before the playwright subcommand. Requires
|
|
1801
2320
|
${ENV_KEY}.
|
|
1802
|
-
rech setup [--profile <email|name|folder>] [--token <tok>] [--yes]
|
|
2321
|
+
rech setup [--listen <local|lan|tailscale|IP>] [--profile <email|name|folder>] [--token <tok>] [--prefix <path>] [--port <port>] [--yes]
|
|
1803
2322
|
First-time setup: daemon + Chrome extension + config
|
|
2323
|
+
--prefix=rechrome mounts at /rechrome/ on a scoped listener.
|
|
2324
|
+
Prefixed setup defaults to the management port + 1; override with --port.
|
|
1804
2325
|
Offers to install missing oxmgr globally (y/N).
|
|
1805
2326
|
--yes approves installation without prompting.
|
|
1806
2327
|
--profile selects the Chrome profile non-interactively.
|
|
1807
2328
|
Menu numbers are not accepted. Resolution order is exact
|
|
1808
2329
|
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
1809
2330
|
then exact profile folder name (e.g. "Profile 1"). See
|
|
1810
|
-
available values with \`rech
|
|
2331
|
+
available values with \`rech profile\`.
|
|
1811
2332
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1812
2333
|
non-TTY/agent runs, skipping the interactive paste
|
|
1813
2334
|
rech provision-profile <name> --experimental [--headed]
|
|
@@ -1821,7 +2342,12 @@ Usage:
|
|
|
1821
2342
|
starts after \`rech setup\`; skipped with no GUI
|
|
1822
2343
|
rech uninstall Remove the serve daemon and clear config
|
|
1823
2344
|
rech serve Start the serve server manually (foreground)
|
|
1824
|
-
rech
|
|
2345
|
+
rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
|
|
2346
|
+
rech profile [ls|list]
|
|
2347
|
+
List Chrome + managed test profiles and connection status
|
|
2348
|
+
rech profile [name] --print-uri [--listener <name>]
|
|
2349
|
+
Print a connection URI (includes the daemon key).
|
|
2350
|
+
Uses RECHROME_URL, or an explicitly selected local listener.
|
|
1825
2351
|
rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
|
|
1826
2352
|
rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
|
|
1827
2353
|
fragile single-shot flow (OAuth/login) never shares tabs
|
|
@@ -1833,6 +2359,7 @@ Environment:
|
|
|
1833
2359
|
RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
|
|
1834
2360
|
client reuses is keyed on the worktree root path; \`branch\` restores the
|
|
1835
2361
|
old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
|
|
2362
|
+
RECH_SETUP_AGENT Setup hints: codex | claude | none (otherwise auto-detected)
|
|
1836
2363
|
|
|
1837
2364
|
Examples:
|
|
1838
2365
|
rech setup
|
|
@@ -1843,55 +2370,114 @@ Examples:
|
|
|
1843
2370
|
rech screenshot`);
|
|
1844
2371
|
}
|
|
1845
2372
|
|
|
2373
|
+
export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
|
|
2374
|
+
export type RechHandlers = {
|
|
2375
|
+
serve(): Promise<void> | void;
|
|
2376
|
+
status(): Promise<void>;
|
|
2377
|
+
listListeners(): Promise<void>;
|
|
2378
|
+
addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
|
|
2379
|
+
removeListener(name: string): Promise<void>;
|
|
2380
|
+
listProfiles(): Promise<void>;
|
|
2381
|
+
printProfileUri(selector?: string, listener?: string): Promise<void>;
|
|
2382
|
+
setup(opts: SetupOptions): Promise<void>;
|
|
2383
|
+
tray(action?: string): Promise<void>;
|
|
2384
|
+
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
2385
|
+
uninstall(): Promise<void>;
|
|
2386
|
+
};
|
|
2387
|
+
|
|
2388
|
+
/** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
|
|
2389
|
+
export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "setup", "tray", "provision-profile", "uninstall"]);
|
|
2390
|
+
|
|
2391
|
+
const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
|
|
2392
|
+
|
|
2393
|
+
export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
2394
|
+
return yargs(argv)
|
|
2395
|
+
.scriptName("rech")
|
|
2396
|
+
.parserConfiguration({ "parse-numbers": false, "parse-positional-numbers": false })
|
|
2397
|
+
.command("serve", "Run the rechrome daemon in the foreground", {}, () => handlers.serve())
|
|
2398
|
+
.command("status", "Show daemon, relay and profile connection status", {}, () => handlers.status())
|
|
2399
|
+
.command(["listener", "listeners"], "Manage network listeners", y => y
|
|
2400
|
+
.command(["ls", "list", "$0"], "List listeners (credentials hidden)", {}, () => handlers.listListeners())
|
|
2401
|
+
.command("add <name>", "Expose registered profiles on a network", y => y
|
|
2402
|
+
.positional("name", { type: "string", demandOption: true })
|
|
2403
|
+
.option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2404
|
+
.option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
|
|
2405
|
+
.option("port", portOption)
|
|
2406
|
+
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
|
|
2407
|
+
a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
|
|
2408
|
+
.command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
|
|
2409
|
+
a => handlers.removeListener(a.name))
|
|
2410
|
+
.demandCommand(1).strict())
|
|
2411
|
+
.command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
|
|
2412
|
+
.positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
|
|
2413
|
+
.option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
|
|
2414
|
+
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
|
|
2415
|
+
a => {
|
|
2416
|
+
if (a.printUri) return handlers.printProfileUri(a.name, a.listener);
|
|
2417
|
+
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2418
|
+
throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
|
|
2419
|
+
})
|
|
2420
|
+
.command("setup", "Install the daemon and connect a Chrome profile", y => y
|
|
2421
|
+
.option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: email, name or folder" })
|
|
2422
|
+
.option("token", { type: "string", requiresArg: true, describe: "Extension token (default: read from the profile, or RECH_TOKEN)" })
|
|
2423
|
+
.option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
|
|
2424
|
+
.option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix for a scoped listener, e.g. rechrome" })
|
|
2425
|
+
.option("port", portOption)
|
|
2426
|
+
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2427
|
+
a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
|
|
2428
|
+
.command("tray [action]", "Show or hide the tray icon", y => y
|
|
2429
|
+
.positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
|
|
2430
|
+
a => handlers.tray(a.action))
|
|
2431
|
+
.command("provision-profile <name>", "Create a managed Chrome-for-Testing profile (experimental)", y => y
|
|
2432
|
+
.positional("name", { type: "string", demandOption: true })
|
|
2433
|
+
.option("experimental", { type: "boolean", default: false })
|
|
2434
|
+
.option("headed", { type: "boolean", default: false }),
|
|
2435
|
+
a => handlers.provisionProfile(a.name, { headed: a.headed, experimental: a.experimental }))
|
|
2436
|
+
.command("uninstall", "Stop and remove the rechrome daemon", {}, () => handlers.uninstall())
|
|
2437
|
+
.demandCommand(1)
|
|
2438
|
+
.strict()
|
|
2439
|
+
.help()
|
|
2440
|
+
.version(false)
|
|
2441
|
+
.fail((message, error) => { throw error ?? new Error(message); });
|
|
2442
|
+
}
|
|
2443
|
+
|
|
1846
2444
|
if (import.meta.main) {
|
|
1847
2445
|
let args = process.argv.slice(2);
|
|
1848
2446
|
const cmd = args[0]?.toLowerCase();
|
|
1849
2447
|
|
|
1850
|
-
if (cmd
|
|
1851
|
-
const
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
1883
|
-
if (!experimental) {
|
|
1884
|
-
console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
|
|
1885
|
-
console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
|
|
1886
|
-
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
|
|
1887
|
-
console.error(`To proceed anyway, re-run with --experimental.`);
|
|
1888
|
-
process.exit(1);
|
|
2448
|
+
if (cmd && RECH_COMMANDS.has(cmd)) {
|
|
2449
|
+
const handlers: RechHandlers = {
|
|
2450
|
+
serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
|
|
2451
|
+
status,
|
|
2452
|
+
listListeners, addListener, removeListener, listProfiles, printProfileUri,
|
|
2453
|
+
setup: async (opts) => {
|
|
2454
|
+
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
2455
|
+
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
|
2456
|
+
await startTray({ quiet: true }).catch(() => {});
|
|
2457
|
+
},
|
|
2458
|
+
tray: trayCommand,
|
|
2459
|
+
provisionProfile: async (name, { headed, experimental }) => {
|
|
2460
|
+
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
2461
|
+
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
2462
|
+
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
2463
|
+
if (!experimental) throw new Error([
|
|
2464
|
+
`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
|
|
2465
|
+
`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
|
|
2466
|
+
`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
|
|
2467
|
+
`To proceed anyway, re-run with --experimental.`,
|
|
2468
|
+
].join("\n"));
|
|
2469
|
+
await provisionProfile(name, { headed });
|
|
2470
|
+
},
|
|
2471
|
+
uninstall: daemonUninstall,
|
|
2472
|
+
};
|
|
2473
|
+
try {
|
|
2474
|
+
await rechCli([cmd, ...args.slice(1)], handlers).parseAsync();
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2477
|
+
process.exitCode = 1;
|
|
2478
|
+
} finally {
|
|
2479
|
+
if (cmd !== "serve") envWatcher?.close();
|
|
1889
2480
|
}
|
|
1890
|
-
await provisionProfile(name, { headed });
|
|
1891
|
-
envWatcher?.close();
|
|
1892
|
-
} else if (cmd === "uninstall") {
|
|
1893
|
-
await daemonUninstall();
|
|
1894
|
-
envWatcher?.close();
|
|
1895
2481
|
} else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
|
|
1896
2482
|
printHelp();
|
|
1897
2483
|
envWatcher?.close();
|
|
@@ -1927,7 +2513,6 @@ if (import.meta.main) {
|
|
|
1927
2513
|
overrideEnv = {
|
|
1928
2514
|
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
|
|
1929
2515
|
PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
|
|
1930
|
-
PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
|
|
1931
2516
|
};
|
|
1932
2517
|
if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
|
|
1933
2518
|
if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
|