rechrome 1.16.1 → 1.17.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/package.json +1 -1
- package/rech.js +146 -57
- package/rech.ts +146 -57
- package/serve.js +78 -10
- package/serve.ts +78 -10
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { file } from "bun";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
5
|
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
|
|
6
|
-
import { hostname } from "os";
|
|
6
|
+
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
9
9
|
export const ENV_KEY = "RECHROME_URL";
|
|
@@ -11,7 +11,10 @@ export const DEFAULT_PORT = 13775;
|
|
|
11
11
|
export const RECH_DIR = join(import.meta.dir, ".rech");
|
|
12
12
|
export const LOG_DIR = join(RECH_DIR, "logs");
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Home dir: HOME on POSIX, USERPROFILE on Windows (handled by os.homedir()).
|
|
15
|
+
export const HOME = homedir();
|
|
16
|
+
|
|
17
|
+
const RECH_HOME_DIR = join(HOME, ".rechrome");
|
|
15
18
|
const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.json");
|
|
16
19
|
|
|
17
20
|
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string };
|
|
@@ -29,7 +32,7 @@ async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
const envFile = join(import.meta.dir, ".env.local");
|
|
32
|
-
const globalEnvFile = join(
|
|
35
|
+
const globalEnvFile = join(HOME || "~", ".env.local");
|
|
33
36
|
|
|
34
37
|
// Walk CWD→root loading env files nearest-first; per-key: closest file wins, farther files skip.
|
|
35
38
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
@@ -91,6 +94,34 @@ function isReadable(p?: string): boolean {
|
|
|
91
94
|
try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// Open a file/URL in the OS default app/browser. `open` is macOS-only — Windows needs
|
|
98
|
+
// `cmd /c start`, Linux needs `xdg-open`.
|
|
99
|
+
function openInDefaultApp(target: string): void {
|
|
100
|
+
const cmd = process.platform === "darwin" ? ["open", target]
|
|
101
|
+
: process.platform === "win32" ? ["cmd", "/c", "start", "", target]
|
|
102
|
+
: ["xdg-open", target];
|
|
103
|
+
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Best-effort path to the Chrome executable for the current platform (used to open a
|
|
107
|
+
// specific profile at a chrome-extension:// URL). Returns null if not found.
|
|
108
|
+
function findChromeBinary(): string | null {
|
|
109
|
+
const candidates = process.platform === "darwin"
|
|
110
|
+
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
|
|
111
|
+
: process.platform === "win32"
|
|
112
|
+
? [
|
|
113
|
+
join(process.env.PROGRAMFILES || "C:\\Program Files", "Google/Chrome/Application/chrome.exe"),
|
|
114
|
+
join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google/Chrome/Application/chrome.exe"),
|
|
115
|
+
join(process.env.LOCALAPPDATA || join(HOME, "AppData/Local"), "Google/Chrome/Application/chrome.exe"),
|
|
116
|
+
]
|
|
117
|
+
: ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
|
|
118
|
+
for (const p of candidates) {
|
|
119
|
+
if (p.includes("/") || p.includes("\\")) { if (existsSync(p)) return p; }
|
|
120
|
+
else { const w = Bun.which(p); if (w) return w; }
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
export function log(msg: string) {
|
|
95
126
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
96
127
|
const ts = new Date().toISOString();
|
|
@@ -214,7 +245,7 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
|
|
|
214
245
|
}
|
|
215
246
|
|
|
216
247
|
const CHROME_LOCAL_STATE_PATHS = () => {
|
|
217
|
-
const home =
|
|
248
|
+
const home = HOME || "~";
|
|
218
249
|
return [
|
|
219
250
|
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
220
251
|
join(home, ".config/google-chrome/Local State"),
|
|
@@ -250,7 +281,7 @@ const LEGACY_EXTENSION_DIST_DIR = join(import.meta.dir, "lib/playwright-multi-ta
|
|
|
250
281
|
|
|
251
282
|
// Stable per-user location: we copy the bundled dist here so Chrome's recorded install path survives
|
|
252
283
|
// the ephemeral bunx temp dir being cleaned up between invocations.
|
|
253
|
-
export const EXTENSION_DIST_DIR = join(
|
|
284
|
+
export const EXTENSION_DIST_DIR = join(HOME, ".rechrome", "extension");
|
|
254
285
|
|
|
255
286
|
// With the manifest `key` field set, Chrome derives this ID deterministically from the key (not the path),
|
|
256
287
|
// so we can locate the extension by ID even when the on-disk path differs from what Chrome stored.
|
|
@@ -458,7 +489,7 @@ async function run(url: string, args: string[]) {
|
|
|
458
489
|
process.exit(status);
|
|
459
490
|
}
|
|
460
491
|
|
|
461
|
-
function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
492
|
+
export function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
462
493
|
return `<!DOCTYPE html>
|
|
463
494
|
<html lang="en">
|
|
464
495
|
<head>
|
|
@@ -516,15 +547,35 @@ function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
|
516
547
|
</html>`;
|
|
517
548
|
}
|
|
518
549
|
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
550
|
+
const PM_PROCESS_NAME = "rechrome";
|
|
551
|
+
// Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
|
|
552
|
+
// migrates an existing checkout cleanly.
|
|
553
|
+
const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
|
|
554
|
+
// oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
|
|
555
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
556
|
+
const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
|
|
557
|
+
|
|
558
|
+
// Spawn the active process manager. `env` is merged over process.env for the
|
|
559
|
+
// child: pm2 captures the CLI's environment for the managed process (it has no
|
|
560
|
+
// per-var flag like oxmgr's --env), so install passes daemon env this way.
|
|
561
|
+
async function runPm(args: string[], env?: Record<string, string>): Promise<number> {
|
|
562
|
+
const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
|
|
563
|
+
stdout: "inherit",
|
|
564
|
+
stderr: "inherit",
|
|
565
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
566
|
+
});
|
|
523
567
|
await proc.exited;
|
|
524
568
|
return proc.exitCode ?? 1;
|
|
525
569
|
}
|
|
526
570
|
|
|
527
|
-
|
|
571
|
+
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
572
|
+
// Both render the process name verbatim, so callers can substring-match it.
|
|
573
|
+
async function pmList(): Promise<string> {
|
|
574
|
+
const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
|
|
575
|
+
return await new Response(proc.stdout).text();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
528
579
|
// Persist the URL to ~/.env.local before starting the daemon. The daemon's
|
|
529
580
|
// loadEnv() walks CWD→root reading .env.local files and unconditionally
|
|
530
581
|
// overwrites process.env.RECHROME_URL from whichever file it finds first.
|
|
@@ -536,42 +587,75 @@ async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
536
587
|
const filtered = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
537
588
|
await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${serveUrl}`, ""].join("\n"));
|
|
538
589
|
|
|
539
|
-
const home =
|
|
590
|
+
const home = HOME;
|
|
540
591
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
541
592
|
const rechScript = import.meta.filename;
|
|
542
593
|
|
|
543
|
-
// Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab"
|
|
594
|
+
// Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab".
|
|
595
|
+
// The fork is a .js script: POSIX execs it via its shebang (`#!/usr/bin/env node`), but Windows
|
|
596
|
+
// can't exec a .js directly, so it must be invoked through an interpreter. It MUST be node, not
|
|
597
|
+
// bun: the cliDaemon inherits its parent's runtime (spawned via process.execPath), and the
|
|
598
|
+
// extension-bridge relay's WebSocket handshake hangs under Bun (the extension WS connects but
|
|
599
|
+
// `extension.initialized` never completes) — under node it completes, matching the POSIX shebang.
|
|
600
|
+
// serve splits PLAYWRIGHT_CLI on spaces into argv, so we use bare `node` (the node path lives
|
|
601
|
+
// under "Program Files" and contains a space); node must be on the daemon's PATH, same as the
|
|
602
|
+
// shebang's `env node` assumption. The repo path contains no spaces.
|
|
544
603
|
const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
|
|
545
604
|
const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
|
|
546
|
-
|| (existsSync(bundledForkCli)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
605
|
+
|| (existsSync(bundledForkCli)
|
|
606
|
+
? (IS_WINDOWS ? `node ${bundledForkCli}` : bundledForkCli)
|
|
607
|
+
: "playwright-cli-multi-tab");
|
|
608
|
+
|
|
609
|
+
// Environment the managed `serve` process must run with.
|
|
610
|
+
const daemonEnv: Record<string, string> = {
|
|
611
|
+
HOME: home,
|
|
612
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
|
613
|
+
[ENV_KEY]: serveUrl,
|
|
614
|
+
PWMCP_TEST_CONNECTION_TIMEOUT: process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000",
|
|
615
|
+
PLAYWRIGHT_CLI: resolvedPlaywrightCli,
|
|
616
|
+
};
|
|
617
|
+
if (process.env.RECH_HOST) daemonEnv.RECH_HOST = process.env.RECH_HOST;
|
|
618
|
+
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
619
|
+
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
620
|
+
|
|
621
|
+
// Drop any prior registration (current + legacy names) before re-adding.
|
|
622
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
623
|
+
|
|
624
|
+
let startCode: number;
|
|
625
|
+
if (IS_WINDOWS) {
|
|
626
|
+
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
627
|
+
// autorestarts by default, and runs the bun binary directly with
|
|
628
|
+
// `--interpreter none` (so it isn't fed to node).
|
|
629
|
+
startCode = await runPm([
|
|
630
|
+
"start", bunBin,
|
|
631
|
+
"--name", PM_PROCESS_NAME,
|
|
632
|
+
"--interpreter", "none",
|
|
633
|
+
"--cwd", home,
|
|
634
|
+
"--", rechScript, "serve",
|
|
635
|
+
], daemonEnv);
|
|
636
|
+
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
637
|
+
} else {
|
|
638
|
+
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
639
|
+
startCode = await runPm([
|
|
640
|
+
"start",
|
|
641
|
+
"--name", PM_PROCESS_NAME,
|
|
642
|
+
"--restart", "always",
|
|
643
|
+
"--cwd", home,
|
|
644
|
+
...envArgs,
|
|
645
|
+
`${bunBin} ${rechScript} serve`,
|
|
646
|
+
]);
|
|
647
|
+
await runPm(["service", "install"]);
|
|
648
|
+
}
|
|
649
|
+
// Surface a failed start instead of reporting a daemon that was never registered.
|
|
650
|
+
if (startCode !== 0)
|
|
651
|
+
throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
|
|
569
652
|
}
|
|
570
653
|
|
|
571
654
|
async function daemonUninstall(): Promise<void> {
|
|
572
|
-
await
|
|
573
|
-
await
|
|
574
|
-
|
|
655
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
656
|
+
if (IS_WINDOWS) await runPm(["save"]);
|
|
657
|
+
else await runPm(["service", "uninstall"]);
|
|
658
|
+
console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
|
|
575
659
|
}
|
|
576
660
|
|
|
577
661
|
async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
@@ -693,7 +777,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
693
777
|
await waitForServe();
|
|
694
778
|
} else {
|
|
695
779
|
await daemonInstall(url);
|
|
696
|
-
console.log(` Registered daemon: ${
|
|
780
|
+
console.log(` Registered daemon: ${PM_PROCESS_NAME}`);
|
|
697
781
|
await waitForServe();
|
|
698
782
|
}
|
|
699
783
|
|
|
@@ -748,7 +832,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
748
832
|
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
749
833
|
await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
|
|
750
834
|
console.log(`\n Opening install guide in your browser...`);
|
|
751
|
-
|
|
835
|
+
openInDefaultApp(setupHtmlPath);
|
|
752
836
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
753
837
|
}
|
|
754
838
|
console.log(` Extension found: ${extId}`);
|
|
@@ -771,11 +855,13 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
771
855
|
console.log(`\n Get auth token from the extension:`);
|
|
772
856
|
console.log(` ${statusUrl}`);
|
|
773
857
|
if (isTTY) {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
858
|
+
const chromeBin = findChromeBinary();
|
|
859
|
+
if (chromeBin) {
|
|
860
|
+
Bun.spawn(
|
|
861
|
+
[chromeBin, `--profile-directory=${profileDir}`, statusUrl],
|
|
862
|
+
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
863
|
+
);
|
|
864
|
+
}
|
|
779
865
|
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
780
866
|
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
781
867
|
} else {
|
|
@@ -832,7 +918,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
832
918
|
|
|
833
919
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
834
920
|
const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
|
|
835
|
-
const homeEnvPath = join(
|
|
921
|
+
const homeEnvPath = join(HOME, ".env.local");
|
|
836
922
|
// Show whether each target already exists so it's clear we'll update (merge) vs create.
|
|
837
923
|
const tag = async (p: string) => (await file(p).exists()) ? "exists → will update" : "new file";
|
|
838
924
|
const [pwdTag, pwdRechTag, homeTag] = await Promise.all([tag(pwdEnvPath), tag(pwdRechPath), tag(homeEnvPath)]);
|
|
@@ -890,16 +976,19 @@ async function status(): Promise<void> {
|
|
|
890
976
|
const { host, port, protocol } = parseUrl(url);
|
|
891
977
|
const parsed = parseUrl(url);
|
|
892
978
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
893
|
-
//
|
|
894
|
-
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const
|
|
902
|
-
console.log(`
|
|
979
|
+
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
980
|
+
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
981
|
+
const bind = ping
|
|
982
|
+
? await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
983
|
+
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
984
|
+
signal: AbortSignal.timeout(2000),
|
|
985
|
+
}).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
|
|
986
|
+
: undefined;
|
|
987
|
+
const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
|
|
988
|
+
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
|
|
989
|
+
const pmOut = await pmList();
|
|
990
|
+
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
991
|
+
console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
903
992
|
const registry = await readTokenRegistry();
|
|
904
993
|
const entries = Object.entries(registry);
|
|
905
994
|
if (entries.length) {
|
package/rech.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { file } from "bun";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
5
|
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
|
|
6
|
-
import { hostname } from "os";
|
|
6
|
+
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
9
9
|
export const ENV_KEY = "RECHROME_URL";
|
|
@@ -11,7 +11,10 @@ export const DEFAULT_PORT = 13775;
|
|
|
11
11
|
export const RECH_DIR = join(import.meta.dir, ".rech");
|
|
12
12
|
export const LOG_DIR = join(RECH_DIR, "logs");
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Home dir: HOME on POSIX, USERPROFILE on Windows (handled by os.homedir()).
|
|
15
|
+
export const HOME = homedir();
|
|
16
|
+
|
|
17
|
+
const RECH_HOME_DIR = join(HOME, ".rechrome");
|
|
15
18
|
const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.json");
|
|
16
19
|
|
|
17
20
|
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string };
|
|
@@ -29,7 +32,7 @@ async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
const envFile = join(import.meta.dir, ".env.local");
|
|
32
|
-
const globalEnvFile = join(
|
|
35
|
+
const globalEnvFile = join(HOME || "~", ".env.local");
|
|
33
36
|
|
|
34
37
|
// Walk CWD→root loading env files nearest-first; per-key: closest file wins, farther files skip.
|
|
35
38
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
@@ -91,6 +94,34 @@ function isReadable(p?: string): boolean {
|
|
|
91
94
|
try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// Open a file/URL in the OS default app/browser. `open` is macOS-only — Windows needs
|
|
98
|
+
// `cmd /c start`, Linux needs `xdg-open`.
|
|
99
|
+
function openInDefaultApp(target: string): void {
|
|
100
|
+
const cmd = process.platform === "darwin" ? ["open", target]
|
|
101
|
+
: process.platform === "win32" ? ["cmd", "/c", "start", "", target]
|
|
102
|
+
: ["xdg-open", target];
|
|
103
|
+
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Best-effort path to the Chrome executable for the current platform (used to open a
|
|
107
|
+
// specific profile at a chrome-extension:// URL). Returns null if not found.
|
|
108
|
+
function findChromeBinary(): string | null {
|
|
109
|
+
const candidates = process.platform === "darwin"
|
|
110
|
+
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
|
|
111
|
+
: process.platform === "win32"
|
|
112
|
+
? [
|
|
113
|
+
join(process.env.PROGRAMFILES || "C:\\Program Files", "Google/Chrome/Application/chrome.exe"),
|
|
114
|
+
join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google/Chrome/Application/chrome.exe"),
|
|
115
|
+
join(process.env.LOCALAPPDATA || join(HOME, "AppData/Local"), "Google/Chrome/Application/chrome.exe"),
|
|
116
|
+
]
|
|
117
|
+
: ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
|
|
118
|
+
for (const p of candidates) {
|
|
119
|
+
if (p.includes("/") || p.includes("\\")) { if (existsSync(p)) return p; }
|
|
120
|
+
else { const w = Bun.which(p); if (w) return w; }
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
export function log(msg: string) {
|
|
95
126
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
96
127
|
const ts = new Date().toISOString();
|
|
@@ -214,7 +245,7 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
|
|
|
214
245
|
}
|
|
215
246
|
|
|
216
247
|
const CHROME_LOCAL_STATE_PATHS = () => {
|
|
217
|
-
const home =
|
|
248
|
+
const home = HOME || "~";
|
|
218
249
|
return [
|
|
219
250
|
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
220
251
|
join(home, ".config/google-chrome/Local State"),
|
|
@@ -250,7 +281,7 @@ const LEGACY_EXTENSION_DIST_DIR = join(import.meta.dir, "lib/playwright-multi-ta
|
|
|
250
281
|
|
|
251
282
|
// Stable per-user location: we copy the bundled dist here so Chrome's recorded install path survives
|
|
252
283
|
// the ephemeral bunx temp dir being cleaned up between invocations.
|
|
253
|
-
export const EXTENSION_DIST_DIR = join(
|
|
284
|
+
export const EXTENSION_DIST_DIR = join(HOME, ".rechrome", "extension");
|
|
254
285
|
|
|
255
286
|
// With the manifest `key` field set, Chrome derives this ID deterministically from the key (not the path),
|
|
256
287
|
// so we can locate the extension by ID even when the on-disk path differs from what Chrome stored.
|
|
@@ -458,7 +489,7 @@ async function run(url: string, args: string[]) {
|
|
|
458
489
|
process.exit(status);
|
|
459
490
|
}
|
|
460
491
|
|
|
461
|
-
function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
492
|
+
export function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
462
493
|
return `<!DOCTYPE html>
|
|
463
494
|
<html lang="en">
|
|
464
495
|
<head>
|
|
@@ -516,15 +547,35 @@ function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
|
516
547
|
</html>`;
|
|
517
548
|
}
|
|
518
549
|
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
550
|
+
const PM_PROCESS_NAME = "rechrome";
|
|
551
|
+
// Pre-rename names to evict on (re)install/uninstall so a single `rech setup`
|
|
552
|
+
// migrates an existing checkout cleanly.
|
|
553
|
+
const LEGACY_PROCESS_NAMES = ["rechrome-serve"];
|
|
554
|
+
// oxmgr everywhere, but it's unstable on Windows — fall back to pm2 there.
|
|
555
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
556
|
+
const PM_BIN = IS_WINDOWS ? "pm2" : "oxmgr";
|
|
557
|
+
|
|
558
|
+
// Spawn the active process manager. `env` is merged over process.env for the
|
|
559
|
+
// child: pm2 captures the CLI's environment for the managed process (it has no
|
|
560
|
+
// per-var flag like oxmgr's --env), so install passes daemon env this way.
|
|
561
|
+
async function runPm(args: string[], env?: Record<string, string>): Promise<number> {
|
|
562
|
+
const proc = Bun.spawn(["bunx", PM_BIN, ...args], {
|
|
563
|
+
stdout: "inherit",
|
|
564
|
+
stderr: "inherit",
|
|
565
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
566
|
+
});
|
|
523
567
|
await proc.exited;
|
|
524
568
|
return proc.exitCode ?? 1;
|
|
525
569
|
}
|
|
526
570
|
|
|
527
|
-
|
|
571
|
+
// Capture the process-manager's process list as text (oxmgr `list` / pm2 `jlist`).
|
|
572
|
+
// Both render the process name verbatim, so callers can substring-match it.
|
|
573
|
+
async function pmList(): Promise<string> {
|
|
574
|
+
const proc = Bun.spawn(["bunx", PM_BIN, IS_WINDOWS ? "jlist" : "list"], { stdout: "pipe", stderr: "ignore" });
|
|
575
|
+
return await new Response(proc.stdout).text();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
528
579
|
// Persist the URL to ~/.env.local before starting the daemon. The daemon's
|
|
529
580
|
// loadEnv() walks CWD→root reading .env.local files and unconditionally
|
|
530
581
|
// overwrites process.env.RECHROME_URL from whichever file it finds first.
|
|
@@ -536,42 +587,75 @@ async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
536
587
|
const filtered = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
537
588
|
await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${serveUrl}`, ""].join("\n"));
|
|
538
589
|
|
|
539
|
-
const home =
|
|
590
|
+
const home = HOME;
|
|
540
591
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
541
592
|
const rechScript = import.meta.filename;
|
|
542
593
|
|
|
543
|
-
// Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab"
|
|
594
|
+
// Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab".
|
|
595
|
+
// The fork is a .js script: POSIX execs it via its shebang (`#!/usr/bin/env node`), but Windows
|
|
596
|
+
// can't exec a .js directly, so it must be invoked through an interpreter. It MUST be node, not
|
|
597
|
+
// bun: the cliDaemon inherits its parent's runtime (spawned via process.execPath), and the
|
|
598
|
+
// extension-bridge relay's WebSocket handshake hangs under Bun (the extension WS connects but
|
|
599
|
+
// `extension.initialized` never completes) — under node it completes, matching the POSIX shebang.
|
|
600
|
+
// serve splits PLAYWRIGHT_CLI on spaces into argv, so we use bare `node` (the node path lives
|
|
601
|
+
// under "Program Files" and contains a space); node must be on the daemon's PATH, same as the
|
|
602
|
+
// shebang's `env node` assumption. The repo path contains no spaces.
|
|
544
603
|
const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
|
|
545
604
|
const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
|
|
546
|
-
|| (existsSync(bundledForkCli)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
605
|
+
|| (existsSync(bundledForkCli)
|
|
606
|
+
? (IS_WINDOWS ? `node ${bundledForkCli}` : bundledForkCli)
|
|
607
|
+
: "playwright-cli-multi-tab");
|
|
608
|
+
|
|
609
|
+
// Environment the managed `serve` process must run with.
|
|
610
|
+
const daemonEnv: Record<string, string> = {
|
|
611
|
+
HOME: home,
|
|
612
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
|
613
|
+
[ENV_KEY]: serveUrl,
|
|
614
|
+
PWMCP_TEST_CONNECTION_TIMEOUT: process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000",
|
|
615
|
+
PLAYWRIGHT_CLI: resolvedPlaywrightCli,
|
|
616
|
+
};
|
|
617
|
+
if (process.env.RECH_HOST) daemonEnv.RECH_HOST = process.env.RECH_HOST;
|
|
618
|
+
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
619
|
+
if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
|
|
620
|
+
|
|
621
|
+
// Drop any prior registration (current + legacy names) before re-adding.
|
|
622
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
623
|
+
|
|
624
|
+
let startCode: number;
|
|
625
|
+
if (IS_WINDOWS) {
|
|
626
|
+
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
627
|
+
// autorestarts by default, and runs the bun binary directly with
|
|
628
|
+
// `--interpreter none` (so it isn't fed to node).
|
|
629
|
+
startCode = await runPm([
|
|
630
|
+
"start", bunBin,
|
|
631
|
+
"--name", PM_PROCESS_NAME,
|
|
632
|
+
"--interpreter", "none",
|
|
633
|
+
"--cwd", home,
|
|
634
|
+
"--", rechScript, "serve",
|
|
635
|
+
], daemonEnv);
|
|
636
|
+
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
637
|
+
} else {
|
|
638
|
+
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
639
|
+
startCode = await runPm([
|
|
640
|
+
"start",
|
|
641
|
+
"--name", PM_PROCESS_NAME,
|
|
642
|
+
"--restart", "always",
|
|
643
|
+
"--cwd", home,
|
|
644
|
+
...envArgs,
|
|
645
|
+
`${bunBin} ${rechScript} serve`,
|
|
646
|
+
]);
|
|
647
|
+
await runPm(["service", "install"]);
|
|
648
|
+
}
|
|
649
|
+
// Surface a failed start instead of reporting a daemon that was never registered.
|
|
650
|
+
if (startCode !== 0)
|
|
651
|
+
throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
|
|
569
652
|
}
|
|
570
653
|
|
|
571
654
|
async function daemonUninstall(): Promise<void> {
|
|
572
|
-
await
|
|
573
|
-
await
|
|
574
|
-
|
|
655
|
+
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
656
|
+
if (IS_WINDOWS) await runPm(["save"]);
|
|
657
|
+
else await runPm(["service", "uninstall"]);
|
|
658
|
+
console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
|
|
575
659
|
}
|
|
576
660
|
|
|
577
661
|
async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
@@ -693,7 +777,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
693
777
|
await waitForServe();
|
|
694
778
|
} else {
|
|
695
779
|
await daemonInstall(url);
|
|
696
|
-
console.log(` Registered daemon: ${
|
|
780
|
+
console.log(` Registered daemon: ${PM_PROCESS_NAME}`);
|
|
697
781
|
await waitForServe();
|
|
698
782
|
}
|
|
699
783
|
|
|
@@ -748,7 +832,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
748
832
|
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
749
833
|
await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
|
|
750
834
|
console.log(`\n Opening install guide in your browser...`);
|
|
751
|
-
|
|
835
|
+
openInDefaultApp(setupHtmlPath);
|
|
752
836
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
753
837
|
}
|
|
754
838
|
console.log(` Extension found: ${extId}`);
|
|
@@ -771,11 +855,13 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
771
855
|
console.log(`\n Get auth token from the extension:`);
|
|
772
856
|
console.log(` ${statusUrl}`);
|
|
773
857
|
if (isTTY) {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
858
|
+
const chromeBin = findChromeBinary();
|
|
859
|
+
if (chromeBin) {
|
|
860
|
+
Bun.spawn(
|
|
861
|
+
[chromeBin, `--profile-directory=${profileDir}`, statusUrl],
|
|
862
|
+
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
863
|
+
);
|
|
864
|
+
}
|
|
779
865
|
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
780
866
|
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
781
867
|
} else {
|
|
@@ -832,7 +918,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
832
918
|
|
|
833
919
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
834
920
|
const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
|
|
835
|
-
const homeEnvPath = join(
|
|
921
|
+
const homeEnvPath = join(HOME, ".env.local");
|
|
836
922
|
// Show whether each target already exists so it's clear we'll update (merge) vs create.
|
|
837
923
|
const tag = async (p: string) => (await file(p).exists()) ? "exists → will update" : "new file";
|
|
838
924
|
const [pwdTag, pwdRechTag, homeTag] = await Promise.all([tag(pwdEnvPath), tag(pwdRechPath), tag(homeEnvPath)]);
|
|
@@ -890,16 +976,19 @@ async function status(): Promise<void> {
|
|
|
890
976
|
const { host, port, protocol } = parseUrl(url);
|
|
891
977
|
const parsed = parseUrl(url);
|
|
892
978
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
893
|
-
//
|
|
894
|
-
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const
|
|
902
|
-
console.log(`
|
|
979
|
+
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
980
|
+
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
981
|
+
const bind = ping
|
|
982
|
+
? await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
983
|
+
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
984
|
+
signal: AbortSignal.timeout(2000),
|
|
985
|
+
}).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
|
|
986
|
+
: undefined;
|
|
987
|
+
const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
|
|
988
|
+
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
|
|
989
|
+
const pmOut = await pmList();
|
|
990
|
+
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
991
|
+
console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
903
992
|
const registry = await readTokenRegistry();
|
|
904
993
|
const entries = Object.entries(registry);
|
|
905
994
|
if (entries.length) {
|
package/serve.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getOrCreateUrl,
|
|
9
9
|
authCheck,
|
|
10
10
|
RECH_DIR,
|
|
11
|
+
HOME,
|
|
11
12
|
PASSTHROUGH_ENV_KEYS,
|
|
12
13
|
} from "./rech.js";
|
|
13
14
|
|
|
@@ -57,14 +58,25 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Use path.relative rather than string-prefix: resolve() yields backslash paths on
|
|
62
|
+
// Windows, so a "absBase + '/'" prefix check never matched there (every file was
|
|
63
|
+
// rejected). A candidate is under base iff the relative path neither escapes (..) nor
|
|
64
|
+
// is absolute (different drive).
|
|
65
|
+
const rel = relative(resolve(base), resolve(base, candidate));
|
|
66
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tokenize a command string into argv, honoring double-quoted segments so an interpreter
|
|
70
|
+
// path containing spaces (e.g. a quoted Windows "C:\Program Files\…\node.exe") survives.
|
|
71
|
+
// PLAYWRIGHT_CLI is space-joined by the installer; a plain split(" ") would shatter such paths.
|
|
72
|
+
export function splitCommand(cmd: string): string[] {
|
|
73
|
+
return (cmd.match(/"[^"]*"|\S+/g) ?? []).map(t =>
|
|
74
|
+
t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
66
78
|
if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
|
|
67
|
-
const home =
|
|
79
|
+
const home = HOME || "~";
|
|
68
80
|
const candidates = [
|
|
69
81
|
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
70
82
|
join(home, ".config/google-chrome/Local State"),
|
|
@@ -83,6 +95,45 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
83
95
|
return nameOrEmail;
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
99
|
+
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
100
|
+
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
101
|
+
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
102
|
+
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
103
|
+
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
104
|
+
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
105
|
+
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
106
|
+
async function freeStalePort(port: number): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
// Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
|
|
110
|
+
// process; (2) only if the port is STILL held — the inherited-handle case, where the
|
|
111
|
+
// socket lives in a child while netstat attributes it to a now-dead owner — fall back
|
|
112
|
+
// to killing orphaned cliDaemon holders. The fallback is the only recovery for that
|
|
113
|
+
// case (the live holder can't be mapped from the port), but it runs only when the
|
|
114
|
+
// precise kill failed, so the broad sweep is a logged last resort, not the default.
|
|
115
|
+
const ps = [
|
|
116
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
117
|
+
`$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
|
|
118
|
+
"if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
|
|
119
|
+
`if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
|
|
120
|
+
" $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
|
|
121
|
+
" Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
|
|
122
|
+
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
123
|
+
"}",
|
|
124
|
+
].join(" ");
|
|
125
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
126
|
+
const out = r.stdout?.toString().trim();
|
|
127
|
+
if (out) log(out);
|
|
128
|
+
} else {
|
|
129
|
+
Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// best effort — the retry will surface a clear error if the port is still held
|
|
133
|
+
}
|
|
134
|
+
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
135
|
+
}
|
|
136
|
+
|
|
86
137
|
export async function serve() {
|
|
87
138
|
const url = await getOrCreateUrl();
|
|
88
139
|
const { key, port } = parseUrl(url);
|
|
@@ -103,7 +154,7 @@ export async function serve() {
|
|
|
103
154
|
}, 86_400_000);
|
|
104
155
|
}
|
|
105
156
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
106
|
-
const
|
|
157
|
+
const startServer = () => Bun.serve({
|
|
107
158
|
hostname: listenHost,
|
|
108
159
|
port,
|
|
109
160
|
tls,
|
|
@@ -182,7 +233,7 @@ export async function serve() {
|
|
|
182
233
|
});
|
|
183
234
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
184
235
|
|
|
185
|
-
const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab")
|
|
236
|
+
const [bin, ...binArgs] = splitCommand(process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab");
|
|
186
237
|
|
|
187
238
|
if (filteredArgs.length === 0) {
|
|
188
239
|
filteredArgs.push("--help");
|
|
@@ -195,15 +246,17 @@ export async function serve() {
|
|
|
195
246
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
196
247
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
197
248
|
|
|
198
|
-
// bare `
|
|
199
|
-
|
|
249
|
+
// open against an existing session: bare `open` returns a tab-list hint; `open <url>`
|
|
250
|
+
// converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
|
|
251
|
+
// was dead — about:blank/<url> is already appended above, so length is always >=2.)
|
|
252
|
+
if (isOpenCmd) {
|
|
200
253
|
try {
|
|
201
254
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
202
255
|
cwd: workDir,
|
|
203
256
|
stdin: "ignore",
|
|
204
257
|
stdout: "pipe",
|
|
205
258
|
stderr: "pipe",
|
|
206
|
-
env: { PATH: process.env.PATH, HOME: process.env.
|
|
259
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
207
260
|
});
|
|
208
261
|
const [listStatus, listOut] = await Promise.race([
|
|
209
262
|
Promise.all([listProc.exited, new Response(listProc.stdout).text()]),
|
|
@@ -248,10 +301,13 @@ export async function serve() {
|
|
|
248
301
|
|
|
249
302
|
const childEnv: Record<string, string | undefined> = {
|
|
250
303
|
PATH: process.env.PATH,
|
|
251
|
-
HOME:
|
|
304
|
+
HOME: HOME,
|
|
305
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
252
306
|
TMPDIR: process.env.TMPDIR,
|
|
253
307
|
DISPLAY: process.env.DISPLAY,
|
|
254
308
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
309
|
+
DEBUG: process.env.DEBUG, // forward debug namespaces (e.g. pw:mcp:relay) for diagnostics
|
|
310
|
+
PWDEBUG: process.env.PWDEBUG,
|
|
255
311
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
256
312
|
...passthroughEnv,
|
|
257
313
|
// Enable extension bridge when credentials are present
|
|
@@ -339,6 +395,18 @@ export async function serve() {
|
|
|
339
395
|
},
|
|
340
396
|
});
|
|
341
397
|
|
|
398
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port held after a
|
|
399
|
+
// prior serve exits; on EADDRINUSE, clear stale holders once and retry rather than crash-loop.
|
|
400
|
+
let server: ReturnType<typeof startServer>;
|
|
401
|
+
try {
|
|
402
|
+
server = startServer();
|
|
403
|
+
} catch (e: any) {
|
|
404
|
+
if (!String(e?.code ?? e?.message ?? "").includes("EADDRINUSE")) throw e;
|
|
405
|
+
log(`port ${port} in use — clearing stale daemon holders and retrying`);
|
|
406
|
+
await freeStalePort(port);
|
|
407
|
+
server = startServer();
|
|
408
|
+
}
|
|
409
|
+
|
|
342
410
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
343
411
|
log(`Connection URL set (use .env.local to view)`);
|
|
344
412
|
}
|
package/serve.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getOrCreateUrl,
|
|
9
9
|
authCheck,
|
|
10
10
|
RECH_DIR,
|
|
11
|
+
HOME,
|
|
11
12
|
PASSTHROUGH_ENV_KEYS,
|
|
12
13
|
} from "./rech.ts";
|
|
13
14
|
|
|
@@ -57,14 +58,25 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Use path.relative rather than string-prefix: resolve() yields backslash paths on
|
|
62
|
+
// Windows, so a "absBase + '/'" prefix check never matched there (every file was
|
|
63
|
+
// rejected). A candidate is under base iff the relative path neither escapes (..) nor
|
|
64
|
+
// is absolute (different drive).
|
|
65
|
+
const rel = relative(resolve(base), resolve(base, candidate));
|
|
66
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tokenize a command string into argv, honoring double-quoted segments so an interpreter
|
|
70
|
+
// path containing spaces (e.g. a quoted Windows "C:\Program Files\…\node.exe") survives.
|
|
71
|
+
// PLAYWRIGHT_CLI is space-joined by the installer; a plain split(" ") would shatter such paths.
|
|
72
|
+
export function splitCommand(cmd: string): string[] {
|
|
73
|
+
return (cmd.match(/"[^"]*"|\S+/g) ?? []).map(t =>
|
|
74
|
+
t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
66
78
|
if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
|
|
67
|
-
const home =
|
|
79
|
+
const home = HOME || "~";
|
|
68
80
|
const candidates = [
|
|
69
81
|
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
70
82
|
join(home, ".config/google-chrome/Local State"),
|
|
@@ -83,6 +95,45 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
83
95
|
return nameOrEmail;
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
99
|
+
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
100
|
+
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
101
|
+
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
102
|
+
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
103
|
+
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
104
|
+
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
105
|
+
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
106
|
+
async function freeStalePort(port: number): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
// Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
|
|
110
|
+
// process; (2) only if the port is STILL held — the inherited-handle case, where the
|
|
111
|
+
// socket lives in a child while netstat attributes it to a now-dead owner — fall back
|
|
112
|
+
// to killing orphaned cliDaemon holders. The fallback is the only recovery for that
|
|
113
|
+
// case (the live holder can't be mapped from the port), but it runs only when the
|
|
114
|
+
// precise kill failed, so the broad sweep is a logged last resort, not the default.
|
|
115
|
+
const ps = [
|
|
116
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
117
|
+
`$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
|
|
118
|
+
"if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
|
|
119
|
+
`if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
|
|
120
|
+
" $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
|
|
121
|
+
" Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
|
|
122
|
+
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
123
|
+
"}",
|
|
124
|
+
].join(" ");
|
|
125
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
126
|
+
const out = r.stdout?.toString().trim();
|
|
127
|
+
if (out) log(out);
|
|
128
|
+
} else {
|
|
129
|
+
Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// best effort — the retry will surface a clear error if the port is still held
|
|
133
|
+
}
|
|
134
|
+
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
135
|
+
}
|
|
136
|
+
|
|
86
137
|
export async function serve() {
|
|
87
138
|
const url = await getOrCreateUrl();
|
|
88
139
|
const { key, port } = parseUrl(url);
|
|
@@ -103,7 +154,7 @@ export async function serve() {
|
|
|
103
154
|
}, 86_400_000);
|
|
104
155
|
}
|
|
105
156
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
106
|
-
const
|
|
157
|
+
const startServer = () => Bun.serve({
|
|
107
158
|
hostname: listenHost,
|
|
108
159
|
port,
|
|
109
160
|
tls,
|
|
@@ -182,7 +233,7 @@ export async function serve() {
|
|
|
182
233
|
});
|
|
183
234
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
184
235
|
|
|
185
|
-
const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab")
|
|
236
|
+
const [bin, ...binArgs] = splitCommand(process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab");
|
|
186
237
|
|
|
187
238
|
if (filteredArgs.length === 0) {
|
|
188
239
|
filteredArgs.push("--help");
|
|
@@ -195,15 +246,17 @@ export async function serve() {
|
|
|
195
246
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
196
247
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
197
248
|
|
|
198
|
-
// bare `
|
|
199
|
-
|
|
249
|
+
// open against an existing session: bare `open` returns a tab-list hint; `open <url>`
|
|
250
|
+
// converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
|
|
251
|
+
// was dead — about:blank/<url> is already appended above, so length is always >=2.)
|
|
252
|
+
if (isOpenCmd) {
|
|
200
253
|
try {
|
|
201
254
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
202
255
|
cwd: workDir,
|
|
203
256
|
stdin: "ignore",
|
|
204
257
|
stdout: "pipe",
|
|
205
258
|
stderr: "pipe",
|
|
206
|
-
env: { PATH: process.env.PATH, HOME: process.env.
|
|
259
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
207
260
|
});
|
|
208
261
|
const [listStatus, listOut] = await Promise.race([
|
|
209
262
|
Promise.all([listProc.exited, new Response(listProc.stdout).text()]),
|
|
@@ -248,10 +301,13 @@ export async function serve() {
|
|
|
248
301
|
|
|
249
302
|
const childEnv: Record<string, string | undefined> = {
|
|
250
303
|
PATH: process.env.PATH,
|
|
251
|
-
HOME:
|
|
304
|
+
HOME: HOME,
|
|
305
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
252
306
|
TMPDIR: process.env.TMPDIR,
|
|
253
307
|
DISPLAY: process.env.DISPLAY,
|
|
254
308
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
309
|
+
DEBUG: process.env.DEBUG, // forward debug namespaces (e.g. pw:mcp:relay) for diagnostics
|
|
310
|
+
PWDEBUG: process.env.PWDEBUG,
|
|
255
311
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
256
312
|
...passthroughEnv,
|
|
257
313
|
// Enable extension bridge when credentials are present
|
|
@@ -339,6 +395,18 @@ export async function serve() {
|
|
|
339
395
|
},
|
|
340
396
|
});
|
|
341
397
|
|
|
398
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port held after a
|
|
399
|
+
// prior serve exits; on EADDRINUSE, clear stale holders once and retry rather than crash-loop.
|
|
400
|
+
let server: ReturnType<typeof startServer>;
|
|
401
|
+
try {
|
|
402
|
+
server = startServer();
|
|
403
|
+
} catch (e: any) {
|
|
404
|
+
if (!String(e?.code ?? e?.message ?? "").includes("EADDRINUSE")) throw e;
|
|
405
|
+
log(`port ${port} in use — clearing stale daemon holders and retrying`);
|
|
406
|
+
await freeStalePort(port);
|
|
407
|
+
server = startServer();
|
|
408
|
+
}
|
|
409
|
+
|
|
342
410
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
343
411
|
log(`Connection URL set (use .env.local to view)`);
|
|
344
412
|
}
|