rechrome 1.12.3 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extension/lib/background.mjs +44 -24
- package/extension/lib/ui/connect.js +8 -7
- package/package.json +1 -1
- package/rech.js +38 -10
- package/rech.ts +38 -10
- package/serve.js +15 -1
- package/serve.ts +15 -1
|
@@ -368,14 +368,33 @@ async function openRelayConnection(mcpRelayUrl, protocolVersion) {
|
|
|
368
368
|
}
|
|
369
369
|
const PLAYWRIGHT_GROUP_TITLE = "Playwright";
|
|
370
370
|
const PLAYWRIGHT_GROUP_COLOR = "green";
|
|
371
|
+
const PLAYWRIGHT_GROUP_MARK = "🎭";
|
|
371
372
|
const NON_DEBUGGABLE_SCHEMES = ["chrome:", "edge:", "devtools:"];
|
|
372
373
|
const CONNECTED_BADGE = { text: "✓", color: "#4CAF50", title: "Connected to Playwright client" };
|
|
373
374
|
function isNonDebuggableUrl(url) {
|
|
374
375
|
return !!url && NON_DEBUGGABLE_SCHEMES.some((s) => url.startsWith(s));
|
|
375
376
|
}
|
|
377
|
+
function urlDomain(url) {
|
|
378
|
+
if (!url)
|
|
379
|
+
return void 0;
|
|
380
|
+
try {
|
|
381
|
+
const u = new URL(url);
|
|
382
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
383
|
+
return void 0;
|
|
384
|
+
return u.hostname.replace(/^www\./, "");
|
|
385
|
+
} catch {
|
|
386
|
+
return void 0;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function groupTitle(clientName, seedUrl) {
|
|
390
|
+
return `${PLAYWRIGHT_GROUP_MARK} ${clientName || urlDomain(seedUrl) || PLAYWRIGHT_GROUP_TITLE}`;
|
|
391
|
+
}
|
|
376
392
|
async function cleanupStalePlaywrightGroups() {
|
|
377
393
|
try {
|
|
378
|
-
const groups = await chrome.tabGroups.query({
|
|
394
|
+
const groups = (await chrome.tabGroups.query({})).filter((g) => {
|
|
395
|
+
var _a;
|
|
396
|
+
return (_a = g.title) == null ? void 0 : _a.startsWith(PLAYWRIGHT_GROUP_MARK);
|
|
397
|
+
});
|
|
379
398
|
const tabsPerGroup = await Promise.all(groups.map((g) => chrome.tabs.query({ groupId: g.id })));
|
|
380
399
|
const tabIds = tabsPerGroup.flat().map((t) => t.id).filter((id) => id !== void 0);
|
|
381
400
|
if (tabIds.length)
|
|
@@ -385,14 +404,16 @@ async function cleanupStalePlaywrightGroups() {
|
|
|
385
404
|
}
|
|
386
405
|
}
|
|
387
406
|
class ConnectedTabGroup {
|
|
388
|
-
constructor(connection, selectedTab) {
|
|
407
|
+
constructor(connection, selectedTab, clientName) {
|
|
389
408
|
__publicField(this, "_connection");
|
|
390
409
|
__publicField(this, "_groupId", null);
|
|
391
410
|
__publicField(this, "_groupTabIds", /* @__PURE__ */ new Set());
|
|
392
411
|
__publicField(this, "_onTabUpdatedListener");
|
|
393
412
|
__publicField(this, "_onTabRemovedListener");
|
|
413
|
+
__publicField(this, "_groupTitle");
|
|
394
414
|
__publicField(this, "onclose");
|
|
395
415
|
this._connection = connection;
|
|
416
|
+
this._groupTitle = groupTitle(clientName, selectedTab.url);
|
|
396
417
|
this._connection.onclose = () => this._onConnectionClose();
|
|
397
418
|
this._connection.ontabattached = (tabId) => this._onTabAttached(tabId);
|
|
398
419
|
this._connection.ontabdetached = (tabId) => this._onTabDetached(tabId);
|
|
@@ -485,7 +506,7 @@ class ConnectedTabGroup {
|
|
|
485
506
|
await this._retryOnDrag(async () => {
|
|
486
507
|
if (this._groupId === null) {
|
|
487
508
|
this._groupId = await chrome.tabs.group({ tabIds: [tabId] });
|
|
488
|
-
await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title:
|
|
509
|
+
await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title: this._groupTitle });
|
|
489
510
|
} else {
|
|
490
511
|
await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] });
|
|
491
512
|
}
|
|
@@ -518,8 +539,11 @@ class ConnectedTabGroup {
|
|
|
518
539
|
}
|
|
519
540
|
class PlaywrightExtension {
|
|
520
541
|
constructor() {
|
|
521
|
-
|
|
522
|
-
|
|
542
|
+
// Multiple concurrent clients can share one Chrome profile — each connection gets
|
|
543
|
+
// its own ConnectedTabGroup (its own Chrome tab group), so a new client no longer
|
|
544
|
+
// evicts existing ones. Tabs stay isolated by per-group _groupId / attachedTabs.
|
|
545
|
+
__publicField(this, "_activeGroups", /* @__PURE__ */ new Set());
|
|
546
|
+
__publicField(this, "_clientNames", /* @__PURE__ */ new Map());
|
|
523
547
|
__publicField(this, "_pendingConnections", new PendingConnections());
|
|
524
548
|
// Service worker restarts lose all connection state, so any existing
|
|
525
549
|
// Playwright groups are stale. Connections wait on this before reconciling.
|
|
@@ -530,7 +554,6 @@ class PlaywrightExtension {
|
|
|
530
554
|
}
|
|
531
555
|
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
|
|
532
556
|
_onMessage(message, sender, sendResponse) {
|
|
533
|
-
var _a;
|
|
534
557
|
switch (message.type) {
|
|
535
558
|
case "connectionRequested":
|
|
536
559
|
this._pendingConnections.create(sender.tab.id, message.mcpRelayUrl, message.protocolVersion).then(
|
|
@@ -541,8 +564,8 @@ class PlaywrightExtension {
|
|
|
541
564
|
case "getTabs":
|
|
542
565
|
this._getTabs().then(
|
|
543
566
|
(tabs) => {
|
|
544
|
-
var
|
|
545
|
-
return sendResponse({ success: true, tabs, currentTabId: (
|
|
567
|
+
var _a;
|
|
568
|
+
return sendResponse({ success: true, tabs, currentTabId: (_a = sender.tab) == null ? void 0 : _a.id });
|
|
546
569
|
},
|
|
547
570
|
(error) => sendResponse({ success: false, error: error.message })
|
|
548
571
|
);
|
|
@@ -557,8 +580,8 @@ class PlaywrightExtension {
|
|
|
557
580
|
}
|
|
558
581
|
case "getConnectionStatus":
|
|
559
582
|
sendResponse({
|
|
560
|
-
connectedTabIds: ((
|
|
561
|
-
clientName: this.
|
|
583
|
+
connectedTabIds: [...this._activeGroups].flatMap((group) => group.connectedTabIds()),
|
|
584
|
+
clientName: [...this._clientNames.values()].filter(Boolean).join(", ") || void 0
|
|
562
585
|
});
|
|
563
586
|
return false;
|
|
564
587
|
case "disconnect":
|
|
@@ -576,19 +599,16 @@ class PlaywrightExtension {
|
|
|
576
599
|
async _connectTab(selectorTabId, tab, clientName) {
|
|
577
600
|
try {
|
|
578
601
|
await this._cleanupPromise;
|
|
579
|
-
this._disconnect("Another connection is requested");
|
|
580
602
|
const connection = await this._pendingConnections.take(selectorTabId);
|
|
581
603
|
if (!connection)
|
|
582
604
|
throw new Error("Pending client connection closed");
|
|
583
|
-
const group = new ConnectedTabGroup(connection, tab);
|
|
605
|
+
const group = new ConnectedTabGroup(connection, tab, clientName);
|
|
584
606
|
group.onclose = () => {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
this._activeClientName = void 0;
|
|
588
|
-
}
|
|
607
|
+
this._activeGroups.delete(group);
|
|
608
|
+
this._clientNames.delete(group);
|
|
589
609
|
};
|
|
590
|
-
this.
|
|
591
|
-
this.
|
|
610
|
+
this._activeGroups.add(group);
|
|
611
|
+
this._clientNames.set(group, clientName);
|
|
592
612
|
await Promise.all([
|
|
593
613
|
chrome.tabs.update(tab.id, { active: true }),
|
|
594
614
|
chrome.windows.update(tab.windowId, { focused: true })
|
|
@@ -612,13 +632,13 @@ class PlaywrightExtension {
|
|
|
612
632
|
active: true
|
|
613
633
|
});
|
|
614
634
|
}
|
|
615
|
-
// Closes
|
|
616
|
-
//
|
|
635
|
+
// Closes every active group's connection. ConnectedTabGroup's onclose handles
|
|
636
|
+
// per-group state cleanup (connectedTabIds, badges, reconcile).
|
|
617
637
|
_disconnect(reason) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
this.
|
|
621
|
-
this.
|
|
638
|
+
for (const group of this._activeGroups)
|
|
639
|
+
group.close(reason);
|
|
640
|
+
this._activeGroups.clear();
|
|
641
|
+
this._clientNames.clear();
|
|
622
642
|
}
|
|
623
643
|
}
|
|
624
644
|
new PlaywrightExtension();
|
|
@@ -27,9 +27,10 @@ const ConnectApp = () => {
|
|
|
27
27
|
setError(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
|
+
let info = "unknown";
|
|
30
31
|
try {
|
|
31
32
|
const client = JSON.parse(params.get("client") || "{}");
|
|
32
|
-
|
|
33
|
+
info = `${client.name || "unknown"}`;
|
|
33
34
|
setClientInfo(info);
|
|
34
35
|
setStatus({
|
|
35
36
|
type: "connecting",
|
|
@@ -60,7 +61,7 @@ const ConnectApp = () => {
|
|
|
60
61
|
const expectedToken = getOrCreateAuthToken();
|
|
61
62
|
const token = params.get("token");
|
|
62
63
|
if (token === expectedToken) {
|
|
63
|
-
await handleConnectToTab();
|
|
64
|
+
await handleConnectToTab(void 0, info);
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
66
67
|
if (token) {
|
|
@@ -86,26 +87,26 @@ const ConnectApp = () => {
|
|
|
86
87
|
else
|
|
87
88
|
setStatus({ type: "error", message: "Failed to load tabs: " + response.error });
|
|
88
89
|
}, []);
|
|
89
|
-
const handleConnectToTab = reactExports.useCallback(async (tab) => {
|
|
90
|
+
const handleConnectToTab = reactExports.useCallback(async (tab, clientName = clientInfo) => {
|
|
90
91
|
setShowTabList(false);
|
|
91
92
|
try {
|
|
92
93
|
const response = await chrome.runtime.sendMessage({
|
|
93
94
|
type: "connectToTab",
|
|
94
95
|
tab,
|
|
95
|
-
clientName
|
|
96
|
+
clientName
|
|
96
97
|
});
|
|
97
98
|
if (response == null ? void 0 : response.success) {
|
|
98
|
-
setStatus({ type: "connected", message: `"${
|
|
99
|
+
setStatus({ type: "connected", message: `"${clientName}" connected.` });
|
|
99
100
|
} else {
|
|
100
101
|
setStatus({
|
|
101
102
|
type: "error",
|
|
102
|
-
message: (response == null ? void 0 : response.error) || `"${
|
|
103
|
+
message: (response == null ? void 0 : response.error) || `"${clientName}" failed to connect.`
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
} catch (e) {
|
|
106
107
|
setStatus({
|
|
107
108
|
type: "error",
|
|
108
|
-
message: `"${
|
|
109
|
+
message: `"${clientName}" failed to connect: ${e}`
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
}, [clientInfo]);
|
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -597,7 +597,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
597
597
|
};
|
|
598
598
|
|
|
599
599
|
// [1/4] Daemon
|
|
600
|
-
console.log("\n[1/4]
|
|
600
|
+
console.log("\n[1/4] Checking serve daemon...");
|
|
601
601
|
|
|
602
602
|
// Bind address (persists to ~/.env.local as RECH_HOST).
|
|
603
603
|
// Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
|
|
@@ -630,15 +630,24 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
630
630
|
const liveBindUnknown = !!authPing?.ok && !liveBind;
|
|
631
631
|
const currentBind = liveBind || persistedBind;
|
|
632
632
|
|
|
633
|
+
// A healthy daemon already answering on our key needs no reinstall — don't re-prompt for it.
|
|
634
|
+
const daemonHealthy = !!(anonPing && authPing?.ok && !liveBindUnknown);
|
|
635
|
+
// An explicit RECH_HOST override that differs from the live bind is a deliberate rebind request.
|
|
636
|
+
const explicitRebind = !!process.env.RECH_HOST && process.env.RECH_HOST !== currentBind;
|
|
637
|
+
|
|
633
638
|
// Non-TTY honors explicit process.env.RECH_HOST (shell or merged env stack) — matches the documented `RECH_HOST=0.0.0.0 rech setup` flow.
|
|
634
639
|
let desiredBind = process.env.RECH_HOST || currentBind;
|
|
635
|
-
|
|
640
|
+
// Only prompt to (re)configure the bind when we actually need to set up the daemon. A running
|
|
641
|
+
// daemon is left alone unless the user explicitly asks for a different bind via RECH_HOST.
|
|
642
|
+
if (isTTY && (!daemonHealthy || explicitRebind)) {
|
|
636
643
|
console.log(`\n Bind address (current: ${currentBind}):`);
|
|
637
644
|
console.log(` 1. 127.0.0.1 (localhost only)`);
|
|
638
645
|
console.log(` 2. 0.0.0.0 (all interfaces — HTTP plaintext, trust your network)`);
|
|
639
646
|
const defaultBindChoice = currentBind === "0.0.0.0" ? "2" : "1";
|
|
640
647
|
const bindAns = (await ask(` Choice [${defaultBindChoice}]: `, defaultBindChoice)).trim();
|
|
641
648
|
desiredBind = bindAns === "2" || bindAns === "0.0.0.0" ? "0.0.0.0" : "127.0.0.1";
|
|
649
|
+
} else if (daemonHealthy) {
|
|
650
|
+
console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
|
|
642
651
|
}
|
|
643
652
|
const bindChanged = desiredBind !== currentBind;
|
|
644
653
|
const persistedChanged = desiredBind !== persistedBind;
|
|
@@ -774,11 +783,26 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
774
783
|
console.log(` [agent] Find PLAYWRIGHT_MCP_EXTENSION_TOKEN=... on that page`);
|
|
775
784
|
console.log(` [agent] Provide the token value on next stdin line:\n`);
|
|
776
785
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
786
|
+
// Retry on empty/too-short paste — a truncated copy or a stale token shouldn't
|
|
787
|
+
// abort the whole setup. Bounded so a non-TTY agent with exhausted stdin can't spin.
|
|
788
|
+
const maxTries = isTTY ? 5 : 3;
|
|
789
|
+
for (let attempt = 1; attempt <= maxTries; attempt++) {
|
|
790
|
+
const tokenInput = (await ask(" Paste token: ")).trim();
|
|
791
|
+
const token = tokenInput.replace(/^.*?=/, "").trim();
|
|
792
|
+
const retriesLeft = maxTries - attempt;
|
|
793
|
+
if (!token) {
|
|
794
|
+
console.error(` No token entered.${retriesLeft ? " Copy the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value and try again." : ""}`);
|
|
795
|
+
} else if (token.length < 20) {
|
|
796
|
+
console.error(` Token too short (${token.length} chars) — likely truncated when copying.${retriesLeft ? " Re-copy the full value and try again." : ""}`);
|
|
797
|
+
} else {
|
|
798
|
+
console.log(" Token accepted");
|
|
799
|
+
return { extId, token };
|
|
800
|
+
}
|
|
801
|
+
// Non-TTY with no input left: ask() won't block, so stop instead of burning retries on empty reads.
|
|
802
|
+
if (!isTTY && !tokenInput) break;
|
|
803
|
+
}
|
|
804
|
+
console.error(" No valid token provided — aborting");
|
|
805
|
+
return null;
|
|
782
806
|
}
|
|
783
807
|
|
|
784
808
|
// [2/4] Primary profile
|
|
@@ -809,12 +833,16 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
809
833
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
810
834
|
const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
|
|
811
835
|
const homeEnvPath = join(process.env.HOME!, ".env.local");
|
|
836
|
+
// Show whether each target already exists so it's clear we'll update (merge) vs create.
|
|
837
|
+
const tag = async (p: string) => (await file(p).exists()) ? "exists → will update" : "new file";
|
|
838
|
+
const [pwdTag, pwdRechTag, homeTag] = await Promise.all([tag(pwdEnvPath), tag(pwdRechPath), tag(homeEnvPath)]);
|
|
812
839
|
const saveChoice = (await ask(
|
|
813
|
-
`Save to:\n 1. ${pwdEnvPath} (current dir) [default]\n 2. ${pwdRechPath} (current dir, rechrome-only)\n 3. ${homeEnvPath} (user home)\n 4. Skip (already copied)\n\n Choice [1]: `
|
|
840
|
+
`Save to:\n 1. ${pwdEnvPath} (current dir) [${pwdTag}] [default]\n 2. ${pwdRechPath} (current dir, rechrome-only) [${pwdRechTag}]\n 3. ${homeEnvPath} (user home) [${homeTag}]\n 4. Skip (already copied)\n\n Choice [1]: `
|
|
814
841
|
)).trim();
|
|
815
842
|
if (saveChoice !== "4") {
|
|
816
843
|
const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
|
|
817
844
|
if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
|
|
845
|
+
const existedBefore = await file(globalEnvPath).exists();
|
|
818
846
|
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
819
847
|
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
820
848
|
let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
|
|
@@ -822,7 +850,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
822
850
|
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
823
851
|
else lines.push(newLine);
|
|
824
852
|
await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
|
|
825
|
-
console.log(`\
|
|
853
|
+
console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
|
|
826
854
|
}
|
|
827
855
|
|
|
828
856
|
// Save primary to token registry
|
|
@@ -850,7 +878,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
850
878
|
}
|
|
851
879
|
rl?.close();
|
|
852
880
|
envWatcher?.close();
|
|
853
|
-
console.log(`\nDone! Test with:\n rech
|
|
881
|
+
console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
|
|
854
882
|
}
|
|
855
883
|
|
|
856
884
|
async function status(): Promise<void> {
|
package/rech.ts
CHANGED
|
@@ -597,7 +597,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
597
597
|
};
|
|
598
598
|
|
|
599
599
|
// [1/4] Daemon
|
|
600
|
-
console.log("\n[1/4]
|
|
600
|
+
console.log("\n[1/4] Checking serve daemon...");
|
|
601
601
|
|
|
602
602
|
// Bind address (persists to ~/.env.local as RECH_HOST).
|
|
603
603
|
// Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
|
|
@@ -630,15 +630,24 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
630
630
|
const liveBindUnknown = !!authPing?.ok && !liveBind;
|
|
631
631
|
const currentBind = liveBind || persistedBind;
|
|
632
632
|
|
|
633
|
+
// A healthy daemon already answering on our key needs no reinstall — don't re-prompt for it.
|
|
634
|
+
const daemonHealthy = !!(anonPing && authPing?.ok && !liveBindUnknown);
|
|
635
|
+
// An explicit RECH_HOST override that differs from the live bind is a deliberate rebind request.
|
|
636
|
+
const explicitRebind = !!process.env.RECH_HOST && process.env.RECH_HOST !== currentBind;
|
|
637
|
+
|
|
633
638
|
// Non-TTY honors explicit process.env.RECH_HOST (shell or merged env stack) — matches the documented `RECH_HOST=0.0.0.0 rech setup` flow.
|
|
634
639
|
let desiredBind = process.env.RECH_HOST || currentBind;
|
|
635
|
-
|
|
640
|
+
// Only prompt to (re)configure the bind when we actually need to set up the daemon. A running
|
|
641
|
+
// daemon is left alone unless the user explicitly asks for a different bind via RECH_HOST.
|
|
642
|
+
if (isTTY && (!daemonHealthy || explicitRebind)) {
|
|
636
643
|
console.log(`\n Bind address (current: ${currentBind}):`);
|
|
637
644
|
console.log(` 1. 127.0.0.1 (localhost only)`);
|
|
638
645
|
console.log(` 2. 0.0.0.0 (all interfaces — HTTP plaintext, trust your network)`);
|
|
639
646
|
const defaultBindChoice = currentBind === "0.0.0.0" ? "2" : "1";
|
|
640
647
|
const bindAns = (await ask(` Choice [${defaultBindChoice}]: `, defaultBindChoice)).trim();
|
|
641
648
|
desiredBind = bindAns === "2" || bindAns === "0.0.0.0" ? "0.0.0.0" : "127.0.0.1";
|
|
649
|
+
} else if (daemonHealthy) {
|
|
650
|
+
console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
|
|
642
651
|
}
|
|
643
652
|
const bindChanged = desiredBind !== currentBind;
|
|
644
653
|
const persistedChanged = desiredBind !== persistedBind;
|
|
@@ -774,11 +783,26 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
774
783
|
console.log(` [agent] Find PLAYWRIGHT_MCP_EXTENSION_TOKEN=... on that page`);
|
|
775
784
|
console.log(` [agent] Provide the token value on next stdin line:\n`);
|
|
776
785
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
786
|
+
// Retry on empty/too-short paste — a truncated copy or a stale token shouldn't
|
|
787
|
+
// abort the whole setup. Bounded so a non-TTY agent with exhausted stdin can't spin.
|
|
788
|
+
const maxTries = isTTY ? 5 : 3;
|
|
789
|
+
for (let attempt = 1; attempt <= maxTries; attempt++) {
|
|
790
|
+
const tokenInput = (await ask(" Paste token: ")).trim();
|
|
791
|
+
const token = tokenInput.replace(/^.*?=/, "").trim();
|
|
792
|
+
const retriesLeft = maxTries - attempt;
|
|
793
|
+
if (!token) {
|
|
794
|
+
console.error(` No token entered.${retriesLeft ? " Copy the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value and try again." : ""}`);
|
|
795
|
+
} else if (token.length < 20) {
|
|
796
|
+
console.error(` Token too short (${token.length} chars) — likely truncated when copying.${retriesLeft ? " Re-copy the full value and try again." : ""}`);
|
|
797
|
+
} else {
|
|
798
|
+
console.log(" Token accepted");
|
|
799
|
+
return { extId, token };
|
|
800
|
+
}
|
|
801
|
+
// Non-TTY with no input left: ask() won't block, so stop instead of burning retries on empty reads.
|
|
802
|
+
if (!isTTY && !tokenInput) break;
|
|
803
|
+
}
|
|
804
|
+
console.error(" No valid token provided — aborting");
|
|
805
|
+
return null;
|
|
782
806
|
}
|
|
783
807
|
|
|
784
808
|
// [2/4] Primary profile
|
|
@@ -809,12 +833,16 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
809
833
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
810
834
|
const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
|
|
811
835
|
const homeEnvPath = join(process.env.HOME!, ".env.local");
|
|
836
|
+
// Show whether each target already exists so it's clear we'll update (merge) vs create.
|
|
837
|
+
const tag = async (p: string) => (await file(p).exists()) ? "exists → will update" : "new file";
|
|
838
|
+
const [pwdTag, pwdRechTag, homeTag] = await Promise.all([tag(pwdEnvPath), tag(pwdRechPath), tag(homeEnvPath)]);
|
|
812
839
|
const saveChoice = (await ask(
|
|
813
|
-
`Save to:\n 1. ${pwdEnvPath} (current dir) [default]\n 2. ${pwdRechPath} (current dir, rechrome-only)\n 3. ${homeEnvPath} (user home)\n 4. Skip (already copied)\n\n Choice [1]: `
|
|
840
|
+
`Save to:\n 1. ${pwdEnvPath} (current dir) [${pwdTag}] [default]\n 2. ${pwdRechPath} (current dir, rechrome-only) [${pwdRechTag}]\n 3. ${homeEnvPath} (user home) [${homeTag}]\n 4. Skip (already copied)\n\n Choice [1]: `
|
|
814
841
|
)).trim();
|
|
815
842
|
if (saveChoice !== "4") {
|
|
816
843
|
const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
|
|
817
844
|
if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
|
|
845
|
+
const existedBefore = await file(globalEnvPath).exists();
|
|
818
846
|
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
819
847
|
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
820
848
|
let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
|
|
@@ -822,7 +850,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
822
850
|
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
823
851
|
else lines.push(newLine);
|
|
824
852
|
await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
|
|
825
|
-
console.log(`\
|
|
853
|
+
console.log(`\n${existedBefore ? "Updated" : "Created"} ${globalEnvPath}`);
|
|
826
854
|
}
|
|
827
855
|
|
|
828
856
|
// Save primary to token registry
|
|
@@ -850,7 +878,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
850
878
|
}
|
|
851
879
|
rl?.close();
|
|
852
880
|
envWatcher?.close();
|
|
853
|
-
console.log(`\nDone! Test with:\n rech
|
|
881
|
+
console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
|
|
854
882
|
}
|
|
855
883
|
|
|
856
884
|
async function status(): Promise<void> {
|
package/serve.js
CHANGED
|
@@ -14,6 +14,20 @@ import {
|
|
|
14
14
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
15
15
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
16
16
|
|
|
17
|
+
// Short, human-friendly label for a client identity, used as the Chrome tab-group
|
|
18
|
+
// name so concurrent sessions on one profile are distinguishable.
|
|
19
|
+
// gitUrl ".../owner/repo/tree/branch" -> "repo:branch"; "host:/path/to/dir" -> "dir";
|
|
20
|
+
// bare host/IP -> as-is. Strips a trailing "@profile" (email) suffix first.
|
|
21
|
+
function shortClientLabel(raw: string): string {
|
|
22
|
+
if (!raw) return raw;
|
|
23
|
+
const baseId = raw.includes("@") ? raw.slice(0, raw.indexOf("@")) : raw;
|
|
24
|
+
const git = baseId.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
|
|
25
|
+
if (git) return git[2] ? `${git[1]}:${git[2]}` : git[1];
|
|
26
|
+
const hostCwd = baseId.match(/^[^:]+:(.+)$/);
|
|
27
|
+
if (hostCwd) return hostCwd[1].split("/").filter(Boolean).pop() || baseId;
|
|
28
|
+
return baseId;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
|
|
18
32
|
const certContent = await file(certPath).text().catch(() => null);
|
|
19
33
|
if (!certContent) return false;
|
|
@@ -233,7 +247,7 @@ export async function serve() {
|
|
|
233
247
|
TMPDIR: process.env.TMPDIR,
|
|
234
248
|
DISPLAY: process.env.DISPLAY,
|
|
235
249
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
236
|
-
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
|
|
250
|
+
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
237
251
|
...passthroughEnv,
|
|
238
252
|
// Enable extension bridge when credentials are present
|
|
239
253
|
...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
|
package/serve.ts
CHANGED
|
@@ -14,6 +14,20 @@ import {
|
|
|
14
14
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
15
15
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
16
16
|
|
|
17
|
+
// Short, human-friendly label for a client identity, used as the Chrome tab-group
|
|
18
|
+
// name so concurrent sessions on one profile are distinguishable.
|
|
19
|
+
// gitUrl ".../owner/repo/tree/branch" -> "repo:branch"; "host:/path/to/dir" -> "dir";
|
|
20
|
+
// bare host/IP -> as-is. Strips a trailing "@profile" (email) suffix first.
|
|
21
|
+
function shortClientLabel(raw: string): string {
|
|
22
|
+
if (!raw) return raw;
|
|
23
|
+
const baseId = raw.includes("@") ? raw.slice(0, raw.indexOf("@")) : raw;
|
|
24
|
+
const git = baseId.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
|
|
25
|
+
if (git) return git[2] ? `${git[1]}:${git[2]}` : git[1];
|
|
26
|
+
const hostCwd = baseId.match(/^[^:]+:(.+)$/);
|
|
27
|
+
if (hostCwd) return hostCwd[1].split("/").filter(Boolean).pop() || baseId;
|
|
28
|
+
return baseId;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
|
|
18
32
|
const certContent = await file(certPath).text().catch(() => null);
|
|
19
33
|
if (!certContent) return false;
|
|
@@ -233,7 +247,7 @@ export async function serve() {
|
|
|
233
247
|
TMPDIR: process.env.TMPDIR,
|
|
234
248
|
DISPLAY: process.env.DISPLAY,
|
|
235
249
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
236
|
-
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
|
|
250
|
+
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
237
251
|
...passthroughEnv,
|
|
238
252
|
// Enable extension bridge when credentials are present
|
|
239
253
|
...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
|