rechrome 1.22.0 → 1.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extension/lib/background.mjs +16 -3
- package/extension/lib/ui/connect.js +39 -3
- package/extension/manifest.json +2 -1
- package/package.json +1 -1
- package/rech.js +210 -74
- package/rech.ts +210 -74
- package/serve.js +33 -1
- package/serve.ts +33 -1
|
@@ -129,6 +129,7 @@ class RelayConnection {
|
|
|
129
129
|
__publicField(this, "_hasEverAttached", false);
|
|
130
130
|
__publicField(this, "_eventListeners", []);
|
|
131
131
|
__publicField(this, "_closed", false);
|
|
132
|
+
__publicField(this, "_keepAliveInterval");
|
|
132
133
|
__publicField(this, "onclose");
|
|
133
134
|
__publicField(this, "ontabattached");
|
|
134
135
|
__publicField(this, "ontabdetached");
|
|
@@ -143,6 +144,9 @@ class RelayConnection {
|
|
|
143
144
|
this._installEventForwarders();
|
|
144
145
|
this._ws.onmessage = this._onMessage.bind(this);
|
|
145
146
|
this._ws.onclose = () => this._onClose();
|
|
147
|
+
this._keepAliveInterval = setInterval(() => {
|
|
148
|
+
this._sendMessage({ method: "extension.keepalive", params: [] });
|
|
149
|
+
}, 2e4);
|
|
146
150
|
}
|
|
147
151
|
get attachedTabs() {
|
|
148
152
|
return this._attachedTabs;
|
|
@@ -203,6 +207,7 @@ class RelayConnection {
|
|
|
203
207
|
if (this._closed)
|
|
204
208
|
return;
|
|
205
209
|
this._closed = true;
|
|
210
|
+
clearInterval(this._keepAliveInterval);
|
|
206
211
|
for (const l of this._eventListeners)
|
|
207
212
|
l.remove();
|
|
208
213
|
this._eventListeners = [];
|
|
@@ -352,18 +357,23 @@ class PendingConnections {
|
|
|
352
357
|
}
|
|
353
358
|
}
|
|
354
359
|
async function openRelayConnection(mcpRelayUrl, protocolVersion) {
|
|
360
|
+
let socket;
|
|
361
|
+
let timer;
|
|
355
362
|
try {
|
|
356
|
-
|
|
363
|
+
socket = new WebSocket(mcpRelayUrl);
|
|
357
364
|
await new Promise((resolve, reject) => {
|
|
358
365
|
socket.onopen = () => resolve();
|
|
359
366
|
socket.onerror = () => reject(new Error("WebSocket error"));
|
|
360
|
-
setTimeout(() => reject(new Error("Connection timeout")), 5e3);
|
|
367
|
+
timer = setTimeout(() => reject(new Error("Connection timeout")), 5e3);
|
|
361
368
|
});
|
|
362
369
|
return new RelayConnection(socket, protocolVersion);
|
|
363
370
|
} catch (error) {
|
|
371
|
+
socket == null ? void 0 : socket.close();
|
|
364
372
|
const message = `Failed to connect to MCP relay: ${error.message}`;
|
|
365
373
|
debugLog(message);
|
|
366
374
|
throw new Error(message);
|
|
375
|
+
} finally {
|
|
376
|
+
clearTimeout(timer);
|
|
367
377
|
}
|
|
368
378
|
}
|
|
369
379
|
const PLAYWRIGHT_GROUP_TITLE = "pw";
|
|
@@ -595,7 +605,10 @@ class PlaywrightExtension {
|
|
|
595
605
|
}
|
|
596
606
|
async _connectTab(selectorTabId, tab, clientName) {
|
|
597
607
|
try {
|
|
598
|
-
await
|
|
608
|
+
await Promise.race([
|
|
609
|
+
this._cleanupPromise,
|
|
610
|
+
new Promise((resolve) => setTimeout(resolve, 2e3))
|
|
611
|
+
]);
|
|
599
612
|
const connection = await this._pendingConnections.take(selectorTabId);
|
|
600
613
|
if (!connection)
|
|
601
614
|
throw new Error("Pending client connection closed");
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { c as clientExports, j as jsxRuntimeExports, r as reactExports, A as AuthTokenSection, T as TabItem, B as Button, g as getOrCreateAuthToken } from "./authToken.js";
|
|
2
2
|
const SUPPORTED_PROTOCOL_VERSION = 2;
|
|
3
|
+
const BACKGROUND_RESPONSE_TIMEOUT_MS = 1e4;
|
|
4
|
+
async function sendMessageWithTimeout(message, simulateHang = false) {
|
|
5
|
+
let timer;
|
|
6
|
+
try {
|
|
7
|
+
return await Promise.race([
|
|
8
|
+
simulateHang ? new Promise(() => {
|
|
9
|
+
}) : chrome.runtime.sendMessage(message),
|
|
10
|
+
new Promise((_, reject) => {
|
|
11
|
+
timer = setTimeout(() => reject(new Error("Extension service worker did not respond")), BACKGROUND_RESPONSE_TIMEOUT_MS);
|
|
12
|
+
})
|
|
13
|
+
]);
|
|
14
|
+
} finally {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
3
18
|
const ConnectApp = () => {
|
|
4
19
|
const [tabs, setTabs] = reactExports.useState([]);
|
|
5
20
|
const [status, setStatus] = reactExports.useState(null);
|
|
@@ -13,6 +28,8 @@ const ConnectApp = () => {
|
|
|
13
28
|
const runAsync = async () => {
|
|
14
29
|
const params = new URLSearchParams(window.location.search);
|
|
15
30
|
const relayUrl = params.get("mcpRelayUrl");
|
|
31
|
+
const recoveryAttempt = params.get("recoveryAttempt") === "1";
|
|
32
|
+
const hasAutomationToken = !!params.get("token");
|
|
16
33
|
if (!relayUrl) {
|
|
17
34
|
setError("Missing mcpRelayUrl parameter in URL.");
|
|
18
35
|
return;
|
|
@@ -53,7 +70,20 @@ const ConnectApp = () => {
|
|
|
53
70
|
});
|
|
54
71
|
return;
|
|
55
72
|
}
|
|
56
|
-
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await sendMessageWithTimeout(
|
|
76
|
+
{ type: "connectionRequested", mcpRelayUrl: relayUrl, protocolVersion: requestedVersion },
|
|
77
|
+
params.get("testHangOnce") === "1" && !recoveryAttempt
|
|
78
|
+
);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (hasAutomationToken && !recoveryAttempt) {
|
|
81
|
+
setError("Extension service worker is not responding. Retrying once…");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setError(`Extension service worker did not recover: ${error.message}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
57
87
|
if (!response.success) {
|
|
58
88
|
setError(response.error);
|
|
59
89
|
return;
|
|
@@ -90,7 +120,7 @@ const ConnectApp = () => {
|
|
|
90
120
|
const handleConnectToTab = reactExports.useCallback(async (tab, clientName = clientInfo) => {
|
|
91
121
|
setShowTabList(false);
|
|
92
122
|
try {
|
|
93
|
-
const response = await
|
|
123
|
+
const response = await sendMessageWithTimeout({
|
|
94
124
|
type: "connectToTab",
|
|
95
125
|
tab,
|
|
96
126
|
clientName
|
|
@@ -104,12 +134,18 @@ const ConnectApp = () => {
|
|
|
104
134
|
});
|
|
105
135
|
}
|
|
106
136
|
} catch (e) {
|
|
137
|
+
const recoveryAttempt = new URLSearchParams(window.location.search).get("recoveryAttempt") === "1";
|
|
138
|
+
const hasAutomationToken = !!new URLSearchParams(window.location.search).get("token");
|
|
139
|
+
if (hasAutomationToken && !recoveryAttempt) {
|
|
140
|
+
setError("Extension service worker stopped during connection. Retrying once…");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
107
143
|
setStatus({
|
|
108
144
|
type: "error",
|
|
109
145
|
message: `"${clientName}" failed to connect: ${e}`
|
|
110
146
|
});
|
|
111
147
|
}
|
|
112
|
-
}, [clientInfo]);
|
|
148
|
+
}, [clientInfo, setError]);
|
|
113
149
|
reactExports.useEffect(() => {
|
|
114
150
|
const listener = (message) => {
|
|
115
151
|
if (message.type === "pendingConnectionClosed") {
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Playwright Extension",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"description": "Connect your browser to AI agents through Playwright MCP server and CLI. Enables AI-driven web testing, debugging, and automation.",
|
|
6
|
+
"minimum_chrome_version": "116",
|
|
6
7
|
"permissions": [
|
|
7
8
|
"debugger",
|
|
8
9
|
"activeTab",
|
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
|
|
|
6
6
|
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
import { spawn as cpSpawn } from "child_process";
|
|
9
|
+
import { pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
|
|
9
10
|
|
|
10
11
|
export const ENV_KEY = "RECHROME_URL";
|
|
11
12
|
export const DEFAULT_PORT = 13775;
|
|
@@ -350,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
|
|
|
350
351
|
];
|
|
351
352
|
};
|
|
352
353
|
|
|
353
|
-
|
|
354
|
+
type ChromeProfileInfo = { user_name?: string; name?: string };
|
|
355
|
+
|
|
356
|
+
async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
|
|
354
357
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
355
358
|
const f = file(statePath);
|
|
356
359
|
if (!(await f.exists())) continue;
|
|
@@ -362,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
|
|
|
362
365
|
return null;
|
|
363
366
|
}
|
|
364
367
|
|
|
368
|
+
export function resolveChromeProfileSelector(
|
|
369
|
+
profiles: Array<[string, ChromeProfileInfo]>,
|
|
370
|
+
selector: string,
|
|
371
|
+
): [string, ChromeProfileInfo] | null {
|
|
372
|
+
const value = selector.trim();
|
|
373
|
+
validateChromeProfileSelector(value);
|
|
374
|
+
|
|
375
|
+
const needle = value.toLowerCase();
|
|
376
|
+
const selectors: Array<{ label: string; value: (dir: string, info: ChromeProfileInfo) => string }> = [
|
|
377
|
+
{ label: "email", value: (_dir, info) => info.user_name ?? "" },
|
|
378
|
+
{ label: "Chrome profile name", value: (_dir, info) => info.name ?? "" },
|
|
379
|
+
{ label: "profile folder name", value: (dir) => dir },
|
|
380
|
+
];
|
|
381
|
+
for (const kind of selectors) {
|
|
382
|
+
const matches = profiles.filter(([dir, info]) => kind.value(dir, info).trim().toLowerCase() === needle);
|
|
383
|
+
if (matches.length > 1) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`--profile "${value}" matches multiple profiles by ${kind.label}. ` +
|
|
386
|
+
`Use a unique email or profile folder name from \`rech profiles\`.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (matches.length === 1) return matches[0];
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function validateChromeProfileSelector(selector: string): void {
|
|
395
|
+
const value = selector.trim();
|
|
396
|
+
if (!/^\d+$/.test(value)) return;
|
|
397
|
+
throw new Error(
|
|
398
|
+
`--profile no longer accepts menu numbers (received "${value}"). ` +
|
|
399
|
+
`Use the profile email, Chrome profile name, or profile folder name from \`rech profiles\`.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
365
403
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
366
404
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
367
405
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -479,15 +517,14 @@ async function listProfiles(): Promise<void> {
|
|
|
479
517
|
}
|
|
480
518
|
}
|
|
481
519
|
|
|
482
|
-
//
|
|
483
|
-
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
520
|
+
// Columns mirror selector precedence. Only user_name (email) + name (profile name) are read
|
|
484
521
|
// from Local State — the gaia real name is deliberately never surfaced.
|
|
485
522
|
const rows = [
|
|
486
|
-
["
|
|
523
|
+
["EMAIL", "PROFILE NAME", "FOLDER", ""],
|
|
487
524
|
...Object.entries(cache).map(([dir, info]) => [
|
|
488
|
-
dir,
|
|
489
525
|
info.user_name || "",
|
|
490
526
|
info.name || "",
|
|
527
|
+
dir,
|
|
491
528
|
dir === currentDir ? "← current" : "",
|
|
492
529
|
]),
|
|
493
530
|
];
|
|
@@ -545,7 +582,16 @@ async function callServe(
|
|
|
545
582
|
return res.json();
|
|
546
583
|
}
|
|
547
584
|
|
|
585
|
+
export function normalizeCommandArgs(args: string[]): string[] {
|
|
586
|
+
const normalized = [...args];
|
|
587
|
+
if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
|
|
588
|
+
return normalized;
|
|
589
|
+
}
|
|
590
|
+
|
|
548
591
|
async function run(url: string, args: string[]) {
|
|
592
|
+
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
|
+
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
|
+
args = normalizeCommandArgs(args);
|
|
549
595
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
550
596
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
551
597
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
@@ -569,10 +615,17 @@ async function run(url: string, args: string[]) {
|
|
|
569
615
|
if (stderr.includes('Extension connection timeout')) {
|
|
570
616
|
const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
571
617
|
const last = hasToken
|
|
572
|
-
? ` -x: extension token
|
|
618
|
+
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
573
619
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
574
620
|
console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
|
|
575
621
|
}
|
|
622
|
+
if (stderr.includes("Browser '") && stderr.includes("is not open")) {
|
|
623
|
+
console.error(
|
|
624
|
+
`[rech] the session id is derived from this worktree and profile; it is not a persisted stale id. ` +
|
|
625
|
+
`The preceding open did not finish. Retry \`rech open <url>\`; if it reports an extension timeout, ` +
|
|
626
|
+
`reload Playwright MCP Bridge at chrome://extensions and retry.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
576
629
|
process.stderr.write(stderr);
|
|
577
630
|
}
|
|
578
631
|
if (stdout) process.stdout.write(stdout);
|
|
@@ -658,28 +711,80 @@ const PM_PROCESS_NAME = "rechrome";
|
|
|
658
711
|
// Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
|
|
659
712
|
// migrates an existing checkout cleanly.
|
|
660
713
|
const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
|
|
661
|
-
// oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
|
|
662
714
|
const IS_WINDOWS = process.platform === "win32";
|
|
663
|
-
const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
|
|
664
715
|
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
716
|
+
// Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
|
|
717
|
+
// isn't on PATH / doesn't answer. Cached — daemonManager() sits on the hot path
|
|
718
|
+
// of several subcommands (status, setup, install). Synchronous by design so the
|
|
719
|
+
// selection has no await threading through call sites.
|
|
720
|
+
let _oxmgrVersion: string | null | undefined;
|
|
721
|
+
function oxmgrVersion(bin: string): string | null {
|
|
722
|
+
if (_oxmgrVersion !== undefined) return _oxmgrVersion;
|
|
723
|
+
try {
|
|
724
|
+
const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
725
|
+
const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
|
|
726
|
+
_oxmgrVersion = m ? m[1]! : null;
|
|
727
|
+
} catch {
|
|
728
|
+
_oxmgrVersion = null;
|
|
729
|
+
}
|
|
730
|
+
return _oxmgrVersion;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Resolve (and cache) the process manager that daemonizes `rech serve`. The
|
|
734
|
+
// selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
|
|
735
|
+
// RECH_DAEMON_MANAGER override — lives in ./daemon-manager.js (pure + tested);
|
|
736
|
+
// here we just supply the runtime inputs (what's on PATH, the oxmgr version).
|
|
737
|
+
let _daemonMgr: DaemonManager | undefined;
|
|
738
|
+
function daemonManager(): DaemonManager {
|
|
739
|
+
if (_daemonMgr) return _daemonMgr;
|
|
740
|
+
const oxmgrBin = Bun.which("oxmgr");
|
|
741
|
+
const pm2Bin = Bun.which("pm2");
|
|
742
|
+
_daemonMgr = pickDaemonManager({
|
|
743
|
+
oxmgrBin,
|
|
744
|
+
pm2Bin,
|
|
745
|
+
oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
|
|
746
|
+
isWindows: IS_WINDOWS,
|
|
747
|
+
override: process.env.RECH_DAEMON_MANAGER,
|
|
748
|
+
});
|
|
749
|
+
return _daemonMgr;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Spawn the resolved process manager by its absolute path (Bun.which). `env` is
|
|
753
|
+
// merged over process.env for the child: pm2 captures the CLI's environment for
|
|
754
|
+
// the managed process (it has no per-var flag like oxmgr's --env), so install
|
|
755
|
+
// passes daemon env this way.
|
|
756
|
+
async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
|
|
757
|
+
const proc = Bun.spawn([mgr.bin, ...args], {
|
|
670
758
|
stdout: "inherit",
|
|
671
759
|
stderr: "inherit",
|
|
672
|
-
windowsHide: true, // no console-window flash for the
|
|
760
|
+
windowsHide: true, // no console-window flash for the manager child on Windows
|
|
673
761
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
674
762
|
});
|
|
675
763
|
await proc.exited;
|
|
676
764
|
return proc.exitCode ?? 1;
|
|
677
765
|
}
|
|
678
766
|
|
|
767
|
+
// oxmgr boot/login autostart. `service install` wires the platform init
|
|
768
|
+
// integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
|
|
769
|
+
// so the daemon — and the managed serve with it — returns at login/boot, the way
|
|
770
|
+
// the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
|
|
771
|
+
// re-running `service install` re-bootstraps the oxmgr daemon, which restarts
|
|
772
|
+
// every managed process (including the live serve). Best-effort — a failure
|
|
773
|
+
// leaves serve crash-managed but not login-persistent.
|
|
774
|
+
async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
|
|
775
|
+
let alreadyInstalled = false;
|
|
776
|
+
try {
|
|
777
|
+
alreadyInstalled =
|
|
778
|
+
Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
|
|
779
|
+
} catch { /* treat a probe failure as not-installed and attempt install */ }
|
|
780
|
+
if (alreadyInstalled) return;
|
|
781
|
+
await runPm(mgr, ["service", "install"]);
|
|
782
|
+
}
|
|
783
|
+
|
|
679
784
|
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
680
785
|
// Both render the process name verbatim, so callers can substring-match it.
|
|
681
|
-
async function pmList(): Promise<string> {
|
|
682
|
-
const proc = Bun.spawn([
|
|
786
|
+
async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
|
|
787
|
+
const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
683
788
|
return await new Response(proc.stdout).text();
|
|
684
789
|
}
|
|
685
790
|
|
|
@@ -741,25 +846,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
741
846
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
742
847
|
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
743
848
|
|
|
849
|
+
const mgr = daemonManager();
|
|
850
|
+
|
|
744
851
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
745
|
-
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
852
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
746
853
|
|
|
747
854
|
let startCode: number;
|
|
748
|
-
if (
|
|
855
|
+
if (mgr.id === "pm2") {
|
|
749
856
|
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
750
857
|
// autorestarts by default, and runs the bun binary directly with
|
|
751
858
|
// `--interpreter none` (so it isn't fed to node).
|
|
752
|
-
startCode = await runPm([
|
|
859
|
+
startCode = await runPm(mgr, [
|
|
753
860
|
"start", bunBin,
|
|
754
861
|
"--name", PM_PROCESS_NAME,
|
|
755
862
|
"--interpreter", "none",
|
|
756
863
|
"--cwd", home,
|
|
757
864
|
"--", rechScript, "serve",
|
|
758
865
|
], daemonEnv);
|
|
759
|
-
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
866
|
+
await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
760
867
|
} else {
|
|
761
868
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
762
|
-
startCode = await runPm([
|
|
869
|
+
startCode = await runPm(mgr, [
|
|
763
870
|
"start",
|
|
764
871
|
"--name", PM_PROCESS_NAME,
|
|
765
872
|
"--restart", "always",
|
|
@@ -767,18 +874,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
767
874
|
...envArgs,
|
|
768
875
|
`${bunBin} ${rechScript} serve`,
|
|
769
876
|
]);
|
|
770
|
-
|
|
877
|
+
// Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
|
|
878
|
+
// on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
|
|
879
|
+
// path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
|
|
880
|
+
// live daemon.
|
|
881
|
+
await oxmgrEnsureAutostart(mgr);
|
|
771
882
|
}
|
|
772
883
|
// Surface a failed start instead of reporting a daemon that was never registered.
|
|
773
884
|
if (startCode !== 0)
|
|
774
|
-
throw new Error(`${
|
|
885
|
+
throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
|
|
775
886
|
}
|
|
776
887
|
|
|
777
888
|
async function daemonUninstall(): Promise<void> {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
889
|
+
const mgr = daemonManager();
|
|
890
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
891
|
+
if (mgr.id === "pm2") await runPm(mgr, ["save"]);
|
|
892
|
+
else await runPm(mgr, ["service", "uninstall"]);
|
|
893
|
+
console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
|
|
782
894
|
}
|
|
783
895
|
|
|
784
896
|
// ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
|
|
@@ -1076,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1076
1188
|
}
|
|
1077
1189
|
|
|
1078
1190
|
async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
|
|
1191
|
+
if (opts.profile !== undefined) {
|
|
1192
|
+
try {
|
|
1193
|
+
validateChromeProfileSelector(opts.profile);
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1196
|
+
envWatcher?.close();
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1079
1200
|
const { createInterface } = await import("readline");
|
|
1080
1201
|
const isTTY = process.stdin.isTTY ?? false;
|
|
1081
1202
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
@@ -1097,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1097
1218
|
return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
|
|
1098
1219
|
};
|
|
1099
1220
|
|
|
1100
|
-
// [1/
|
|
1101
|
-
console.log("\n[1/
|
|
1221
|
+
// [1/5] Daemon
|
|
1222
|
+
console.log("\n[1/5] Checking serve daemon...");
|
|
1102
1223
|
|
|
1103
1224
|
// Bind address (persists to ~/.env.local as RECH_HOST).
|
|
1104
1225
|
// Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
|
|
@@ -1202,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1202
1323
|
if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
|
|
1203
1324
|
const userDataDir = await findChromeUserDataDir();
|
|
1204
1325
|
|
|
1205
|
-
async function pickProfile(exclude: Set<string>): Promise<[string,
|
|
1326
|
+
async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
|
|
1206
1327
|
const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
|
|
1207
1328
|
if (!available.length) return null;
|
|
1208
1329
|
available.forEach(([dir, info], i) =>
|
|
1209
1330
|
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
1210
1331
|
);
|
|
1211
1332
|
if (opts.profile !== undefined) {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1219
|
-
const sel = available[num - 1] ?? null;
|
|
1220
|
-
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1221
|
-
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1222
|
-
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1223
|
-
console.error(
|
|
1224
|
-
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1225
|
-
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1226
|
-
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1227
|
-
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1228
|
-
);
|
|
1229
|
-
}
|
|
1230
|
-
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1231
|
-
return sel;
|
|
1333
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
1334
|
+
try {
|
|
1335
|
+
match = resolveChromeProfileSelector(available, opts.profile);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
console.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
+
return null;
|
|
1232
1339
|
}
|
|
1233
|
-
const needle = opts.profile.toLowerCase();
|
|
1234
|
-
const match = available.find(([dir, info]) =>
|
|
1235
|
-
dir.toLowerCase() === needle
|
|
1236
|
-
|| (info.name ?? "").toLowerCase() === needle
|
|
1237
|
-
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1238
|
-
) ?? null;
|
|
1239
1340
|
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1240
1341
|
return match;
|
|
1241
1342
|
}
|
|
@@ -1243,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1243
1344
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
1244
1345
|
return available[0];
|
|
1245
1346
|
}
|
|
1246
|
-
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <
|
|
1347
|
+
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <email|name|folder>");
|
|
1247
1348
|
const answer = await ask("\n Profile number: ");
|
|
1248
1349
|
const idx = parseInt(answer.trim()) - 1;
|
|
1249
1350
|
if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
|
|
@@ -1274,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1274
1375
|
console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
|
|
1275
1376
|
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
1276
1377
|
console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
|
|
1277
|
-
console.error(` Then re-run: rech setup --profile <
|
|
1378
|
+
console.error(` Then re-run: rech setup --profile <email|name|folder> [--token <tok>]`);
|
|
1278
1379
|
return null;
|
|
1279
1380
|
}
|
|
1280
1381
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
@@ -1360,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1360
1461
|
return null;
|
|
1361
1462
|
}
|
|
1362
1463
|
|
|
1363
|
-
// [2/
|
|
1364
|
-
console.log("\n[2/
|
|
1464
|
+
// [2/5] Primary profile
|
|
1465
|
+
console.log("\n[2/5] Select Chrome profile:");
|
|
1365
1466
|
const picked = await pickProfile(new Set());
|
|
1366
1467
|
if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
|
|
1367
1468
|
const [profileDir, profileInfoSel] = picked;
|
|
1368
1469
|
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
1369
1470
|
|
|
1370
|
-
// [3
|
|
1371
|
-
console.log("\n[3/
|
|
1471
|
+
// [3/5] Extension + token for primary profile
|
|
1472
|
+
console.log("\n[3/5] Checking extension...");
|
|
1372
1473
|
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
1373
1474
|
const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
|
|
1374
1475
|
if (!primary) { rl?.close(); process.exit(1); }
|
|
1375
1476
|
const { extId, token } = primary;
|
|
1376
1477
|
|
|
1377
|
-
// Build RECHROME_URL
|
|
1478
|
+
// Build RECHROME_URL, verify the selected profile can complete a real extension
|
|
1479
|
+
// handshake, then show it before asking where to save.
|
|
1378
1480
|
const rechUrl = new URL(url);
|
|
1379
1481
|
if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
|
|
1380
1482
|
rechUrl.searchParams.set("extension_id", extId);
|
|
@@ -1382,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1382
1484
|
rechUrl.searchParams.set("profile", profileEmail);
|
|
1383
1485
|
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
1384
1486
|
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
1385
|
-
console.log(`\n[4/
|
|
1487
|
+
console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
|
|
1488
|
+
const probeSession = `iso-setup-${randomBytes(4).toString("hex")}`;
|
|
1489
|
+
const probeIdentity = await getClientIdentity();
|
|
1490
|
+
probeIdentity.profile = profileEmail;
|
|
1491
|
+
const probeEnv: Record<string, string> = {
|
|
1492
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: extId,
|
|
1493
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: token,
|
|
1494
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileEmail,
|
|
1495
|
+
...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
|
|
1496
|
+
};
|
|
1497
|
+
let bridgeVerified = false;
|
|
1498
|
+
try {
|
|
1499
|
+
const probe = await callServe(
|
|
1500
|
+
rechUrl.toString(),
|
|
1501
|
+
[`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
|
|
1502
|
+
probeEnv,
|
|
1503
|
+
probeIdentity,
|
|
1504
|
+
);
|
|
1505
|
+
bridgeVerified = probe.status === 0;
|
|
1506
|
+
if (bridgeVerified) {
|
|
1507
|
+
console.log(" Extension bridge connected successfully");
|
|
1508
|
+
} else {
|
|
1509
|
+
const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
|
|
1510
|
+
console.error(` Extension bridge verification failed: ${diagnostic}`);
|
|
1511
|
+
}
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
console.error(` Extension bridge verification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1514
|
+
} finally {
|
|
1515
|
+
// The isolated probe must never claim or close an existing worktree session.
|
|
1516
|
+
await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity).catch(() => {});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
console.log(`\n[5/5] Your RECHROME_URL:\n\n ${newLine}\n`);
|
|
1386
1520
|
if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
|
|
1387
1521
|
|
|
1388
1522
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
@@ -1433,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1433
1567
|
}
|
|
1434
1568
|
rl?.close();
|
|
1435
1569
|
envWatcher?.close();
|
|
1436
|
-
|
|
1570
|
+
if (bridgeVerified)
|
|
1571
|
+
console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
|
|
1572
|
+
else
|
|
1573
|
+
console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
|
|
1437
1574
|
}
|
|
1438
1575
|
|
|
1439
1576
|
async function status(): Promise<void> {
|
|
@@ -1460,7 +1597,7 @@ async function status(): Promise<void> {
|
|
|
1460
1597
|
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
|
|
1461
1598
|
const pmOut = await pmList();
|
|
1462
1599
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
1463
|
-
console.log(`daemon: ${daemonRegistered ? `${
|
|
1600
|
+
console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
1464
1601
|
const registry = await readTokenRegistry();
|
|
1465
1602
|
const entries = Object.entries(registry);
|
|
1466
1603
|
if (entries.length) {
|
|
@@ -1482,14 +1619,13 @@ function printHelp(): void {
|
|
|
1482
1619
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1483
1620
|
|
|
1484
1621
|
Usage:
|
|
1485
|
-
rech setup [--profile <
|
|
1622
|
+
rech setup [--profile <email|name|folder>] [--token <tok>]
|
|
1486
1623
|
First-time setup: daemon + Chrome extension + config
|
|
1487
1624
|
--profile selects the Chrome profile non-interactively.
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
name ("Profile 1") also matches.
|
|
1625
|
+
Menu numbers are not accepted. Resolution order is exact
|
|
1626
|
+
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
1627
|
+
then exact profile folder name (e.g. "Profile 1"). See
|
|
1628
|
+
available values with \`rech profiles\`.
|
|
1493
1629
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1494
1630
|
non-TTY/agent runs, skipping the interactive paste
|
|
1495
1631
|
rech provision-profile <name> --experimental [--headed]
|
|
@@ -1518,7 +1654,7 @@ Environment:
|
|
|
1518
1654
|
|
|
1519
1655
|
Examples:
|
|
1520
1656
|
rech setup
|
|
1521
|
-
rech setup --profile
|
|
1657
|
+
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1522
1658
|
rech eval "() => document.title"
|
|
1523
1659
|
rech open https://example.com
|
|
1524
1660
|
rech screenshot`);
|
|
@@ -1564,7 +1700,7 @@ if (import.meta.main) {
|
|
|
1564
1700
|
if (!experimental) {
|
|
1565
1701
|
console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
|
|
1566
1702
|
console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
|
|
1567
|
-
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <
|
|
1703
|
+
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
|
|
1568
1704
|
console.error(`To proceed anyway, re-run with --experimental.`);
|
|
1569
1705
|
process.exit(1);
|
|
1570
1706
|
}
|
package/rech.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync
|
|
|
6
6
|
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
import { spawn as cpSpawn } from "child_process";
|
|
9
|
+
import { pickDaemonManager, type DaemonManager } from "./daemon-manager.ts";
|
|
9
10
|
|
|
10
11
|
export const ENV_KEY = "RECHROME_URL";
|
|
11
12
|
export const DEFAULT_PORT = 13775;
|
|
@@ -350,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
|
|
|
350
351
|
];
|
|
351
352
|
};
|
|
352
353
|
|
|
353
|
-
|
|
354
|
+
type ChromeProfileInfo = { user_name?: string; name?: string };
|
|
355
|
+
|
|
356
|
+
async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
|
|
354
357
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
355
358
|
const f = file(statePath);
|
|
356
359
|
if (!(await f.exists())) continue;
|
|
@@ -362,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
|
|
|
362
365
|
return null;
|
|
363
366
|
}
|
|
364
367
|
|
|
368
|
+
export function resolveChromeProfileSelector(
|
|
369
|
+
profiles: Array<[string, ChromeProfileInfo]>,
|
|
370
|
+
selector: string,
|
|
371
|
+
): [string, ChromeProfileInfo] | null {
|
|
372
|
+
const value = selector.trim();
|
|
373
|
+
validateChromeProfileSelector(value);
|
|
374
|
+
|
|
375
|
+
const needle = value.toLowerCase();
|
|
376
|
+
const selectors: Array<{ label: string; value: (dir: string, info: ChromeProfileInfo) => string }> = [
|
|
377
|
+
{ label: "email", value: (_dir, info) => info.user_name ?? "" },
|
|
378
|
+
{ label: "Chrome profile name", value: (_dir, info) => info.name ?? "" },
|
|
379
|
+
{ label: "profile folder name", value: (dir) => dir },
|
|
380
|
+
];
|
|
381
|
+
for (const kind of selectors) {
|
|
382
|
+
const matches = profiles.filter(([dir, info]) => kind.value(dir, info).trim().toLowerCase() === needle);
|
|
383
|
+
if (matches.length > 1) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`--profile "${value}" matches multiple profiles by ${kind.label}. ` +
|
|
386
|
+
`Use a unique email or profile folder name from \`rech profiles\`.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (matches.length === 1) return matches[0];
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function validateChromeProfileSelector(selector: string): void {
|
|
395
|
+
const value = selector.trim();
|
|
396
|
+
if (!/^\d+$/.test(value)) return;
|
|
397
|
+
throw new Error(
|
|
398
|
+
`--profile no longer accepts menu numbers (received "${value}"). ` +
|
|
399
|
+
`Use the profile email, Chrome profile name, or profile folder name from \`rech profiles\`.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
365
403
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
366
404
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
367
405
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -479,15 +517,14 @@ async function listProfiles(): Promise<void> {
|
|
|
479
517
|
}
|
|
480
518
|
}
|
|
481
519
|
|
|
482
|
-
//
|
|
483
|
-
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
520
|
+
// Columns mirror selector precedence. Only user_name (email) + name (profile name) are read
|
|
484
521
|
// from Local State — the gaia real name is deliberately never surfaced.
|
|
485
522
|
const rows = [
|
|
486
|
-
["
|
|
523
|
+
["EMAIL", "PROFILE NAME", "FOLDER", ""],
|
|
487
524
|
...Object.entries(cache).map(([dir, info]) => [
|
|
488
|
-
dir,
|
|
489
525
|
info.user_name || "",
|
|
490
526
|
info.name || "",
|
|
527
|
+
dir,
|
|
491
528
|
dir === currentDir ? "← current" : "",
|
|
492
529
|
]),
|
|
493
530
|
];
|
|
@@ -545,7 +582,16 @@ async function callServe(
|
|
|
545
582
|
return res.json();
|
|
546
583
|
}
|
|
547
584
|
|
|
585
|
+
export function normalizeCommandArgs(args: string[]): string[] {
|
|
586
|
+
const normalized = [...args];
|
|
587
|
+
if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
|
|
588
|
+
return normalized;
|
|
589
|
+
}
|
|
590
|
+
|
|
548
591
|
async function run(url: string, args: string[]) {
|
|
592
|
+
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
|
+
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
|
+
args = normalizeCommandArgs(args);
|
|
549
595
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
550
596
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
551
597
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
@@ -569,10 +615,17 @@ async function run(url: string, args: string[]) {
|
|
|
569
615
|
if (stderr.includes('Extension connection timeout')) {
|
|
570
616
|
const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
571
617
|
const last = hasToken
|
|
572
|
-
? ` -x: extension token
|
|
618
|
+
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
573
619
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
574
620
|
console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
|
|
575
621
|
}
|
|
622
|
+
if (stderr.includes("Browser '") && stderr.includes("is not open")) {
|
|
623
|
+
console.error(
|
|
624
|
+
`[rech] the session id is derived from this worktree and profile; it is not a persisted stale id. ` +
|
|
625
|
+
`The preceding open did not finish. Retry \`rech open <url>\`; if it reports an extension timeout, ` +
|
|
626
|
+
`reload Playwright MCP Bridge at chrome://extensions and retry.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
576
629
|
process.stderr.write(stderr);
|
|
577
630
|
}
|
|
578
631
|
if (stdout) process.stdout.write(stdout);
|
|
@@ -658,28 +711,80 @@ const PM_PROCESS_NAME = "rechrome";
|
|
|
658
711
|
// Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
|
|
659
712
|
// migrates an existing checkout cleanly.
|
|
660
713
|
const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
|
|
661
|
-
// oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
|
|
662
714
|
const IS_WINDOWS = process.platform === "win32";
|
|
663
|
-
const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
|
|
664
715
|
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
716
|
+
// Read the installed oxmgr's version (e.g. "0.4.0+winfix"), or null if oxmgr
|
|
717
|
+
// isn't on PATH / doesn't answer. Cached — daemonManager() sits on the hot path
|
|
718
|
+
// of several subcommands (status, setup, install). Synchronous by design so the
|
|
719
|
+
// selection has no await threading through call sites.
|
|
720
|
+
let _oxmgrVersion: string | null | undefined;
|
|
721
|
+
function oxmgrVersion(bin: string): string | null {
|
|
722
|
+
if (_oxmgrVersion !== undefined) return _oxmgrVersion;
|
|
723
|
+
try {
|
|
724
|
+
const p = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
725
|
+
const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(p.stdout?.toString() ?? "");
|
|
726
|
+
_oxmgrVersion = m ? m[1]! : null;
|
|
727
|
+
} catch {
|
|
728
|
+
_oxmgrVersion = null;
|
|
729
|
+
}
|
|
730
|
+
return _oxmgrVersion;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Resolve (and cache) the process manager that daemonizes `rech serve`. The
|
|
734
|
+
// selection policy — oxmgr default, pm2 fallback, winfix-guarded on Windows,
|
|
735
|
+
// RECH_DAEMON_MANAGER override — lives in ./daemon-manager.ts (pure + tested);
|
|
736
|
+
// here we just supply the runtime inputs (what's on PATH, the oxmgr version).
|
|
737
|
+
let _daemonMgr: DaemonManager | undefined;
|
|
738
|
+
function daemonManager(): DaemonManager {
|
|
739
|
+
if (_daemonMgr) return _daemonMgr;
|
|
740
|
+
const oxmgrBin = Bun.which("oxmgr");
|
|
741
|
+
const pm2Bin = Bun.which("pm2");
|
|
742
|
+
_daemonMgr = pickDaemonManager({
|
|
743
|
+
oxmgrBin,
|
|
744
|
+
pm2Bin,
|
|
745
|
+
oxmgrVersion: oxmgrBin ? oxmgrVersion(oxmgrBin) : null,
|
|
746
|
+
isWindows: IS_WINDOWS,
|
|
747
|
+
override: process.env.RECH_DAEMON_MANAGER,
|
|
748
|
+
});
|
|
749
|
+
return _daemonMgr;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Spawn the resolved process manager by its absolute path (Bun.which). `env` is
|
|
753
|
+
// merged over process.env for the child: pm2 captures the CLI's environment for
|
|
754
|
+
// the managed process (it has no per-var flag like oxmgr's --env), so install
|
|
755
|
+
// passes daemon env this way.
|
|
756
|
+
async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
|
|
757
|
+
const proc = Bun.spawn([mgr.bin, ...args], {
|
|
670
758
|
stdout: "inherit",
|
|
671
759
|
stderr: "inherit",
|
|
672
|
-
windowsHide: true, // no console-window flash for the
|
|
760
|
+
windowsHide: true, // no console-window flash for the manager child on Windows
|
|
673
761
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
674
762
|
});
|
|
675
763
|
await proc.exited;
|
|
676
764
|
return proc.exitCode ?? 1;
|
|
677
765
|
}
|
|
678
766
|
|
|
767
|
+
// oxmgr boot/login autostart. `service install` wires the platform init
|
|
768
|
+
// integration (Windows Task Scheduler, macOS launchd, or a systemd --user unit)
|
|
769
|
+
// so the daemon — and the managed serve with it — returns at login/boot, the way
|
|
770
|
+
// the pm2 path relied on `pm2 resurrect`. Skipped when already installed:
|
|
771
|
+
// re-running `service install` re-bootstraps the oxmgr daemon, which restarts
|
|
772
|
+
// every managed process (including the live serve). Best-effort — a failure
|
|
773
|
+
// leaves serve crash-managed but not login-persistent.
|
|
774
|
+
async function oxmgrEnsureAutostart(mgr: DaemonManager): Promise<void> {
|
|
775
|
+
let alreadyInstalled = false;
|
|
776
|
+
try {
|
|
777
|
+
alreadyInstalled =
|
|
778
|
+
Bun.spawnSync([mgr.bin, "service", "status"], { stdout: "ignore", stderr: "ignore", windowsHide: true }).exitCode === 0;
|
|
779
|
+
} catch { /* treat a probe failure as not-installed and attempt install */ }
|
|
780
|
+
if (alreadyInstalled) return;
|
|
781
|
+
await runPm(mgr, ["service", "install"]);
|
|
782
|
+
}
|
|
783
|
+
|
|
679
784
|
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
680
785
|
// Both render the process name verbatim, so callers can substring-match it.
|
|
681
|
-
async function pmList(): Promise<string> {
|
|
682
|
-
const proc = Bun.spawn([
|
|
786
|
+
async function pmList(mgr: DaemonManager = daemonManager()): Promise<string> {
|
|
787
|
+
const proc = Bun.spawn([mgr.bin, mgr.id === "pm2" ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
683
788
|
return await new Response(proc.stdout).text();
|
|
684
789
|
}
|
|
685
790
|
|
|
@@ -741,25 +846,27 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
741
846
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
742
847
|
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
743
848
|
|
|
849
|
+
const mgr = daemonManager();
|
|
850
|
+
|
|
744
851
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
745
|
-
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
852
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
746
853
|
|
|
747
854
|
let startCode: number;
|
|
748
|
-
if (
|
|
855
|
+
if (mgr.id === "pm2") {
|
|
749
856
|
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
750
857
|
// autorestarts by default, and runs the bun binary directly with
|
|
751
858
|
// `--interpreter none` (so it isn't fed to node).
|
|
752
|
-
startCode = await runPm([
|
|
859
|
+
startCode = await runPm(mgr, [
|
|
753
860
|
"start", bunBin,
|
|
754
861
|
"--name", PM_PROCESS_NAME,
|
|
755
862
|
"--interpreter", "none",
|
|
756
863
|
"--cwd", home,
|
|
757
864
|
"--", rechScript, "serve",
|
|
758
865
|
], daemonEnv);
|
|
759
|
-
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
866
|
+
await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
760
867
|
} else {
|
|
761
868
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
762
|
-
startCode = await runPm([
|
|
869
|
+
startCode = await runPm(mgr, [
|
|
763
870
|
"start",
|
|
764
871
|
"--name", PM_PROCESS_NAME,
|
|
765
872
|
"--restart", "always",
|
|
@@ -767,18 +874,23 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
767
874
|
...envArgs,
|
|
768
875
|
`${bunBin} ${rechScript} serve`,
|
|
769
876
|
]);
|
|
770
|
-
|
|
877
|
+
// Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
|
|
878
|
+
// on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
|
|
879
|
+
// path's `pm2 resurrect` at login. Guarded so a re-install doesn't bounce the
|
|
880
|
+
// live daemon.
|
|
881
|
+
await oxmgrEnsureAutostart(mgr);
|
|
771
882
|
}
|
|
772
883
|
// Surface a failed start instead of reporting a daemon that was never registered.
|
|
773
884
|
if (startCode !== 0)
|
|
774
|
-
throw new Error(`${
|
|
885
|
+
throw new Error(`${mgr.id} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${mgr.id} is installed and on PATH.`);
|
|
775
886
|
}
|
|
776
887
|
|
|
777
888
|
async function daemonUninstall(): Promise<void> {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
889
|
+
const mgr = daemonManager();
|
|
890
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
891
|
+
if (mgr.id === "pm2") await runPm(mgr, ["save"]);
|
|
892
|
+
else await runPm(mgr, ["service", "uninstall"]);
|
|
893
|
+
console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
|
|
782
894
|
}
|
|
783
895
|
|
|
784
896
|
// ── Native tray (menu-bar / system-tray) icon ───────────────────────────────
|
|
@@ -1076,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1076
1188
|
}
|
|
1077
1189
|
|
|
1078
1190
|
async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
|
|
1191
|
+
if (opts.profile !== undefined) {
|
|
1192
|
+
try {
|
|
1193
|
+
validateChromeProfileSelector(opts.profile);
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1196
|
+
envWatcher?.close();
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1079
1200
|
const { createInterface } = await import("readline");
|
|
1080
1201
|
const isTTY = process.stdin.isTTY ?? false;
|
|
1081
1202
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
@@ -1097,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1097
1218
|
return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
|
|
1098
1219
|
};
|
|
1099
1220
|
|
|
1100
|
-
// [1/
|
|
1101
|
-
console.log("\n[1/
|
|
1221
|
+
// [1/5] Daemon
|
|
1222
|
+
console.log("\n[1/5] Checking serve daemon...");
|
|
1102
1223
|
|
|
1103
1224
|
// Bind address (persists to ~/.env.local as RECH_HOST).
|
|
1104
1225
|
// Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
|
|
@@ -1202,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1202
1323
|
if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
|
|
1203
1324
|
const userDataDir = await findChromeUserDataDir();
|
|
1204
1325
|
|
|
1205
|
-
async function pickProfile(exclude: Set<string>): Promise<[string,
|
|
1326
|
+
async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
|
|
1206
1327
|
const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
|
|
1207
1328
|
if (!available.length) return null;
|
|
1208
1329
|
available.forEach(([dir, info], i) =>
|
|
1209
1330
|
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
1210
1331
|
);
|
|
1211
1332
|
if (opts.profile !== undefined) {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1219
|
-
const sel = available[num - 1] ?? null;
|
|
1220
|
-
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1221
|
-
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1222
|
-
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1223
|
-
console.error(
|
|
1224
|
-
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1225
|
-
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1226
|
-
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1227
|
-
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1228
|
-
);
|
|
1229
|
-
}
|
|
1230
|
-
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1231
|
-
return sel;
|
|
1333
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
1334
|
+
try {
|
|
1335
|
+
match = resolveChromeProfileSelector(available, opts.profile);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
console.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
+
return null;
|
|
1232
1339
|
}
|
|
1233
|
-
const needle = opts.profile.toLowerCase();
|
|
1234
|
-
const match = available.find(([dir, info]) =>
|
|
1235
|
-
dir.toLowerCase() === needle
|
|
1236
|
-
|| (info.name ?? "").toLowerCase() === needle
|
|
1237
|
-
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1238
|
-
) ?? null;
|
|
1239
1340
|
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1240
1341
|
return match;
|
|
1241
1342
|
}
|
|
@@ -1243,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1243
1344
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
1244
1345
|
return available[0];
|
|
1245
1346
|
}
|
|
1246
|
-
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <
|
|
1347
|
+
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <email|name|folder>");
|
|
1247
1348
|
const answer = await ask("\n Profile number: ");
|
|
1248
1349
|
const idx = parseInt(answer.trim()) - 1;
|
|
1249
1350
|
if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
|
|
@@ -1274,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1274
1375
|
console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
|
|
1275
1376
|
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
1276
1377
|
console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
|
|
1277
|
-
console.error(` Then re-run: rech setup --profile <
|
|
1378
|
+
console.error(` Then re-run: rech setup --profile <email|name|folder> [--token <tok>]`);
|
|
1278
1379
|
return null;
|
|
1279
1380
|
}
|
|
1280
1381
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
@@ -1360,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1360
1461
|
return null;
|
|
1361
1462
|
}
|
|
1362
1463
|
|
|
1363
|
-
// [2/
|
|
1364
|
-
console.log("\n[2/
|
|
1464
|
+
// [2/5] Primary profile
|
|
1465
|
+
console.log("\n[2/5] Select Chrome profile:");
|
|
1365
1466
|
const picked = await pickProfile(new Set());
|
|
1366
1467
|
if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
|
|
1367
1468
|
const [profileDir, profileInfoSel] = picked;
|
|
1368
1469
|
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
1369
1470
|
|
|
1370
|
-
// [3
|
|
1371
|
-
console.log("\n[3/
|
|
1471
|
+
// [3/5] Extension + token for primary profile
|
|
1472
|
+
console.log("\n[3/5] Checking extension...");
|
|
1372
1473
|
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
1373
1474
|
const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
|
|
1374
1475
|
if (!primary) { rl?.close(); process.exit(1); }
|
|
1375
1476
|
const { extId, token } = primary;
|
|
1376
1477
|
|
|
1377
|
-
// Build RECHROME_URL
|
|
1478
|
+
// Build RECHROME_URL, verify the selected profile can complete a real extension
|
|
1479
|
+
// handshake, then show it before asking where to save.
|
|
1378
1480
|
const rechUrl = new URL(url);
|
|
1379
1481
|
if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
|
|
1380
1482
|
rechUrl.searchParams.set("extension_id", extId);
|
|
@@ -1382,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1382
1484
|
rechUrl.searchParams.set("profile", profileEmail);
|
|
1383
1485
|
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
1384
1486
|
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
1385
|
-
console.log(`\n[4/
|
|
1487
|
+
console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
|
|
1488
|
+
const probeSession = `iso-setup-${randomBytes(4).toString("hex")}`;
|
|
1489
|
+
const probeIdentity = await getClientIdentity();
|
|
1490
|
+
probeIdentity.profile = profileEmail;
|
|
1491
|
+
const probeEnv: Record<string, string> = {
|
|
1492
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: extId,
|
|
1493
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: token,
|
|
1494
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileEmail,
|
|
1495
|
+
...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
|
|
1496
|
+
};
|
|
1497
|
+
let bridgeVerified = false;
|
|
1498
|
+
try {
|
|
1499
|
+
const probe = await callServe(
|
|
1500
|
+
rechUrl.toString(),
|
|
1501
|
+
[`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
|
|
1502
|
+
probeEnv,
|
|
1503
|
+
probeIdentity,
|
|
1504
|
+
);
|
|
1505
|
+
bridgeVerified = probe.status === 0;
|
|
1506
|
+
if (bridgeVerified) {
|
|
1507
|
+
console.log(" Extension bridge connected successfully");
|
|
1508
|
+
} else {
|
|
1509
|
+
const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
|
|
1510
|
+
console.error(` Extension bridge verification failed: ${diagnostic}`);
|
|
1511
|
+
}
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
console.error(` Extension bridge verification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1514
|
+
} finally {
|
|
1515
|
+
// The isolated probe must never claim or close an existing worktree session.
|
|
1516
|
+
await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity).catch(() => {});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
console.log(`\n[5/5] Your RECHROME_URL:\n\n ${newLine}\n`);
|
|
1386
1520
|
if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
|
|
1387
1521
|
|
|
1388
1522
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
@@ -1433,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1433
1567
|
}
|
|
1434
1568
|
rl?.close();
|
|
1435
1569
|
envWatcher?.close();
|
|
1436
|
-
|
|
1570
|
+
if (bridgeVerified)
|
|
1571
|
+
console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
|
|
1572
|
+
else
|
|
1573
|
+
console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
|
|
1437
1574
|
}
|
|
1438
1575
|
|
|
1439
1576
|
async function status(): Promise<void> {
|
|
@@ -1460,7 +1597,7 @@ async function status(): Promise<void> {
|
|
|
1460
1597
|
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${PM_BIN} restart ${PM_PROCESS_NAME}\``);
|
|
1461
1598
|
const pmOut = await pmList();
|
|
1462
1599
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
1463
|
-
console.log(`daemon: ${daemonRegistered ? `${
|
|
1600
|
+
console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
1464
1601
|
const registry = await readTokenRegistry();
|
|
1465
1602
|
const entries = Object.entries(registry);
|
|
1466
1603
|
if (entries.length) {
|
|
@@ -1482,14 +1619,13 @@ function printHelp(): void {
|
|
|
1482
1619
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1483
1620
|
|
|
1484
1621
|
Usage:
|
|
1485
|
-
rech setup [--profile <
|
|
1622
|
+
rech setup [--profile <email|name|folder>] [--token <tok>]
|
|
1486
1623
|
First-time setup: daemon + Chrome extension + config
|
|
1487
1624
|
--profile selects the Chrome profile non-interactively.
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
name ("Profile 1") also matches.
|
|
1625
|
+
Menu numbers are not accepted. Resolution order is exact
|
|
1626
|
+
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
1627
|
+
then exact profile folder name (e.g. "Profile 1"). See
|
|
1628
|
+
available values with \`rech profiles\`.
|
|
1493
1629
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1494
1630
|
non-TTY/agent runs, skipping the interactive paste
|
|
1495
1631
|
rech provision-profile <name> --experimental [--headed]
|
|
@@ -1518,7 +1654,7 @@ Environment:
|
|
|
1518
1654
|
|
|
1519
1655
|
Examples:
|
|
1520
1656
|
rech setup
|
|
1521
|
-
rech setup --profile
|
|
1657
|
+
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1522
1658
|
rech eval "() => document.title"
|
|
1523
1659
|
rech open https://example.com
|
|
1524
1660
|
rech screenshot`);
|
|
@@ -1564,7 +1700,7 @@ if (import.meta.main) {
|
|
|
1564
1700
|
if (!experimental) {
|
|
1565
1701
|
console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
|
|
1566
1702
|
console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
|
|
1567
|
-
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <
|
|
1703
|
+
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
|
|
1568
1704
|
console.error(`To proceed anyway, re-run with --experimental.`);
|
|
1569
1705
|
process.exit(1);
|
|
1570
1706
|
}
|
package/serve.js
CHANGED
|
@@ -84,6 +84,23 @@ function noteSession(sess: string, now: number): void {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export function inferSilentExtensionFailure(options: {
|
|
88
|
+
status: number;
|
|
89
|
+
stdout: string;
|
|
90
|
+
stderr: string;
|
|
91
|
+
isOpenCommand: boolean;
|
|
92
|
+
hasExtensionCredentials: boolean;
|
|
93
|
+
elapsedMs: number;
|
|
94
|
+
handshakeTimeoutMs: number;
|
|
95
|
+
}): string {
|
|
96
|
+
const { status, stdout, stderr, isOpenCommand, hasExtensionCredentials, elapsedMs, handshakeTimeoutMs } = options;
|
|
97
|
+
if (stderr || status === 0 || stdout.trim() || !isOpenCommand || !hasExtensionCredentials)
|
|
98
|
+
return stderr;
|
|
99
|
+
if (elapsedMs < Math.max(1_000, handshakeTimeoutMs - 1_000))
|
|
100
|
+
return stderr;
|
|
101
|
+
return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
function tmpSocketRoot(): string {
|
|
88
105
|
return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
|
|
89
106
|
}
|
|
@@ -478,6 +495,7 @@ export async function serve() {
|
|
|
478
495
|
} catch {}
|
|
479
496
|
}
|
|
480
497
|
|
|
498
|
+
const commandStartedAt = Date.now();
|
|
481
499
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
482
500
|
cwd: workDir,
|
|
483
501
|
stdin: "ignore",
|
|
@@ -497,7 +515,7 @@ export async function serve() {
|
|
|
497
515
|
reject(new Error("timeout"));
|
|
498
516
|
}, TIMEOUT);
|
|
499
517
|
});
|
|
500
|
-
const [status, stdout,
|
|
518
|
+
const [status, stdout, rawStderr] = await Promise.race([
|
|
501
519
|
Promise.all([
|
|
502
520
|
proc.exited,
|
|
503
521
|
new Response(proc.stdout).text(),
|
|
@@ -509,6 +527,20 @@ export async function serve() {
|
|
|
509
527
|
) as [number, string, string];
|
|
510
528
|
clearTimeout(timer);
|
|
511
529
|
|
|
530
|
+
const configuredHandshakeTimeout = Number(childEnv.PWMCP_TEST_CONNECTION_TIMEOUT);
|
|
531
|
+
const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
|
|
532
|
+
? configuredHandshakeTimeout
|
|
533
|
+
: 30_000;
|
|
534
|
+
const stderr = inferSilentExtensionFailure({
|
|
535
|
+
status,
|
|
536
|
+
stdout,
|
|
537
|
+
stderr: rawStderr,
|
|
538
|
+
isOpenCommand: isOpenCmd,
|
|
539
|
+
hasExtensionCredentials: !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN),
|
|
540
|
+
elapsedMs: Date.now() - commandStartedAt,
|
|
541
|
+
handshakeTimeoutMs,
|
|
542
|
+
});
|
|
543
|
+
|
|
512
544
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
513
545
|
|
|
514
546
|
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
|
package/serve.ts
CHANGED
|
@@ -84,6 +84,23 @@ function noteSession(sess: string, now: number): void {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export function inferSilentExtensionFailure(options: {
|
|
88
|
+
status: number;
|
|
89
|
+
stdout: string;
|
|
90
|
+
stderr: string;
|
|
91
|
+
isOpenCommand: boolean;
|
|
92
|
+
hasExtensionCredentials: boolean;
|
|
93
|
+
elapsedMs: number;
|
|
94
|
+
handshakeTimeoutMs: number;
|
|
95
|
+
}): string {
|
|
96
|
+
const { status, stdout, stderr, isOpenCommand, hasExtensionCredentials, elapsedMs, handshakeTimeoutMs } = options;
|
|
97
|
+
if (stderr || status === 0 || stdout.trim() || !isOpenCommand || !hasExtensionCredentials)
|
|
98
|
+
return stderr;
|
|
99
|
+
if (elapsedMs < Math.max(1_000, handshakeTimeoutMs - 1_000))
|
|
100
|
+
return stderr;
|
|
101
|
+
return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
function tmpSocketRoot(): string {
|
|
88
105
|
return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
|
|
89
106
|
}
|
|
@@ -478,6 +495,7 @@ export async function serve() {
|
|
|
478
495
|
} catch {}
|
|
479
496
|
}
|
|
480
497
|
|
|
498
|
+
const commandStartedAt = Date.now();
|
|
481
499
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
482
500
|
cwd: workDir,
|
|
483
501
|
stdin: "ignore",
|
|
@@ -497,7 +515,7 @@ export async function serve() {
|
|
|
497
515
|
reject(new Error("timeout"));
|
|
498
516
|
}, TIMEOUT);
|
|
499
517
|
});
|
|
500
|
-
const [status, stdout,
|
|
518
|
+
const [status, stdout, rawStderr] = await Promise.race([
|
|
501
519
|
Promise.all([
|
|
502
520
|
proc.exited,
|
|
503
521
|
new Response(proc.stdout).text(),
|
|
@@ -509,6 +527,20 @@ export async function serve() {
|
|
|
509
527
|
) as [number, string, string];
|
|
510
528
|
clearTimeout(timer);
|
|
511
529
|
|
|
530
|
+
const configuredHandshakeTimeout = Number(childEnv.PWMCP_TEST_CONNECTION_TIMEOUT);
|
|
531
|
+
const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
|
|
532
|
+
? configuredHandshakeTimeout
|
|
533
|
+
: 30_000;
|
|
534
|
+
const stderr = inferSilentExtensionFailure({
|
|
535
|
+
status,
|
|
536
|
+
stdout,
|
|
537
|
+
stderr: rawStderr,
|
|
538
|
+
isOpenCommand: isOpenCmd,
|
|
539
|
+
hasExtensionCredentials: !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN),
|
|
540
|
+
elapsedMs: Date.now() - commandStartedAt,
|
|
541
|
+
handshakeTimeoutMs,
|
|
542
|
+
});
|
|
543
|
+
|
|
512
544
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
513
545
|
|
|
514
546
|
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
|