rechrome 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -20
- package/package.json +3 -3
- package/rech.js +280 -44
- package/rech.ts +280 -44
- package/serve.js +104 -10
- package/serve.ts +104 -10
package/README.md
CHANGED
|
@@ -49,24 +49,27 @@ This auto-generates a connection URL in `.env.local` (with an auth key).
|
|
|
49
49
|
|
|
50
50
|
### 2. Run commands from a client
|
|
51
51
|
|
|
52
|
-
Copy the `
|
|
52
|
+
Copy the `RECHROME_URL` from the server's `.env.local` to the client's project `.env.local`:
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
|
|
55
|
+
# .env.local in your project directory
|
|
56
|
+
RECHROME_URL=http://YOUR_KEY@server-host:13775
|
|
56
57
|
|
|
57
58
|
# Open a URL
|
|
58
|
-
|
|
59
|
+
rech open https://example.com
|
|
59
60
|
|
|
60
61
|
# Take a screenshot
|
|
61
|
-
|
|
62
|
+
rech screenshot
|
|
62
63
|
|
|
63
64
|
# List open tabs
|
|
64
|
-
|
|
65
|
+
rech tab-list
|
|
65
66
|
|
|
66
67
|
# Any playwright-cli command works
|
|
67
|
-
|
|
68
|
+
rech --help
|
|
68
69
|
```
|
|
69
70
|
|
|
71
|
+
rechrome walks up from the current working directory to find `.env.local`, so each project can have its own connection URL, Chrome profile, and extension token.
|
|
72
|
+
|
|
70
73
|
## Configuration
|
|
71
74
|
|
|
72
75
|
Copy `.env.example` to `.env.local` and edit:
|
|
@@ -75,22 +78,24 @@ Copy `.env.example` to `.env.local` and edit:
|
|
|
75
78
|
cp .env.example .env.local
|
|
76
79
|
```
|
|
77
80
|
|
|
78
|
-
| Variable
|
|
79
|
-
|
|
|
80
|
-
| `
|
|
81
|
-
| `PLAYWRIGHT_CLI`
|
|
82
|
-
| `RECH_HOST`
|
|
83
|
-
| `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server)
|
|
84
|
-
| `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | Chrome extension token (client overrides server)
|
|
85
|
-
| `PLAYWRIGHT_MCP_USER_DATA_DIR` | Chrome user data directory — use to pin connections to a specific Chrome install (client overrides server)
|
|
86
|
-
| `PLAYWRIGHT_MCP_PROFILE_DIRECTORY` | Chrome profile
|
|
87
|
-
|
|
88
|
-
> **Multi-profile tip:**
|
|
81
|
+
| Variable | Description | Default |
|
|
82
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
83
|
+
| `RECHROME_URL` | Connection URL (auto-generated by `rech serve`). Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
|
|
84
|
+
| `PLAYWRIGHT_CLI` | Path to playwright-cli binary (recommended: `playwright-cli-multi-tab` for full multi-tab support) | `playwright-cli` |
|
|
85
|
+
| `RECH_HOST` | Server bind address | `127.0.0.1` |
|
|
86
|
+
| `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
|
|
87
|
+
| `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | Chrome extension token — profile-specific, get it from the extension's status page (client overrides server) | — |
|
|
88
|
+
| `PLAYWRIGHT_MCP_USER_DATA_DIR` | Chrome user data directory — use to pin connections to a specific Chrome install (client overrides server) | — |
|
|
89
|
+
| `PLAYWRIGHT_MCP_PROFILE_DIRECTORY` | Chrome profile — accepts directory name (`Profile 2`), display name (`Snowstar`), or **email** (`you@example.com`) (client overrides server) | — |
|
|
90
|
+
|
|
91
|
+
> **Multi-profile tip:** Each project's `.env.local` can specify a different Chrome profile via the `?profile=` query param in `RECHROME_URL`. The server resolves display names and email addresses to the actual Chrome profile directory automatically (reads `~/Library/Application Support/Google/Chrome/Local State`).
|
|
92
|
+
>
|
|
89
93
|
> ```
|
|
90
|
-
>
|
|
91
|
-
>
|
|
94
|
+
> # .env.local for a work project
|
|
95
|
+
> RECHROME_URL="http://KEY@server:13775?token=TOKEN&extension_id=EXT_ID&profile=taku%40company.com"
|
|
92
96
|
> ```
|
|
93
|
-
>
|
|
97
|
+
>
|
|
98
|
+
> Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
|
|
94
99
|
|
|
95
100
|
### Remote access
|
|
96
101
|
|
|
@@ -115,9 +120,24 @@ bun install
|
|
|
115
120
|
bun test
|
|
116
121
|
```
|
|
117
122
|
|
|
123
|
+
## Why we fork playwright
|
|
124
|
+
|
|
125
|
+
rechrome depends on [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab), which is a fork of [microsoft/playwright](https://github.com/microsoft/playwright). We maintain it because the upstream does not yet support several features required for rechrome's use case:
|
|
126
|
+
|
|
127
|
+
| Feature | Our change | Status |
|
|
128
|
+
|---------|-----------|--------|
|
|
129
|
+
| Multi-tab control | `playwright-multi-tab` fork adds `tab-new`, `tab-list`, `tab-select`, `tab-close` commands and a persistent session daemon | Not in upstream |
|
|
130
|
+
| `PLAYWRIGHT_MCP_EXTENSION_ID` | Lets you specify a custom extension ID instead of the hardcoded default | Not in upstream |
|
|
131
|
+
| `PLAYWRIGHT_MCP_PROFILE_DIRECTORY` | Passes `--profile-directory` to Chrome so the correct system profile is used; auto-detects the Chrome user data dir by OS | Not in upstream |
|
|
132
|
+
|
|
133
|
+
We also fork [playwright-mcp](https://github.com/snomiao/playwright-mcp) (inside `playwright-multi-tab/lib/playwright-mcp`) to support the custom extension ID and multi-tab session routing.
|
|
134
|
+
|
|
135
|
+
PRs upstream are welcome. Once these features land in the official packages we will drop the forks.
|
|
136
|
+
|
|
118
137
|
## Related
|
|
119
138
|
|
|
120
139
|
- [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab) — the underlying Playwright fork powering rechrome's multi-tab and multi-session browser control
|
|
140
|
+
- [microsoft/playwright](https://github.com/microsoft/playwright) — upstream
|
|
121
141
|
|
|
122
142
|
## License
|
|
123
143
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/rechrome.git"
|
|
7
7
|
},
|
|
8
8
|
"bin": {
|
|
9
|
-
"rech": "./rech.
|
|
10
|
-
"rechrome": "./rech.
|
|
9
|
+
"rech": "./rech.ts",
|
|
10
|
+
"rechrome": "./rech.ts"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
".env.example",
|
package/rech.js
CHANGED
|
@@ -4,29 +4,49 @@ import { file } from "bun";
|
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
5
|
import { mkdirSync, appendFileSync, existsSync } from "fs";
|
|
6
6
|
import { hostname } from "os";
|
|
7
|
-
import { join, basename } from "path";
|
|
7
|
+
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
9
|
-
export const ENV_KEY = "
|
|
9
|
+
export const ENV_KEY = "RECHROME_URL";
|
|
10
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
|
-
// Load .env.local from script's directory (works even when invoked from elsewhere)
|
|
15
14
|
const envFile = join(import.meta.dir, ".env.local");
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.catch(() => "");
|
|
16
|
+
async function loadEnvFile(path: string): Promise<boolean> {
|
|
17
|
+
const envRaw = await file(path).text().catch(() => "");
|
|
18
|
+
if (!envRaw) return false;
|
|
19
|
+
let hasKey = false;
|
|
22
20
|
for (const line of envRaw.split("\n")) {
|
|
23
21
|
const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
|
|
24
|
-
if (m)
|
|
22
|
+
if (m) {
|
|
23
|
+
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
24
|
+
if (m[1] === ENV_KEY) hasKey = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return hasKey;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadEnv() {
|
|
31
|
+
// Walk up from cwd first — project-local .env.local takes priority
|
|
32
|
+
let dir = process.cwd();
|
|
33
|
+
while (true) {
|
|
34
|
+
if (await loadEnvFile(join(dir, ".env.local"))) break;
|
|
35
|
+
const parent = join(dir, "..");
|
|
36
|
+
if (parent === dir) break;
|
|
37
|
+
dir = parent;
|
|
25
38
|
}
|
|
39
|
+
// Fall back to script dir's .env.local
|
|
40
|
+
if (!process.env[ENV_KEY]) await loadEnvFile(envFile);
|
|
41
|
+
}
|
|
42
|
+
// Shell-set passthrough vars survive .env.local loading
|
|
43
|
+
const _shellPassthrough: Record<string, string> = {};
|
|
44
|
+
for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
|
|
45
|
+
if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
|
|
26
46
|
}
|
|
27
47
|
await loadEnv();
|
|
48
|
+
Object.assign(process.env, _shellPassthrough);
|
|
28
49
|
|
|
29
|
-
// Watch .env.local for changes and hot-reload
|
|
30
50
|
import { watch } from "node:fs";
|
|
31
51
|
if (existsSync(envFile)) {
|
|
32
52
|
watch(envFile, async () => {
|
|
@@ -39,8 +59,8 @@ if (existsSync(envFile)) {
|
|
|
39
59
|
export const PASSTHROUGH_ENV_KEYS = [
|
|
40
60
|
"PLAYWRIGHT_MCP_EXTENSION_ID",
|
|
41
61
|
"PLAYWRIGHT_MCP_EXTENSION_TOKEN",
|
|
42
|
-
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
43
62
|
"PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
|
|
63
|
+
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
44
64
|
] as const;
|
|
45
65
|
|
|
46
66
|
export function log(msg: string) {
|
|
@@ -54,13 +74,25 @@ export function log(msg: string) {
|
|
|
54
74
|
|
|
55
75
|
export function parseUrl(raw: string) {
|
|
56
76
|
const u = new URL(raw);
|
|
57
|
-
|
|
77
|
+
const scheme = u.protocol.replace(":", "");
|
|
78
|
+
const protocol = scheme === "https" ? "https" : "http";
|
|
79
|
+
const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
|
|
80
|
+
return {
|
|
81
|
+
key: u.username,
|
|
82
|
+
host: u.hostname,
|
|
83
|
+
port: parseInt(u.port) || defaultPort,
|
|
84
|
+
protocol,
|
|
85
|
+
extensionId: u.searchParams.get("extension_id") ?? undefined,
|
|
86
|
+
extensionToken: u.searchParams.get("token") ?? undefined,
|
|
87
|
+
profileDirectory: u.searchParams.get("profile") ?? undefined,
|
|
88
|
+
userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
|
|
89
|
+
};
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
export async function getOrCreateUrl(): Promise<string> {
|
|
61
93
|
if (process.env[ENV_KEY]) return process.env[ENV_KEY];
|
|
62
94
|
const key = randomBytes(9).toString("base64url"); // 12 chars
|
|
63
|
-
const url = `
|
|
95
|
+
const url = `http://${key}@${hostname()}:${DEFAULT_PORT}`;
|
|
64
96
|
const newLine = `${ENV_KEY}=${url}`;
|
|
65
97
|
const envRaw = await file(envFile)
|
|
66
98
|
.text()
|
|
@@ -121,49 +153,162 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
|
|
|
121
153
|
return { hostname: hostname(), cwd };
|
|
122
154
|
}
|
|
123
155
|
|
|
124
|
-
function getClientEnv(): Record<string, string> {
|
|
156
|
+
function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Record<string, string> {
|
|
125
157
|
const env: Record<string, string> = {};
|
|
126
158
|
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
127
159
|
if (process.env[key]) env[key] = process.env[key];
|
|
128
160
|
}
|
|
161
|
+
if (urlExtras?.extensionId)
|
|
162
|
+
env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
|
|
163
|
+
if (urlExtras?.extensionToken)
|
|
164
|
+
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
|
|
165
|
+
if (urlExtras?.profileDirectory)
|
|
166
|
+
env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
|
|
167
|
+
if (urlExtras?.userDataDir)
|
|
168
|
+
env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
|
|
129
169
|
return env;
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
|
|
133
|
-
const
|
|
172
|
+
const CHROME_LOCAL_STATE_PATHS = () => {
|
|
173
|
+
const home = process.env.HOME || "~";
|
|
174
|
+
return [
|
|
175
|
+
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
176
|
+
join(home, ".config/google-chrome/Local State"),
|
|
177
|
+
join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
178
|
+
];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
|
|
182
|
+
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
183
|
+
const f = file(statePath);
|
|
184
|
+
if (!(await f.exists())) continue;
|
|
185
|
+
try {
|
|
186
|
+
const data = JSON.parse(await f.text());
|
|
187
|
+
return data?.profile?.info_cache ?? null;
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function findChromeUserDataDir(): Promise<string | null> {
|
|
194
|
+
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
195
|
+
if (!(await file(statePath).exists())) continue;
|
|
196
|
+
return dirname(statePath);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const EXTENSION_DIST_DIR = join(
|
|
202
|
+
import.meta.dir,
|
|
203
|
+
"lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Walk all Chrome profiles' Secure Preferences and find an extension
|
|
207
|
+
// whose loaded `path` matches our dist directory. The extension ID Chrome
|
|
208
|
+
// generates for an unpacked extension is path-dependent, so we cannot rely
|
|
209
|
+
// on a hardcoded ID across machines.
|
|
210
|
+
async function findInstalledExtension(
|
|
211
|
+
profileDir?: string,
|
|
212
|
+
): Promise<{ id: string; profile: string } | null> {
|
|
213
|
+
const userDataDir = await findChromeUserDataDir();
|
|
214
|
+
if (!userDataDir) return null;
|
|
215
|
+
const cache = await readChromeProfileCache();
|
|
216
|
+
const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
|
|
217
|
+
for (const prof of profiles) {
|
|
218
|
+
const prefsPath = join(userDataDir, prof, "Secure Preferences");
|
|
219
|
+
const f = file(prefsPath);
|
|
220
|
+
if (!(await f.exists())) continue;
|
|
221
|
+
try {
|
|
222
|
+
const data = JSON.parse(await f.text());
|
|
223
|
+
const settings = data?.extensions?.settings ?? {};
|
|
224
|
+
for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
|
|
225
|
+
if (info?.path === EXTENSION_DIST_DIR) return { id: extId, profile: prof };
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function printInstallInstructions(profileDisplay: string): void {
|
|
233
|
+
console.error("");
|
|
234
|
+
console.error("Multi-tab extension is not installed in this Chrome profile.");
|
|
235
|
+
console.error("");
|
|
236
|
+
console.error("To install:");
|
|
237
|
+
console.error(" 1. Open chrome://extensions/ in the selected profile");
|
|
238
|
+
console.error(` (profile: ${profileDisplay})`);
|
|
239
|
+
console.error(" 2. Enable \"Developer mode\" (top-right toggle)");
|
|
240
|
+
console.error(" 3. Click \"Load unpacked\"");
|
|
241
|
+
console.error(" 4. Select this directory:");
|
|
242
|
+
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
243
|
+
console.error(" 5. Re-run `rech setup`");
|
|
244
|
+
console.error("");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function resolveProfileEmail(dir: string): Promise<string> {
|
|
248
|
+
const cache = await readChromeProfileCache();
|
|
249
|
+
if (cache?.[dir]?.user_name) return cache[dir].user_name;
|
|
250
|
+
return dir;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function listProfiles(): Promise<void> {
|
|
254
|
+
const cache = await readChromeProfileCache();
|
|
255
|
+
if (!cache) { console.error("Chrome Local State not found"); process.exit(1); }
|
|
256
|
+
|
|
257
|
+
const current = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
258
|
+
// Resolve email/name → dir for current marker
|
|
259
|
+
let currentDir = current;
|
|
260
|
+
if (current && !/^(Default|Profile \d+)$/i.test(current)) {
|
|
261
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
262
|
+
if (info.user_name === current || info.name === current) { currentDir = dir; break; }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
134
265
|
|
|
266
|
+
const rows = Object.entries(cache).map(([dir, info]) => [
|
|
267
|
+
dir,
|
|
268
|
+
info.user_name || "",
|
|
269
|
+
info.name || "",
|
|
270
|
+
dir === currentDir ? "← current" : "",
|
|
271
|
+
]);
|
|
272
|
+
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
273
|
+
for (const row of rows) {
|
|
274
|
+
console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function callServe(
|
|
279
|
+
url: string,
|
|
280
|
+
args: string[],
|
|
281
|
+
overrideEnv?: Record<string, string>,
|
|
282
|
+
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
283
|
+
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
|
|
135
284
|
const identity = await getClientIdentity();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
const res = await fetch(
|
|
285
|
+
const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
286
|
+
if (effectiveProfile) (identity as any).profile = effectiveProfile;
|
|
287
|
+
const env = { ...getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir }), ...overrideEnv };
|
|
288
|
+
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
140
289
|
method: "POST",
|
|
141
290
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
142
|
-
body: JSON.stringify({ args, identity, env
|
|
291
|
+
body: JSON.stringify({ args, identity, env }),
|
|
143
292
|
signal: AbortSignal.timeout(70_000),
|
|
144
|
-
}).catch((e) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
293
|
+
}).catch((e) => { console.error(`[rech] ${e.message}`); process.exit(1); });
|
|
294
|
+
if (res.status === 401) { console.error("Unauthorized: bad key"); process.exit(1); }
|
|
295
|
+
return res.json();
|
|
296
|
+
}
|
|
148
297
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
298
|
+
async function run(url: string, args: string[]) {
|
|
299
|
+
const { host, port, protocol } = parseUrl(url);
|
|
300
|
+
const effectiveProfile = parseUrl(url).profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
301
|
+
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
302
|
+
const identity = await getClientIdentity();
|
|
303
|
+
const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
|
|
304
|
+
console.error(
|
|
305
|
+
`[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
|
|
306
|
+
);
|
|
153
307
|
|
|
154
|
-
const { status, stdout, stderr, files, existingSession } =
|
|
155
|
-
status: number;
|
|
156
|
-
stdout: string;
|
|
157
|
-
stderr: string;
|
|
158
|
-
files?: string[];
|
|
159
|
-
existingSession?: boolean;
|
|
160
|
-
};
|
|
308
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
161
309
|
|
|
162
|
-
if (existingSession)
|
|
163
|
-
console.error(
|
|
164
|
-
`[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
310
|
+
if (existingSession)
|
|
311
|
+
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
167
312
|
if (stderr) process.stderr.write(stderr);
|
|
168
313
|
if (stdout) process.stdout.write(stdout);
|
|
169
314
|
|
|
@@ -173,8 +318,8 @@ async function run(url: string, args: string[]) {
|
|
|
173
318
|
const gitignorePath = join(dlDir, ".gitignore");
|
|
174
319
|
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
175
320
|
for (const name of files) {
|
|
176
|
-
const fileRes = await fetch(
|
|
177
|
-
headers: { Authorization: `Bearer ${key}` },
|
|
321
|
+
const fileRes = await fetch(`${protocol}://${host}:${port}/files/${name}`, {
|
|
322
|
+
headers: { Authorization: `Bearer ${parseUrl(url).key}` },
|
|
178
323
|
});
|
|
179
324
|
if (!fileRes.ok) continue;
|
|
180
325
|
const dest = join(dlDir, basename(name));
|
|
@@ -186,17 +331,108 @@ async function run(url: string, args: string[]) {
|
|
|
186
331
|
process.exit(status);
|
|
187
332
|
}
|
|
188
333
|
|
|
334
|
+
async function setup(): Promise<void> {
|
|
335
|
+
// 1. Require serve to be running
|
|
336
|
+
const url = process.env[ENV_KEY];
|
|
337
|
+
if (!url) {
|
|
338
|
+
console.error(`${ENV_KEY} not set — start the server first:\n rech serve`);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
const { host, port, protocol } = parseUrl(url);
|
|
342
|
+
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(3000) }).catch(() => null);
|
|
343
|
+
if (!ping) {
|
|
344
|
+
console.error(`rech serve is not running at ${host}:${port}\nStart it with:\n rech serve`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 2. Interactive profile selection
|
|
349
|
+
const cache = await readChromeProfileCache();
|
|
350
|
+
if (!cache) { console.error("Chrome profiles not found"); process.exit(1); }
|
|
351
|
+
const profiles = Object.entries(cache);
|
|
352
|
+
console.log("\nAvailable Chrome profiles:");
|
|
353
|
+
profiles.forEach(([dir, info], i) =>
|
|
354
|
+
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
355
|
+
);
|
|
356
|
+
const { createInterface } = await import("readline");
|
|
357
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
358
|
+
const answer = await new Promise<string>(r => rl.question("\nProfile number: ", r));
|
|
359
|
+
rl.close();
|
|
360
|
+
const idx = parseInt(answer.trim()) - 1;
|
|
361
|
+
if (isNaN(idx) || idx < 0 || idx >= profiles.length) { console.error("Invalid selection"); process.exit(1); }
|
|
362
|
+
const [profileDir, profileInfoSel] = profiles[idx];
|
|
363
|
+
const profileEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileDir };
|
|
364
|
+
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
365
|
+
|
|
366
|
+
// 3. Discover the extension ID. Unpacked extension IDs are derived from the
|
|
367
|
+
// load path, so look up the actual ID from Chrome's Secure Preferences.
|
|
368
|
+
// Env override wins if set explicitly.
|
|
369
|
+
let extId = process.env.PLAYWRIGHT_MCP_EXTENSION_ID;
|
|
370
|
+
if (!extId) {
|
|
371
|
+
const found = await findInstalledExtension(profileDir);
|
|
372
|
+
if (found) extId = found.id;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!extId) {
|
|
376
|
+
printInstallInstructions(profileDisplay);
|
|
377
|
+
console.error("Opening chrome://extensions/ in the selected profile...");
|
|
378
|
+
await callServe(url, ["open", "chrome://extensions/"], profileEnv);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const statusUrl = `chrome-extension://${extId}/status.html`;
|
|
383
|
+
console.log(`\nOpening ${statusUrl}...`);
|
|
384
|
+
const openResult = await callServe(url, ["open", statusUrl], profileEnv);
|
|
385
|
+
if (openResult.status !== 0) { process.stderr.write(openResult.stderr); process.exit(openResult.status); }
|
|
386
|
+
|
|
387
|
+
// 4. Read token from extension page localStorage
|
|
388
|
+
const evalResult = await callServe(url, ["eval", `() => localStorage.getItem('auth-token')`], profileEnv);
|
|
389
|
+
const tokenMatch = evalResult.stdout.match(/"([A-Za-z0-9_-]{20,})"/);
|
|
390
|
+
const token = tokenMatch?.[1];
|
|
391
|
+
if (!token) {
|
|
392
|
+
printInstallInstructions(profileDisplay);
|
|
393
|
+
console.error("Tried to read the auth token from the extension's status page but failed.");
|
|
394
|
+
console.error("This usually means the extension is not loaded in this profile.");
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 5. Write single RECHROME_URL with all params to ~/.env.local
|
|
399
|
+
const home = process.env.HOME!;
|
|
400
|
+
const globalEnvPath = join(home, ".env.local");
|
|
401
|
+
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
402
|
+
const rechUrl = new URL(url);
|
|
403
|
+
rechUrl.searchParams.set("extension_id", extId);
|
|
404
|
+
rechUrl.searchParams.set("token", token);
|
|
405
|
+
// Prefer email for readability, fall back to directory name
|
|
406
|
+
rechUrl.searchParams.set("profile", profileInfoSel.user_name || profileDir);
|
|
407
|
+
const userDataDir = await findChromeUserDataDir();
|
|
408
|
+
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
409
|
+
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
410
|
+
// Remove old separate vars and update RECHROME_URL
|
|
411
|
+
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
412
|
+
let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
|
|
413
|
+
const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
|
|
414
|
+
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
415
|
+
else lines.push(newLine);
|
|
416
|
+
await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
|
|
417
|
+
console.log(`\nSaved to ${globalEnvPath}:\n ${newLine}`);
|
|
418
|
+
console.log("\nDone!");
|
|
419
|
+
}
|
|
420
|
+
|
|
189
421
|
if (import.meta.main) {
|
|
190
422
|
const args = process.argv.slice(2);
|
|
191
423
|
|
|
192
424
|
if (args[0] === "serve") {
|
|
193
425
|
const { serve } = await import("./serve.js");
|
|
194
426
|
serve();
|
|
427
|
+
} else if (args[0] === "profiles") {
|
|
428
|
+
await listProfiles();
|
|
429
|
+
} else if (args[0] === "setup") {
|
|
430
|
+
await setup();
|
|
195
431
|
} else {
|
|
196
432
|
const url = process.env[ENV_KEY];
|
|
197
433
|
if (!url) {
|
|
198
434
|
console.error(
|
|
199
|
-
`Usage:\n rech serve\n ${ENV_KEY}=
|
|
435
|
+
`Usage:\n rech serve\n ${ENV_KEY}=http://key@host:${DEFAULT_PORT}?extension_id=ID&token=TOKEN rech <playwright-args...>\n ${ENV_KEY}=https://key@host/path?extension_id=ID&token=TOKEN rech <playwright-args...>`,
|
|
200
436
|
);
|
|
201
437
|
process.exit(1);
|
|
202
438
|
}
|
package/rech.ts
CHANGED
|
@@ -4,29 +4,49 @@ import { file } from "bun";
|
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
5
|
import { mkdirSync, appendFileSync, existsSync } from "fs";
|
|
6
6
|
import { hostname } from "os";
|
|
7
|
-
import { join, basename } from "path";
|
|
7
|
+
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
9
|
-
export const ENV_KEY = "
|
|
9
|
+
export const ENV_KEY = "RECHROME_URL";
|
|
10
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
|
-
// Load .env.local from script's directory (works even when invoked from elsewhere)
|
|
15
14
|
const envFile = join(import.meta.dir, ".env.local");
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.catch(() => "");
|
|
16
|
+
async function loadEnvFile(path: string): Promise<boolean> {
|
|
17
|
+
const envRaw = await file(path).text().catch(() => "");
|
|
18
|
+
if (!envRaw) return false;
|
|
19
|
+
let hasKey = false;
|
|
22
20
|
for (const line of envRaw.split("\n")) {
|
|
23
21
|
const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
|
|
24
|
-
if (m)
|
|
22
|
+
if (m) {
|
|
23
|
+
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
24
|
+
if (m[1] === ENV_KEY) hasKey = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return hasKey;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadEnv() {
|
|
31
|
+
// Walk up from cwd first — project-local .env.local takes priority
|
|
32
|
+
let dir = process.cwd();
|
|
33
|
+
while (true) {
|
|
34
|
+
if (await loadEnvFile(join(dir, ".env.local"))) break;
|
|
35
|
+
const parent = join(dir, "..");
|
|
36
|
+
if (parent === dir) break;
|
|
37
|
+
dir = parent;
|
|
25
38
|
}
|
|
39
|
+
// Fall back to script dir's .env.local
|
|
40
|
+
if (!process.env[ENV_KEY]) await loadEnvFile(envFile);
|
|
41
|
+
}
|
|
42
|
+
// Shell-set passthrough vars survive .env.local loading
|
|
43
|
+
const _shellPassthrough: Record<string, string> = {};
|
|
44
|
+
for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
|
|
45
|
+
if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
|
|
26
46
|
}
|
|
27
47
|
await loadEnv();
|
|
48
|
+
Object.assign(process.env, _shellPassthrough);
|
|
28
49
|
|
|
29
|
-
// Watch .env.local for changes and hot-reload
|
|
30
50
|
import { watch } from "node:fs";
|
|
31
51
|
if (existsSync(envFile)) {
|
|
32
52
|
watch(envFile, async () => {
|
|
@@ -39,8 +59,8 @@ if (existsSync(envFile)) {
|
|
|
39
59
|
export const PASSTHROUGH_ENV_KEYS = [
|
|
40
60
|
"PLAYWRIGHT_MCP_EXTENSION_ID",
|
|
41
61
|
"PLAYWRIGHT_MCP_EXTENSION_TOKEN",
|
|
42
|
-
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
43
62
|
"PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
|
|
63
|
+
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
44
64
|
] as const;
|
|
45
65
|
|
|
46
66
|
export function log(msg: string) {
|
|
@@ -54,13 +74,25 @@ export function log(msg: string) {
|
|
|
54
74
|
|
|
55
75
|
export function parseUrl(raw: string) {
|
|
56
76
|
const u = new URL(raw);
|
|
57
|
-
|
|
77
|
+
const scheme = u.protocol.replace(":", "");
|
|
78
|
+
const protocol = scheme === "https" ? "https" : "http";
|
|
79
|
+
const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
|
|
80
|
+
return {
|
|
81
|
+
key: u.username,
|
|
82
|
+
host: u.hostname,
|
|
83
|
+
port: parseInt(u.port) || defaultPort,
|
|
84
|
+
protocol,
|
|
85
|
+
extensionId: u.searchParams.get("extension_id") ?? undefined,
|
|
86
|
+
extensionToken: u.searchParams.get("token") ?? undefined,
|
|
87
|
+
profileDirectory: u.searchParams.get("profile") ?? undefined,
|
|
88
|
+
userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
|
|
89
|
+
};
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
export async function getOrCreateUrl(): Promise<string> {
|
|
61
93
|
if (process.env[ENV_KEY]) return process.env[ENV_KEY];
|
|
62
94
|
const key = randomBytes(9).toString("base64url"); // 12 chars
|
|
63
|
-
const url = `
|
|
95
|
+
const url = `http://${key}@${hostname()}:${DEFAULT_PORT}`;
|
|
64
96
|
const newLine = `${ENV_KEY}=${url}`;
|
|
65
97
|
const envRaw = await file(envFile)
|
|
66
98
|
.text()
|
|
@@ -121,49 +153,162 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
|
|
|
121
153
|
return { hostname: hostname(), cwd };
|
|
122
154
|
}
|
|
123
155
|
|
|
124
|
-
function getClientEnv(): Record<string, string> {
|
|
156
|
+
function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Record<string, string> {
|
|
125
157
|
const env: Record<string, string> = {};
|
|
126
158
|
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
127
159
|
if (process.env[key]) env[key] = process.env[key];
|
|
128
160
|
}
|
|
161
|
+
if (urlExtras?.extensionId)
|
|
162
|
+
env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
|
|
163
|
+
if (urlExtras?.extensionToken)
|
|
164
|
+
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
|
|
165
|
+
if (urlExtras?.profileDirectory)
|
|
166
|
+
env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
|
|
167
|
+
if (urlExtras?.userDataDir)
|
|
168
|
+
env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
|
|
129
169
|
return env;
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
|
|
133
|
-
const
|
|
172
|
+
const CHROME_LOCAL_STATE_PATHS = () => {
|
|
173
|
+
const home = process.env.HOME || "~";
|
|
174
|
+
return [
|
|
175
|
+
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
176
|
+
join(home, ".config/google-chrome/Local State"),
|
|
177
|
+
join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
178
|
+
];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
|
|
182
|
+
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
183
|
+
const f = file(statePath);
|
|
184
|
+
if (!(await f.exists())) continue;
|
|
185
|
+
try {
|
|
186
|
+
const data = JSON.parse(await f.text());
|
|
187
|
+
return data?.profile?.info_cache ?? null;
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function findChromeUserDataDir(): Promise<string | null> {
|
|
194
|
+
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
195
|
+
if (!(await file(statePath).exists())) continue;
|
|
196
|
+
return dirname(statePath);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const EXTENSION_DIST_DIR = join(
|
|
202
|
+
import.meta.dir,
|
|
203
|
+
"lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Walk all Chrome profiles' Secure Preferences and find an extension
|
|
207
|
+
// whose loaded `path` matches our dist directory. The extension ID Chrome
|
|
208
|
+
// generates for an unpacked extension is path-dependent, so we cannot rely
|
|
209
|
+
// on a hardcoded ID across machines.
|
|
210
|
+
async function findInstalledExtension(
|
|
211
|
+
profileDir?: string,
|
|
212
|
+
): Promise<{ id: string; profile: string } | null> {
|
|
213
|
+
const userDataDir = await findChromeUserDataDir();
|
|
214
|
+
if (!userDataDir) return null;
|
|
215
|
+
const cache = await readChromeProfileCache();
|
|
216
|
+
const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
|
|
217
|
+
for (const prof of profiles) {
|
|
218
|
+
const prefsPath = join(userDataDir, prof, "Secure Preferences");
|
|
219
|
+
const f = file(prefsPath);
|
|
220
|
+
if (!(await f.exists())) continue;
|
|
221
|
+
try {
|
|
222
|
+
const data = JSON.parse(await f.text());
|
|
223
|
+
const settings = data?.extensions?.settings ?? {};
|
|
224
|
+
for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
|
|
225
|
+
if (info?.path === EXTENSION_DIST_DIR) return { id: extId, profile: prof };
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function printInstallInstructions(profileDisplay: string): void {
|
|
233
|
+
console.error("");
|
|
234
|
+
console.error("Multi-tab extension is not installed in this Chrome profile.");
|
|
235
|
+
console.error("");
|
|
236
|
+
console.error("To install:");
|
|
237
|
+
console.error(" 1. Open chrome://extensions/ in the selected profile");
|
|
238
|
+
console.error(` (profile: ${profileDisplay})`);
|
|
239
|
+
console.error(" 2. Enable \"Developer mode\" (top-right toggle)");
|
|
240
|
+
console.error(" 3. Click \"Load unpacked\"");
|
|
241
|
+
console.error(" 4. Select this directory:");
|
|
242
|
+
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
243
|
+
console.error(" 5. Re-run `rech setup`");
|
|
244
|
+
console.error("");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function resolveProfileEmail(dir: string): Promise<string> {
|
|
248
|
+
const cache = await readChromeProfileCache();
|
|
249
|
+
if (cache?.[dir]?.user_name) return cache[dir].user_name;
|
|
250
|
+
return dir;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function listProfiles(): Promise<void> {
|
|
254
|
+
const cache = await readChromeProfileCache();
|
|
255
|
+
if (!cache) { console.error("Chrome Local State not found"); process.exit(1); }
|
|
256
|
+
|
|
257
|
+
const current = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
258
|
+
// Resolve email/name → dir for current marker
|
|
259
|
+
let currentDir = current;
|
|
260
|
+
if (current && !/^(Default|Profile \d+)$/i.test(current)) {
|
|
261
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
262
|
+
if (info.user_name === current || info.name === current) { currentDir = dir; break; }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
134
265
|
|
|
266
|
+
const rows = Object.entries(cache).map(([dir, info]) => [
|
|
267
|
+
dir,
|
|
268
|
+
info.user_name || "",
|
|
269
|
+
info.name || "",
|
|
270
|
+
dir === currentDir ? "← current" : "",
|
|
271
|
+
]);
|
|
272
|
+
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
273
|
+
for (const row of rows) {
|
|
274
|
+
console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function callServe(
|
|
279
|
+
url: string,
|
|
280
|
+
args: string[],
|
|
281
|
+
overrideEnv?: Record<string, string>,
|
|
282
|
+
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
283
|
+
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
|
|
135
284
|
const identity = await getClientIdentity();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
const res = await fetch(
|
|
285
|
+
const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
286
|
+
if (effectiveProfile) (identity as any).profile = effectiveProfile;
|
|
287
|
+
const env = { ...getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir }), ...overrideEnv };
|
|
288
|
+
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
140
289
|
method: "POST",
|
|
141
290
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
142
|
-
body: JSON.stringify({ args, identity, env
|
|
291
|
+
body: JSON.stringify({ args, identity, env }),
|
|
143
292
|
signal: AbortSignal.timeout(70_000),
|
|
144
|
-
}).catch((e) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
293
|
+
}).catch((e) => { console.error(`[rech] ${e.message}`); process.exit(1); });
|
|
294
|
+
if (res.status === 401) { console.error("Unauthorized: bad key"); process.exit(1); }
|
|
295
|
+
return res.json();
|
|
296
|
+
}
|
|
148
297
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
298
|
+
async function run(url: string, args: string[]) {
|
|
299
|
+
const { host, port, protocol } = parseUrl(url);
|
|
300
|
+
const effectiveProfile = parseUrl(url).profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
301
|
+
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
302
|
+
const identity = await getClientIdentity();
|
|
303
|
+
const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
|
|
304
|
+
console.error(
|
|
305
|
+
`[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
|
|
306
|
+
);
|
|
153
307
|
|
|
154
|
-
const { status, stdout, stderr, files, existingSession } =
|
|
155
|
-
status: number;
|
|
156
|
-
stdout: string;
|
|
157
|
-
stderr: string;
|
|
158
|
-
files?: string[];
|
|
159
|
-
existingSession?: boolean;
|
|
160
|
-
};
|
|
308
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
161
309
|
|
|
162
|
-
if (existingSession)
|
|
163
|
-
console.error(
|
|
164
|
-
`[rech] session already has open tabs — listing existing tabs instead of opening a new window`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
310
|
+
if (existingSession)
|
|
311
|
+
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
167
312
|
if (stderr) process.stderr.write(stderr);
|
|
168
313
|
if (stdout) process.stdout.write(stdout);
|
|
169
314
|
|
|
@@ -173,8 +318,8 @@ async function run(url: string, args: string[]) {
|
|
|
173
318
|
const gitignorePath = join(dlDir, ".gitignore");
|
|
174
319
|
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
175
320
|
for (const name of files) {
|
|
176
|
-
const fileRes = await fetch(
|
|
177
|
-
headers: { Authorization: `Bearer ${key}` },
|
|
321
|
+
const fileRes = await fetch(`${protocol}://${host}:${port}/files/${name}`, {
|
|
322
|
+
headers: { Authorization: `Bearer ${parseUrl(url).key}` },
|
|
178
323
|
});
|
|
179
324
|
if (!fileRes.ok) continue;
|
|
180
325
|
const dest = join(dlDir, basename(name));
|
|
@@ -186,17 +331,108 @@ async function run(url: string, args: string[]) {
|
|
|
186
331
|
process.exit(status);
|
|
187
332
|
}
|
|
188
333
|
|
|
334
|
+
async function setup(): Promise<void> {
|
|
335
|
+
// 1. Require serve to be running
|
|
336
|
+
const url = process.env[ENV_KEY];
|
|
337
|
+
if (!url) {
|
|
338
|
+
console.error(`${ENV_KEY} not set — start the server first:\n rech serve`);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
const { host, port, protocol } = parseUrl(url);
|
|
342
|
+
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(3000) }).catch(() => null);
|
|
343
|
+
if (!ping) {
|
|
344
|
+
console.error(`rech serve is not running at ${host}:${port}\nStart it with:\n rech serve`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 2. Interactive profile selection
|
|
349
|
+
const cache = await readChromeProfileCache();
|
|
350
|
+
if (!cache) { console.error("Chrome profiles not found"); process.exit(1); }
|
|
351
|
+
const profiles = Object.entries(cache);
|
|
352
|
+
console.log("\nAvailable Chrome profiles:");
|
|
353
|
+
profiles.forEach(([dir, info], i) =>
|
|
354
|
+
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
355
|
+
);
|
|
356
|
+
const { createInterface } = await import("readline");
|
|
357
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
358
|
+
const answer = await new Promise<string>(r => rl.question("\nProfile number: ", r));
|
|
359
|
+
rl.close();
|
|
360
|
+
const idx = parseInt(answer.trim()) - 1;
|
|
361
|
+
if (isNaN(idx) || idx < 0 || idx >= profiles.length) { console.error("Invalid selection"); process.exit(1); }
|
|
362
|
+
const [profileDir, profileInfoSel] = profiles[idx];
|
|
363
|
+
const profileEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileDir };
|
|
364
|
+
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
365
|
+
|
|
366
|
+
// 3. Discover the extension ID. Unpacked extension IDs are derived from the
|
|
367
|
+
// load path, so look up the actual ID from Chrome's Secure Preferences.
|
|
368
|
+
// Env override wins if set explicitly.
|
|
369
|
+
let extId = process.env.PLAYWRIGHT_MCP_EXTENSION_ID;
|
|
370
|
+
if (!extId) {
|
|
371
|
+
const found = await findInstalledExtension(profileDir);
|
|
372
|
+
if (found) extId = found.id;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!extId) {
|
|
376
|
+
printInstallInstructions(profileDisplay);
|
|
377
|
+
console.error("Opening chrome://extensions/ in the selected profile...");
|
|
378
|
+
await callServe(url, ["open", "chrome://extensions/"], profileEnv);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const statusUrl = `chrome-extension://${extId}/status.html`;
|
|
383
|
+
console.log(`\nOpening ${statusUrl}...`);
|
|
384
|
+
const openResult = await callServe(url, ["open", statusUrl], profileEnv);
|
|
385
|
+
if (openResult.status !== 0) { process.stderr.write(openResult.stderr); process.exit(openResult.status); }
|
|
386
|
+
|
|
387
|
+
// 4. Read token from extension page localStorage
|
|
388
|
+
const evalResult = await callServe(url, ["eval", `() => localStorage.getItem('auth-token')`], profileEnv);
|
|
389
|
+
const tokenMatch = evalResult.stdout.match(/"([A-Za-z0-9_-]{20,})"/);
|
|
390
|
+
const token = tokenMatch?.[1];
|
|
391
|
+
if (!token) {
|
|
392
|
+
printInstallInstructions(profileDisplay);
|
|
393
|
+
console.error("Tried to read the auth token from the extension's status page but failed.");
|
|
394
|
+
console.error("This usually means the extension is not loaded in this profile.");
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 5. Write single RECHROME_URL with all params to ~/.env.local
|
|
399
|
+
const home = process.env.HOME!;
|
|
400
|
+
const globalEnvPath = join(home, ".env.local");
|
|
401
|
+
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
402
|
+
const rechUrl = new URL(url);
|
|
403
|
+
rechUrl.searchParams.set("extension_id", extId);
|
|
404
|
+
rechUrl.searchParams.set("token", token);
|
|
405
|
+
// Prefer email for readability, fall back to directory name
|
|
406
|
+
rechUrl.searchParams.set("profile", profileInfoSel.user_name || profileDir);
|
|
407
|
+
const userDataDir = await findChromeUserDataDir();
|
|
408
|
+
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
409
|
+
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
410
|
+
// Remove old separate vars and update RECHROME_URL
|
|
411
|
+
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
412
|
+
let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
|
|
413
|
+
const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
|
|
414
|
+
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
415
|
+
else lines.push(newLine);
|
|
416
|
+
await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
|
|
417
|
+
console.log(`\nSaved to ${globalEnvPath}:\n ${newLine}`);
|
|
418
|
+
console.log("\nDone!");
|
|
419
|
+
}
|
|
420
|
+
|
|
189
421
|
if (import.meta.main) {
|
|
190
422
|
const args = process.argv.slice(2);
|
|
191
423
|
|
|
192
424
|
if (args[0] === "serve") {
|
|
193
425
|
const { serve } = await import("./serve.ts");
|
|
194
426
|
serve();
|
|
427
|
+
} else if (args[0] === "profiles") {
|
|
428
|
+
await listProfiles();
|
|
429
|
+
} else if (args[0] === "setup") {
|
|
430
|
+
await setup();
|
|
195
431
|
} else {
|
|
196
432
|
const url = process.env[ENV_KEY];
|
|
197
433
|
if (!url) {
|
|
198
434
|
console.error(
|
|
199
|
-
`Usage:\n rech serve\n ${ENV_KEY}=
|
|
435
|
+
`Usage:\n rech serve\n ${ENV_KEY}=http://key@host:${DEFAULT_PORT}?extension_id=ID&token=TOKEN rech <playwright-args...>\n ${ENV_KEY}=https://key@host/path?extension_id=ID&token=TOKEN rech <playwright-args...>`,
|
|
200
436
|
);
|
|
201
437
|
process.exit(1);
|
|
202
438
|
}
|
package/serve.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { file } from "bun";
|
|
2
|
-
import { createHash } from "crypto";
|
|
3
|
-
import { mkdirSync } from "fs";
|
|
2
|
+
import { createHash, X509Certificate } from "crypto";
|
|
3
|
+
import { mkdirSync, unlinkSync } from "fs";
|
|
4
4
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
5
5
|
import {
|
|
6
6
|
log,
|
|
@@ -11,12 +11,59 @@ import {
|
|
|
11
11
|
PASSTHROUGH_ENV_KEYS,
|
|
12
12
|
} from "./rech.js";
|
|
13
13
|
|
|
14
|
+
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
15
|
+
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
16
|
+
|
|
17
|
+
async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
|
|
18
|
+
const certContent = await file(certPath).text().catch(() => null);
|
|
19
|
+
if (!certContent) return false;
|
|
20
|
+
try {
|
|
21
|
+
const cert = new X509Certificate(certContent);
|
|
22
|
+
const daysLeft = (new Date(cert.validTo).getTime() - Date.now()) / 86_400_000;
|
|
23
|
+
if (daysLeft > CERT_RENEW_THRESHOLD_DAYS) return false;
|
|
24
|
+
const domain = cert.subjectAltName?.match(/DNS:([^\s,]+)/)?.[1];
|
|
25
|
+
if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
|
|
26
|
+
log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
|
|
27
|
+
const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
|
|
28
|
+
stdout: "pipe", stderr: "pipe",
|
|
29
|
+
});
|
|
30
|
+
const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
31
|
+
if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
|
|
32
|
+
log(`TLS cert renewed for ${domain}`);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
log(`TLS cert check error: ${e}`);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
15
41
|
const absBase = resolve(base) + "/";
|
|
16
42
|
const absCandidate = resolve(base, candidate);
|
|
17
43
|
return absCandidate.startsWith(absBase);
|
|
18
44
|
}
|
|
19
45
|
|
|
46
|
+
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
47
|
+
if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
|
|
48
|
+
const home = process.env.HOME || "~";
|
|
49
|
+
const candidates = [
|
|
50
|
+
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
51
|
+
join(home, ".config/google-chrome/Local State"),
|
|
52
|
+
join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
53
|
+
];
|
|
54
|
+
for (const statePath of candidates) {
|
|
55
|
+
const f = file(statePath);
|
|
56
|
+
if (!(await f.exists())) continue;
|
|
57
|
+
const data = JSON.parse(await f.text());
|
|
58
|
+
const cache: Record<string, any> = data?.profile?.info_cache ?? {};
|
|
59
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
60
|
+
if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return nameOrEmail;
|
|
65
|
+
}
|
|
66
|
+
|
|
20
67
|
export async function serve() {
|
|
21
68
|
const url = await getOrCreateUrl();
|
|
22
69
|
const { key, port } = parseUrl(url);
|
|
@@ -25,9 +72,21 @@ export async function serve() {
|
|
|
25
72
|
mkdirSync(workDir, { recursive: true });
|
|
26
73
|
|
|
27
74
|
const listenHost = process.env.RECH_HOST || "127.0.0.1";
|
|
75
|
+
const certPath = process.env.RECH_TLS_CERT;
|
|
76
|
+
const keyPath = process.env.RECH_TLS_KEY;
|
|
77
|
+
if (certPath && keyPath) {
|
|
78
|
+
const renewed = await renewCertIfNeeded(certPath, keyPath);
|
|
79
|
+
if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
80
|
+
// Check daily; pm2 restarts cleanly after exit(0)
|
|
81
|
+
setInterval(async () => {
|
|
82
|
+
if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
83
|
+
}, 86_400_000);
|
|
84
|
+
}
|
|
85
|
+
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
28
86
|
const server = Bun.serve({
|
|
29
87
|
hostname: listenHost,
|
|
30
88
|
port,
|
|
89
|
+
tls,
|
|
31
90
|
async fetch(req) {
|
|
32
91
|
const reqUrl = new URL(req.url);
|
|
33
92
|
|
|
@@ -61,9 +120,10 @@ export async function serve() {
|
|
|
61
120
|
} else {
|
|
62
121
|
args = body.args;
|
|
63
122
|
const id = body.identity as
|
|
64
|
-
| { gitUrl?: string; hostname?: string; cwd?: string }
|
|
123
|
+
| { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
|
|
65
124
|
| undefined;
|
|
66
|
-
const
|
|
125
|
+
const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
|
|
126
|
+
const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
|
|
67
127
|
if (raw) {
|
|
68
128
|
sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
|
|
69
129
|
clientName = raw;
|
|
@@ -101,11 +161,15 @@ export async function serve() {
|
|
|
101
161
|
|
|
102
162
|
log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
|
|
103
163
|
|
|
104
|
-
// For open commands,
|
|
164
|
+
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
105
165
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
106
|
-
if (isOpenCmd)
|
|
166
|
+
if (isOpenCmd && filteredArgs.length === 1)
|
|
167
|
+
filteredArgs.push("about:blank");
|
|
168
|
+
|
|
169
|
+
// bare `rech open` with no URL: warn if session already has tabs
|
|
170
|
+
if (isOpenCmd && filteredArgs.length === 1) {
|
|
107
171
|
try {
|
|
108
|
-
const listProc = Bun.spawn([bin, ...binArgs, "tab-list",
|
|
172
|
+
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
109
173
|
cwd: workDir,
|
|
110
174
|
stdin: "ignore",
|
|
111
175
|
stdout: "pipe",
|
|
@@ -138,6 +202,14 @@ export async function serve() {
|
|
|
138
202
|
}
|
|
139
203
|
Object.assign(passthroughEnv, clientEnv);
|
|
140
204
|
|
|
205
|
+
// Resolve profile name/email → directory name
|
|
206
|
+
if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
207
|
+
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
208
|
+
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
209
|
+
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
210
|
+
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
211
|
+
}
|
|
212
|
+
|
|
141
213
|
const childEnv: Record<string, string | undefined> = {
|
|
142
214
|
PATH: process.env.PATH,
|
|
143
215
|
HOME: process.env.HOME,
|
|
@@ -146,8 +218,30 @@ export async function serve() {
|
|
|
146
218
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
147
219
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
|
|
148
220
|
...passthroughEnv,
|
|
221
|
+
// Enable extension bridge when credentials are present
|
|
222
|
+
...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
|
|
223
|
+
? { PLAYWRIGHT_MCP_EXTENSION: "1" }
|
|
224
|
+
: {}),
|
|
149
225
|
};
|
|
150
|
-
|
|
226
|
+
// For open commands: clean up stale sockets so a closed browser can be reopened
|
|
227
|
+
if (isOpenCmd) {
|
|
228
|
+
const tmpDir = (process.env.TMPDIR || "/tmp").replace(/\/$/, "");
|
|
229
|
+
const playwrightTmpDir = `${tmpDir}/playwright-cli`;
|
|
230
|
+
try {
|
|
231
|
+
const { readdirSync } = await import("fs");
|
|
232
|
+
for (const sub of readdirSync(playwrightTmpDir)) {
|
|
233
|
+
const subDir = `${playwrightTmpDir}/${sub}`;
|
|
234
|
+
for (const f of readdirSync(subDir)) {
|
|
235
|
+
if (f.startsWith(namespacedSession)) {
|
|
236
|
+
const sockPath = `${subDir}/${f}`;
|
|
237
|
+
try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
151
245
|
cwd: workDir,
|
|
152
246
|
stdin: "ignore",
|
|
153
247
|
stdout: "pipe",
|
|
@@ -171,7 +265,7 @@ export async function serve() {
|
|
|
171
265
|
timeout.then(() => [1, "", ""] as [number, string, string]),
|
|
172
266
|
]).catch(
|
|
173
267
|
() => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
|
|
174
|
-
);
|
|
268
|
+
) as [number, string, string];
|
|
175
269
|
|
|
176
270
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
177
271
|
|
|
@@ -209,6 +303,6 @@ export async function serve() {
|
|
|
209
303
|
},
|
|
210
304
|
});
|
|
211
305
|
|
|
212
|
-
log(`serving on http://${server.hostname}:${server.port}`);
|
|
306
|
+
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
213
307
|
log(`Connection URL set (use .env.local to view)`);
|
|
214
308
|
}
|
package/serve.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { file } from "bun";
|
|
2
|
-
import { createHash } from "crypto";
|
|
3
|
-
import { mkdirSync } from "fs";
|
|
2
|
+
import { createHash, X509Certificate } from "crypto";
|
|
3
|
+
import { mkdirSync, unlinkSync } from "fs";
|
|
4
4
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
5
5
|
import {
|
|
6
6
|
log,
|
|
@@ -11,12 +11,59 @@ import {
|
|
|
11
11
|
PASSTHROUGH_ENV_KEYS,
|
|
12
12
|
} from "./rech.ts";
|
|
13
13
|
|
|
14
|
+
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
15
|
+
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
16
|
+
|
|
17
|
+
async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
|
|
18
|
+
const certContent = await file(certPath).text().catch(() => null);
|
|
19
|
+
if (!certContent) return false;
|
|
20
|
+
try {
|
|
21
|
+
const cert = new X509Certificate(certContent);
|
|
22
|
+
const daysLeft = (new Date(cert.validTo).getTime() - Date.now()) / 86_400_000;
|
|
23
|
+
if (daysLeft > CERT_RENEW_THRESHOLD_DAYS) return false;
|
|
24
|
+
const domain = cert.subjectAltName?.match(/DNS:([^\s,]+)/)?.[1];
|
|
25
|
+
if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
|
|
26
|
+
log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
|
|
27
|
+
const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
|
|
28
|
+
stdout: "pipe", stderr: "pipe",
|
|
29
|
+
});
|
|
30
|
+
const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
31
|
+
if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
|
|
32
|
+
log(`TLS cert renewed for ${domain}`);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
log(`TLS cert check error: ${e}`);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
15
41
|
const absBase = resolve(base) + "/";
|
|
16
42
|
const absCandidate = resolve(base, candidate);
|
|
17
43
|
return absCandidate.startsWith(absBase);
|
|
18
44
|
}
|
|
19
45
|
|
|
46
|
+
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
47
|
+
if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
|
|
48
|
+
const home = process.env.HOME || "~";
|
|
49
|
+
const candidates = [
|
|
50
|
+
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
51
|
+
join(home, ".config/google-chrome/Local State"),
|
|
52
|
+
join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
53
|
+
];
|
|
54
|
+
for (const statePath of candidates) {
|
|
55
|
+
const f = file(statePath);
|
|
56
|
+
if (!(await f.exists())) continue;
|
|
57
|
+
const data = JSON.parse(await f.text());
|
|
58
|
+
const cache: Record<string, any> = data?.profile?.info_cache ?? {};
|
|
59
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
60
|
+
if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return nameOrEmail;
|
|
65
|
+
}
|
|
66
|
+
|
|
20
67
|
export async function serve() {
|
|
21
68
|
const url = await getOrCreateUrl();
|
|
22
69
|
const { key, port } = parseUrl(url);
|
|
@@ -25,9 +72,21 @@ export async function serve() {
|
|
|
25
72
|
mkdirSync(workDir, { recursive: true });
|
|
26
73
|
|
|
27
74
|
const listenHost = process.env.RECH_HOST || "127.0.0.1";
|
|
75
|
+
const certPath = process.env.RECH_TLS_CERT;
|
|
76
|
+
const keyPath = process.env.RECH_TLS_KEY;
|
|
77
|
+
if (certPath && keyPath) {
|
|
78
|
+
const renewed = await renewCertIfNeeded(certPath, keyPath);
|
|
79
|
+
if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
80
|
+
// Check daily; pm2 restarts cleanly after exit(0)
|
|
81
|
+
setInterval(async () => {
|
|
82
|
+
if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
83
|
+
}, 86_400_000);
|
|
84
|
+
}
|
|
85
|
+
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
28
86
|
const server = Bun.serve({
|
|
29
87
|
hostname: listenHost,
|
|
30
88
|
port,
|
|
89
|
+
tls,
|
|
31
90
|
async fetch(req) {
|
|
32
91
|
const reqUrl = new URL(req.url);
|
|
33
92
|
|
|
@@ -61,9 +120,10 @@ export async function serve() {
|
|
|
61
120
|
} else {
|
|
62
121
|
args = body.args;
|
|
63
122
|
const id = body.identity as
|
|
64
|
-
| { gitUrl?: string; hostname?: string; cwd?: string }
|
|
123
|
+
| { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
|
|
65
124
|
| undefined;
|
|
66
|
-
const
|
|
125
|
+
const baseId = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
|
|
126
|
+
const raw = baseId && id?.profile ? `${baseId}@${id.profile}` : baseId;
|
|
67
127
|
if (raw) {
|
|
68
128
|
sessionId = createHash("sha256").update(raw).digest("hex").slice(0, 12);
|
|
69
129
|
clientName = raw;
|
|
@@ -101,11 +161,15 @@ export async function serve() {
|
|
|
101
161
|
|
|
102
162
|
log(`run: rech ${filteredArgs.join(" ")} (session=${namespacedSession})`);
|
|
103
163
|
|
|
104
|
-
// For open commands,
|
|
164
|
+
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
105
165
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
106
|
-
if (isOpenCmd)
|
|
166
|
+
if (isOpenCmd && filteredArgs.length === 1)
|
|
167
|
+
filteredArgs.push("about:blank");
|
|
168
|
+
|
|
169
|
+
// bare `rech open` with no URL: warn if session already has tabs
|
|
170
|
+
if (isOpenCmd && filteredArgs.length === 1) {
|
|
107
171
|
try {
|
|
108
|
-
const listProc = Bun.spawn([bin, ...binArgs, "tab-list",
|
|
172
|
+
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
109
173
|
cwd: workDir,
|
|
110
174
|
stdin: "ignore",
|
|
111
175
|
stdout: "pipe",
|
|
@@ -138,6 +202,14 @@ export async function serve() {
|
|
|
138
202
|
}
|
|
139
203
|
Object.assign(passthroughEnv, clientEnv);
|
|
140
204
|
|
|
205
|
+
// Resolve profile name/email → directory name
|
|
206
|
+
if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
207
|
+
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
208
|
+
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
209
|
+
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
210
|
+
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
211
|
+
}
|
|
212
|
+
|
|
141
213
|
const childEnv: Record<string, string | undefined> = {
|
|
142
214
|
PATH: process.env.PATH,
|
|
143
215
|
HOME: process.env.HOME,
|
|
@@ -146,8 +218,30 @@ export async function serve() {
|
|
|
146
218
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
147
219
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: clientName } : {}),
|
|
148
220
|
...passthroughEnv,
|
|
221
|
+
// Enable extension bridge when credentials are present
|
|
222
|
+
...(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN
|
|
223
|
+
? { PLAYWRIGHT_MCP_EXTENSION: "1" }
|
|
224
|
+
: {}),
|
|
149
225
|
};
|
|
150
|
-
|
|
226
|
+
// For open commands: clean up stale sockets so a closed browser can be reopened
|
|
227
|
+
if (isOpenCmd) {
|
|
228
|
+
const tmpDir = (process.env.TMPDIR || "/tmp").replace(/\/$/, "");
|
|
229
|
+
const playwrightTmpDir = `${tmpDir}/playwright-cli`;
|
|
230
|
+
try {
|
|
231
|
+
const { readdirSync } = await import("fs");
|
|
232
|
+
for (const sub of readdirSync(playwrightTmpDir)) {
|
|
233
|
+
const subDir = `${playwrightTmpDir}/${sub}`;
|
|
234
|
+
for (const f of readdirSync(subDir)) {
|
|
235
|
+
if (f.startsWith(namespacedSession)) {
|
|
236
|
+
const sockPath = `${subDir}/${f}`;
|
|
237
|
+
try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
151
245
|
cwd: workDir,
|
|
152
246
|
stdin: "ignore",
|
|
153
247
|
stdout: "pipe",
|
|
@@ -171,7 +265,7 @@ export async function serve() {
|
|
|
171
265
|
timeout.then(() => [1, "", ""] as [number, string, string]),
|
|
172
266
|
]).catch(
|
|
173
267
|
() => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
|
|
174
|
-
);
|
|
268
|
+
) as [number, string, string];
|
|
175
269
|
|
|
176
270
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
177
271
|
|
|
@@ -209,6 +303,6 @@ export async function serve() {
|
|
|
209
303
|
},
|
|
210
304
|
});
|
|
211
305
|
|
|
212
|
-
log(`serving on http://${server.hostname}:${server.port}`);
|
|
306
|
+
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
213
307
|
log(`Connection URL set (use .env.local to view)`);
|
|
214
308
|
}
|