rechrome 1.17.0 → 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 +65 -17
- package/rech.ts +65 -17
- package/serve.js +73 -7
- package/serve.ts +73 -7
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -94,6 +94,34 @@ function isReadable(p?: string): boolean {
|
|
|
94
94
|
try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
95
95
|
}
|
|
96
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
|
+
|
|
97
125
|
export function log(msg: string) {
|
|
98
126
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
99
127
|
const ts = new Date().toISOString();
|
|
@@ -461,7 +489,7 @@ async function run(url: string, args: string[]) {
|
|
|
461
489
|
process.exit(status);
|
|
462
490
|
}
|
|
463
491
|
|
|
464
|
-
function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
492
|
+
export function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
465
493
|
return `<!DOCTYPE html>
|
|
466
494
|
<html lang="en">
|
|
467
495
|
<head>
|
|
@@ -563,10 +591,20 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
563
591
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
564
592
|
const rechScript = import.meta.filename;
|
|
565
593
|
|
|
566
|
-
// 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.
|
|
567
603
|
const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
|
|
568
604
|
const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
|
|
569
|
-
|| (existsSync(bundledForkCli)
|
|
605
|
+
|| (existsSync(bundledForkCli)
|
|
606
|
+
? (IS_WINDOWS ? `node ${bundledForkCli}` : bundledForkCli)
|
|
607
|
+
: "playwright-cli-multi-tab");
|
|
570
608
|
|
|
571
609
|
// Environment the managed `serve` process must run with.
|
|
572
610
|
const daemonEnv: Record<string, string> = {
|
|
@@ -583,11 +621,12 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
583
621
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
584
622
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
585
623
|
|
|
624
|
+
let startCode: number;
|
|
586
625
|
if (IS_WINDOWS) {
|
|
587
626
|
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
588
627
|
// autorestarts by default, and runs the bun binary directly with
|
|
589
628
|
// `--interpreter none` (so it isn't fed to node).
|
|
590
|
-
await runPm([
|
|
629
|
+
startCode = await runPm([
|
|
591
630
|
"start", bunBin,
|
|
592
631
|
"--name", PM_PROCESS_NAME,
|
|
593
632
|
"--interpreter", "none",
|
|
@@ -597,7 +636,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
597
636
|
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
598
637
|
} else {
|
|
599
638
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
600
|
-
await runPm([
|
|
639
|
+
startCode = await runPm([
|
|
601
640
|
"start",
|
|
602
641
|
"--name", PM_PROCESS_NAME,
|
|
603
642
|
"--restart", "always",
|
|
@@ -607,6 +646,9 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
607
646
|
]);
|
|
608
647
|
await runPm(["service", "install"]);
|
|
609
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.`);
|
|
610
652
|
}
|
|
611
653
|
|
|
612
654
|
async function daemonUninstall(): Promise<void> {
|
|
@@ -790,7 +832,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
790
832
|
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
791
833
|
await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
|
|
792
834
|
console.log(`\n Opening install guide in your browser...`);
|
|
793
|
-
|
|
835
|
+
openInDefaultApp(setupHtmlPath);
|
|
794
836
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
795
837
|
}
|
|
796
838
|
console.log(` Extension found: ${extId}`);
|
|
@@ -813,11 +855,13 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
813
855
|
console.log(`\n Get auth token from the extension:`);
|
|
814
856
|
console.log(` ${statusUrl}`);
|
|
815
857
|
if (isTTY) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
}
|
|
821
865
|
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
822
866
|
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
823
867
|
} else {
|
|
@@ -932,12 +976,16 @@ async function status(): Promise<void> {
|
|
|
932
976
|
const { host, port, protocol } = parseUrl(url);
|
|
933
977
|
const parsed = parseUrl(url);
|
|
934
978
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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"}`);
|
|
941
989
|
const pmOut = await pmList();
|
|
942
990
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
943
991
|
console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
|
package/rech.ts
CHANGED
|
@@ -94,6 +94,34 @@ function isReadable(p?: string): boolean {
|
|
|
94
94
|
try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
95
95
|
}
|
|
96
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
|
+
|
|
97
125
|
export function log(msg: string) {
|
|
98
126
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
99
127
|
const ts = new Date().toISOString();
|
|
@@ -461,7 +489,7 @@ async function run(url: string, args: string[]) {
|
|
|
461
489
|
process.exit(status);
|
|
462
490
|
}
|
|
463
491
|
|
|
464
|
-
function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
492
|
+
export function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
465
493
|
return `<!DOCTYPE html>
|
|
466
494
|
<html lang="en">
|
|
467
495
|
<head>
|
|
@@ -563,10 +591,20 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
563
591
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
564
592
|
const rechScript = import.meta.filename;
|
|
565
593
|
|
|
566
|
-
// 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.
|
|
567
603
|
const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
|
|
568
604
|
const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
|
|
569
|
-
|| (existsSync(bundledForkCli)
|
|
605
|
+
|| (existsSync(bundledForkCli)
|
|
606
|
+
? (IS_WINDOWS ? `node ${bundledForkCli}` : bundledForkCli)
|
|
607
|
+
: "playwright-cli-multi-tab");
|
|
570
608
|
|
|
571
609
|
// Environment the managed `serve` process must run with.
|
|
572
610
|
const daemonEnv: Record<string, string> = {
|
|
@@ -583,11 +621,12 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
583
621
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
584
622
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
585
623
|
|
|
624
|
+
let startCode: number;
|
|
586
625
|
if (IS_WINDOWS) {
|
|
587
626
|
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
588
627
|
// autorestarts by default, and runs the bun binary directly with
|
|
589
628
|
// `--interpreter none` (so it isn't fed to node).
|
|
590
|
-
await runPm([
|
|
629
|
+
startCode = await runPm([
|
|
591
630
|
"start", bunBin,
|
|
592
631
|
"--name", PM_PROCESS_NAME,
|
|
593
632
|
"--interpreter", "none",
|
|
@@ -597,7 +636,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
597
636
|
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
598
637
|
} else {
|
|
599
638
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
600
|
-
await runPm([
|
|
639
|
+
startCode = await runPm([
|
|
601
640
|
"start",
|
|
602
641
|
"--name", PM_PROCESS_NAME,
|
|
603
642
|
"--restart", "always",
|
|
@@ -607,6 +646,9 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
607
646
|
]);
|
|
608
647
|
await runPm(["service", "install"]);
|
|
609
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.`);
|
|
610
652
|
}
|
|
611
653
|
|
|
612
654
|
async function daemonUninstall(): Promise<void> {
|
|
@@ -790,7 +832,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
790
832
|
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
791
833
|
await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
|
|
792
834
|
console.log(`\n Opening install guide in your browser...`);
|
|
793
|
-
|
|
835
|
+
openInDefaultApp(setupHtmlPath);
|
|
794
836
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
795
837
|
}
|
|
796
838
|
console.log(` Extension found: ${extId}`);
|
|
@@ -813,11 +855,13 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
813
855
|
console.log(`\n Get auth token from the extension:`);
|
|
814
856
|
console.log(` ${statusUrl}`);
|
|
815
857
|
if (isTTY) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
}
|
|
821
865
|
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
822
866
|
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
823
867
|
} else {
|
|
@@ -932,12 +976,16 @@ async function status(): Promise<void> {
|
|
|
932
976
|
const { host, port, protocol } = parseUrl(url);
|
|
933
977
|
const parsed = parseUrl(url);
|
|
934
978
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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"}`);
|
|
941
989
|
const pmOut = await pmList();
|
|
942
990
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
943
991
|
console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
|
package/serve.js
CHANGED
|
@@ -58,9 +58,20 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
@@ -84,6 +95,45 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
84
95
|
return nameOrEmail;
|
|
85
96
|
}
|
|
86
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
|
+
|
|
87
137
|
export async function serve() {
|
|
88
138
|
const url = await getOrCreateUrl();
|
|
89
139
|
const { key, port } = parseUrl(url);
|
|
@@ -104,7 +154,7 @@ export async function serve() {
|
|
|
104
154
|
}, 86_400_000);
|
|
105
155
|
}
|
|
106
156
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
107
|
-
const
|
|
157
|
+
const startServer = () => Bun.serve({
|
|
108
158
|
hostname: listenHost,
|
|
109
159
|
port,
|
|
110
160
|
tls,
|
|
@@ -183,7 +233,7 @@ export async function serve() {
|
|
|
183
233
|
});
|
|
184
234
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
185
235
|
|
|
186
|
-
const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab")
|
|
236
|
+
const [bin, ...binArgs] = splitCommand(process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab");
|
|
187
237
|
|
|
188
238
|
if (filteredArgs.length === 0) {
|
|
189
239
|
filteredArgs.push("--help");
|
|
@@ -196,8 +246,10 @@ export async function serve() {
|
|
|
196
246
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
197
247
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
198
248
|
|
|
199
|
-
// bare `
|
|
200
|
-
|
|
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) {
|
|
201
253
|
try {
|
|
202
254
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
203
255
|
cwd: workDir,
|
|
@@ -254,6 +306,8 @@ export async function serve() {
|
|
|
254
306
|
TMPDIR: process.env.TMPDIR,
|
|
255
307
|
DISPLAY: process.env.DISPLAY,
|
|
256
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,
|
|
257
311
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
258
312
|
...passthroughEnv,
|
|
259
313
|
// Enable extension bridge when credentials are present
|
|
@@ -341,6 +395,18 @@ export async function serve() {
|
|
|
341
395
|
},
|
|
342
396
|
});
|
|
343
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
|
+
|
|
344
410
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
345
411
|
log(`Connection URL set (use .env.local to view)`);
|
|
346
412
|
}
|
package/serve.ts
CHANGED
|
@@ -58,9 +58,20 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
@@ -84,6 +95,45 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
84
95
|
return nameOrEmail;
|
|
85
96
|
}
|
|
86
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
|
+
|
|
87
137
|
export async function serve() {
|
|
88
138
|
const url = await getOrCreateUrl();
|
|
89
139
|
const { key, port } = parseUrl(url);
|
|
@@ -104,7 +154,7 @@ export async function serve() {
|
|
|
104
154
|
}, 86_400_000);
|
|
105
155
|
}
|
|
106
156
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
107
|
-
const
|
|
157
|
+
const startServer = () => Bun.serve({
|
|
108
158
|
hostname: listenHost,
|
|
109
159
|
port,
|
|
110
160
|
tls,
|
|
@@ -183,7 +233,7 @@ export async function serve() {
|
|
|
183
233
|
});
|
|
184
234
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
185
235
|
|
|
186
|
-
const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab")
|
|
236
|
+
const [bin, ...binArgs] = splitCommand(process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab");
|
|
187
237
|
|
|
188
238
|
if (filteredArgs.length === 0) {
|
|
189
239
|
filteredArgs.push("--help");
|
|
@@ -196,8 +246,10 @@ export async function serve() {
|
|
|
196
246
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
197
247
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
198
248
|
|
|
199
|
-
// bare `
|
|
200
|
-
|
|
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) {
|
|
201
253
|
try {
|
|
202
254
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
203
255
|
cwd: workDir,
|
|
@@ -254,6 +306,8 @@ export async function serve() {
|
|
|
254
306
|
TMPDIR: process.env.TMPDIR,
|
|
255
307
|
DISPLAY: process.env.DISPLAY,
|
|
256
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,
|
|
257
311
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
258
312
|
...passthroughEnv,
|
|
259
313
|
// Enable extension bridge when credentials are present
|
|
@@ -341,6 +395,18 @@ export async function serve() {
|
|
|
341
395
|
},
|
|
342
396
|
});
|
|
343
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
|
+
|
|
344
410
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
345
411
|
log(`Connection URL set (use .env.local to view)`);
|
|
346
412
|
}
|