rechrome 1.31.1 → 1.31.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/rechrome.js +136 -17
- package/rechrome.ts +136 -17
- package/serve.js +33 -15
- package/serve.ts +33 -15
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -362,7 +362,18 @@ export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
|
362
362
|
return buildListenChoices(networkInterfaces(), ips);
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
/**
|
|
366
|
+
* `funnel`: Funnel (public internet) is on for the host that serves — or would serve — this
|
|
367
|
+
* node's routes, so a route there is not tailnet-only. `mounts`: every Serve path in use.
|
|
368
|
+
*/
|
|
369
|
+
export type TailscaleServe = { dnsName: string | null; routeUrl: string | null; funnel: boolean; mounts: string[] };
|
|
370
|
+
|
|
371
|
+
export function serveMountsAndFunnel(serveStatus: unknown, host: string | null): { mounts: string[]; funnel: boolean } {
|
|
372
|
+
const status = serveStatus as { Web?: Record<string, { Handlers?: Record<string, unknown> }>; AllowFunnel?: Record<string, boolean> } | null;
|
|
373
|
+
const mounts = Object.values(status?.Web ?? {}).flatMap(config => Object.keys(config.Handlers ?? {}).map(path => path.replace(/\/+$/, "") || "/"));
|
|
374
|
+
const funnel = Object.entries(status?.AllowFunnel ?? {}).some(([hostPort, on]) => on && (!host || hostPort.split(/:(?=\d+$)/)[0] === host));
|
|
375
|
+
return { mounts: [...new Set(mounts)], funnel };
|
|
376
|
+
}
|
|
366
377
|
|
|
367
378
|
/**
|
|
368
379
|
* Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
|
|
@@ -393,7 +404,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
393
404
|
let serveStatus: unknown = null;
|
|
394
405
|
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
395
406
|
try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
|
|
396
|
-
|
|
407
|
+
const routeUrl = findTailscaleServeRoute(dnsName, serveStatus, port, prefix);
|
|
408
|
+
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
397
409
|
}
|
|
398
410
|
|
|
399
411
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
@@ -1091,7 +1103,7 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
|
|
|
1091
1103
|
throw new ShareListenerError([
|
|
1092
1104
|
`"${profile}" isn't shared on any listener yet.`,
|
|
1093
1105
|
scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
|
|
1094
|
-
` Or
|
|
1106
|
+
` ${scoped.length ? "Or give" : "Give"} it its own link: rech share ${JSON.stringify(profile)} (in a terminal it asks who should connect)`,
|
|
1095
1107
|
].filter(Boolean).join("\n"), "none", scoped);
|
|
1096
1108
|
}
|
|
1097
1109
|
|
|
@@ -1142,7 +1154,7 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1142
1154
|
}
|
|
1143
1155
|
|
|
1144
1156
|
/** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
|
|
1145
|
-
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string }): Promise<void> {
|
|
1157
|
+
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
|
|
1146
1158
|
const config = await requireListeners();
|
|
1147
1159
|
const name = opts.listener ?? opts.defaultName;
|
|
1148
1160
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1151,7 +1163,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1151
1163
|
let changes = "";
|
|
1152
1164
|
if (!listener) {
|
|
1153
1165
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1154
|
-
listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1166
|
+
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1155
1167
|
config.listeners.push(listener);
|
|
1156
1168
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1157
1169
|
} else {
|
|
@@ -1161,24 +1173,100 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1161
1173
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
1162
1174
|
}
|
|
1163
1175
|
await writeListeners(config);
|
|
1164
|
-
|
|
1176
|
+
if (changes.startsWith("created") && snapshot.length === 1) console.error(`New link "${name}" for ${snapshot[0]}.`);
|
|
1177
|
+
else console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
|
|
1165
1178
|
if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
|
|
1166
1179
|
else if (changes !== "no changes" && !changes.startsWith("created")) console.error(`[rech] links already given out for "${name}" now reach exactly these profiles too (same key). For a fresh key: rech listener rotate-key ${name}`);
|
|
1167
1180
|
// The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
|
|
1168
|
-
|
|
1181
|
+
// A one-profile link names its profile, so the other machine needs no --profile.
|
|
1182
|
+
const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
|
|
1183
|
+
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1169
1184
|
let ready = false;
|
|
1170
1185
|
for (let i = 0; i < 20 && !ready; i++) {
|
|
1171
1186
|
ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
|
|
1172
1187
|
if (!ready) await Bun.sleep(250);
|
|
1173
1188
|
}
|
|
1174
1189
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1190
|
+
if (opts.tailscaleServe && !listener.publicUrl) {
|
|
1191
|
+
const publicUrl = await exposeWithTailscaleServe(listener);
|
|
1192
|
+
if (publicUrl) {
|
|
1193
|
+
listener.publicUrl = publicUrl;
|
|
1194
|
+
await writeListeners(config);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1175
1197
|
const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
|
|
1176
|
-
if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1198
|
+
if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1177
1199
|
console.log(uri);
|
|
1178
|
-
console.error(`
|
|
1200
|
+
console.error(snapshot.length === 1 ? `On the other machine: rech connect '<this link>'` : `On the other machine: rech connect '<this link>', then rech --profile <name> open <url>`);
|
|
1179
1201
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
1180
1202
|
}
|
|
1181
1203
|
|
|
1204
|
+
/** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
|
|
1205
|
+
export function profileSlug(profile: string): string {
|
|
1206
|
+
const slug = profile.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
1207
|
+
return slug || "profile";
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* `base`, or `base-2`, `base-3`… — the first name whose listener and path are free. Never a
|
|
1212
|
+
* reserved name (`share --all` and `share a b` reuse theirs and rewrite their allowlists, so a
|
|
1213
|
+
* one-profile link under such a name would later grant its key more profiles), and never a
|
|
1214
|
+
* mount a proxy already serves (Tailscale Serve would replace that route).
|
|
1215
|
+
*/
|
|
1216
|
+
export function uniqueShareName(base: string, listeners: Listener[], servedMounts: string[] = []): string {
|
|
1217
|
+
const reserved = (name: string) => name === "local" || name === "share-all" || /^share-[0-9a-f]{6}$/.test(name);
|
|
1218
|
+
const taken = (name: string) => reserved(name) || servedMounts.includes(`/rechrome/${name}`)
|
|
1219
|
+
|| listeners.some(l => l.name === name || normalizePrefix(l.prefix) === `/rechrome/${name}/`);
|
|
1220
|
+
for (let i = 1; ; i++) { const name = i === 1 ? base : `${base}-${i}`; if (!taken(name)) return name; }
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** Route a loopback listener through Tailscale Serve (HTTPS, tailnet only); returns its public URL. */
|
|
1224
|
+
async function exposeWithTailscaleServe(listener: Listener): Promise<string | null> {
|
|
1225
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
1226
|
+
const existing = await detectTailscaleServe(listener.port, prefix);
|
|
1227
|
+
if (existing.funnel) {
|
|
1228
|
+
console.error("Tailscale Funnel is on for this machine, so a Serve route here would be public, not tailnet-only. Not exposing it.\n Turn Funnel off (tailscale funnel reset), then rech share again.");
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
if (existing.routeUrl) return existing.routeUrl;
|
|
1232
|
+
const binary = tailscaleBinary();
|
|
1233
|
+
if (!binary) return null;
|
|
1234
|
+
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1235
|
+
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1236
|
+
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1237
|
+
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1238
|
+
if (await proc.exited !== 0) {
|
|
1239
|
+
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Give one profile its own link. In a terminal, ask how the other machines will reach it
|
|
1247
|
+
* (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
|
|
1248
|
+
* loopback link, like `rech share a b`.
|
|
1249
|
+
*/
|
|
1250
|
+
async function shareNewLink(profile: string, opts: { local?: boolean; save?: boolean }): Promise<void> {
|
|
1251
|
+
const served = tailscaleBinary() ? (await detectTailscaleServe(0, "/")).mounts : [];
|
|
1252
|
+
const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
|
|
1253
|
+
let host = "127.0.0.1", tailscaleServe = false;
|
|
1254
|
+
if (isInteractive()) {
|
|
1255
|
+
const serve = !!tailscaleBinary();
|
|
1256
|
+
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1257
|
+
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1258
|
+
const how = await promptChoice(`How will your other machines reach it?`, [
|
|
1259
|
+
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1260
|
+
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1261
|
+
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1262
|
+
], 0);
|
|
1263
|
+
if (!how) throw new Error("Cancelled; nothing shared.");
|
|
1264
|
+
tailscaleServe = how === "tailscale-serve";
|
|
1265
|
+
host = tailscaleServe ? "127.0.0.1" : how;
|
|
1266
|
+
}
|
|
1267
|
+
return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1182
1270
|
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
|
|
1183
1271
|
if (opts.all) {
|
|
1184
1272
|
if (selector) throw new Error("Pass a profile or --all, not both.");
|
|
@@ -1225,17 +1313,24 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1225
1313
|
if (!listener && config) {
|
|
1226
1314
|
try { listener = chooseShareListener(profile, listeners); }
|
|
1227
1315
|
catch (error) {
|
|
1228
|
-
if (!
|
|
1316
|
+
if (!(error instanceof ShareListenerError)) throw error;
|
|
1317
|
+
// Not shared yet: give it its own link rather than stopping with instructions.
|
|
1318
|
+
if (!interactive) { if (error.kind === "none") return shareNewLink(profile, opts); throw error; }
|
|
1229
1319
|
const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
|
|
1230
1320
|
if (error.kind === "several") {
|
|
1231
1321
|
listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
|
|
1232
1322
|
if (!listener) throw cancelled();
|
|
1233
1323
|
} else {
|
|
1234
|
-
|
|
1235
|
-
//
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1324
|
+
// Keep going until there is a URL (or the user cancels): a new link of its own is the
|
|
1325
|
+
// default, since allowing it on an existing listener hands it to that listener's key holders.
|
|
1326
|
+
const NEW_LINK = "\0new";
|
|
1327
|
+
const target = error.listeners.length ? await promptChoice(`${profile} isn't shared yet.`, [
|
|
1328
|
+
{ label: "Create a new link for it", value: NEW_LINK as string | null },
|
|
1329
|
+
...error.listeners.map(l => ({ label: `Add it to link "${l.name}" (its existing key)`, value: l.name as string | null })),
|
|
1330
|
+
{ label: "Cancel", value: null },
|
|
1331
|
+
], 0) : NEW_LINK;
|
|
1238
1332
|
if (!target) throw cancelled();
|
|
1333
|
+
if (target === NEW_LINK) return shareNewLink(profile, opts);
|
|
1239
1334
|
allowProfiles(config, target, [profile]);
|
|
1240
1335
|
await writeListeners(config);
|
|
1241
1336
|
console.error(`[rech] allowed "${profile}" on ${target}`);
|
|
@@ -1250,7 +1345,14 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1250
1345
|
console.error(`[rech] note: this link's key works for every profile on listener "${chosen.name}" (${chosen.profiles.join(", ")}); ?profile= only picks the default. For a one-profile link, give that profile its own listener.`);
|
|
1251
1346
|
let uri = profileConnectionUri(profile, url, listeners, listener);
|
|
1252
1347
|
// Prefer where a proxy exposes the listener, when it has been recorded.
|
|
1253
|
-
|
|
1348
|
+
// A link whose proxy was set up by hand since (e.g. after `tailscale serve` failed here):
|
|
1349
|
+
// record the tailnet-only route so this and later shares print it.
|
|
1350
|
+
const pending = listeners.find(l => l.key === parseUrl(uri).key);
|
|
1351
|
+
if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && tailscaleBinary()) {
|
|
1352
|
+
const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
|
|
1353
|
+
if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
|
|
1354
|
+
}
|
|
1355
|
+
const publicUrl = pending?.publicUrl;
|
|
1254
1356
|
if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
|
|
1255
1357
|
console.log(uri);
|
|
1256
1358
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
@@ -1922,14 +2024,31 @@ async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<str
|
|
|
1922
2024
|
daemonManager();
|
|
1923
2025
|
}
|
|
1924
2026
|
|
|
2027
|
+
/**
|
|
2028
|
+
* Rewriting RECHROME_URL for a new daemon key must not drop the saved `?profile=`: without it
|
|
2029
|
+
* every client loses its Chrome profile (and a setup aborted before its final save step used
|
|
2030
|
+
* to leave it that way).
|
|
2031
|
+
*/
|
|
2032
|
+
export function keepProfileParam(next: string, previous?: string): string {
|
|
2033
|
+
try {
|
|
2034
|
+
const url = new URL(next);
|
|
2035
|
+
// A .env.local value may be quoted: RECHROME_URL="http://…/?profile=work".
|
|
2036
|
+
const raw = previous?.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
2037
|
+
const profile = raw ? new URL(raw).searchParams.get("profile") : null;
|
|
2038
|
+
if (profile && !url.searchParams.has("profile")) url.searchParams.set("profile", profile);
|
|
2039
|
+
return url.toString();
|
|
2040
|
+
} catch { return next; }
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1925
2043
|
export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
1926
2044
|
// Resolve the manager first so a missing dependency fails before config is mutated.
|
|
1927
2045
|
const mgr = daemonManager();
|
|
1928
2046
|
// Persist the URL for future clients without an explicit environment override.
|
|
1929
2047
|
// The daemon's explicit environment takes precedence over this saved default.
|
|
1930
2048
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
1931
|
-
const
|
|
1932
|
-
|
|
2049
|
+
const lines = envRaw.trimEnd().split("\n");
|
|
2050
|
+
const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
2051
|
+
await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))}`, ""].join("\n"));
|
|
1933
2052
|
|
|
1934
2053
|
const home = HOME;
|
|
1935
2054
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
package/rechrome.ts
CHANGED
|
@@ -362,7 +362,18 @@ export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
|
362
362
|
return buildListenChoices(networkInterfaces(), ips);
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
/**
|
|
366
|
+
* `funnel`: Funnel (public internet) is on for the host that serves — or would serve — this
|
|
367
|
+
* node's routes, so a route there is not tailnet-only. `mounts`: every Serve path in use.
|
|
368
|
+
*/
|
|
369
|
+
export type TailscaleServe = { dnsName: string | null; routeUrl: string | null; funnel: boolean; mounts: string[] };
|
|
370
|
+
|
|
371
|
+
export function serveMountsAndFunnel(serveStatus: unknown, host: string | null): { mounts: string[]; funnel: boolean } {
|
|
372
|
+
const status = serveStatus as { Web?: Record<string, { Handlers?: Record<string, unknown> }>; AllowFunnel?: Record<string, boolean> } | null;
|
|
373
|
+
const mounts = Object.values(status?.Web ?? {}).flatMap(config => Object.keys(config.Handlers ?? {}).map(path => path.replace(/\/+$/, "") || "/"));
|
|
374
|
+
const funnel = Object.entries(status?.AllowFunnel ?? {}).some(([hostPort, on]) => on && (!host || hostPort.split(/:(?=\d+$)/)[0] === host));
|
|
375
|
+
return { mounts: [...new Set(mounts)], funnel };
|
|
376
|
+
}
|
|
366
377
|
|
|
367
378
|
/**
|
|
368
379
|
* Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
|
|
@@ -393,7 +404,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
393
404
|
let serveStatus: unknown = null;
|
|
394
405
|
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
395
406
|
try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
|
|
396
|
-
|
|
407
|
+
const routeUrl = findTailscaleServeRoute(dnsName, serveStatus, port, prefix);
|
|
408
|
+
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
397
409
|
}
|
|
398
410
|
|
|
399
411
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
@@ -1091,7 +1103,7 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
|
|
|
1091
1103
|
throw new ShareListenerError([
|
|
1092
1104
|
`"${profile}" isn't shared on any listener yet.`,
|
|
1093
1105
|
scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
|
|
1094
|
-
` Or
|
|
1106
|
+
` ${scoped.length ? "Or give" : "Give"} it its own link: rech share ${JSON.stringify(profile)} (in a terminal it asks who should connect)`,
|
|
1095
1107
|
].filter(Boolean).join("\n"), "none", scoped);
|
|
1096
1108
|
}
|
|
1097
1109
|
|
|
@@ -1142,7 +1154,7 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1142
1154
|
}
|
|
1143
1155
|
|
|
1144
1156
|
/** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
|
|
1145
|
-
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string }): Promise<void> {
|
|
1157
|
+
async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string; host?: string; tailscaleServe?: boolean }): Promise<void> {
|
|
1146
1158
|
const config = await requireListeners();
|
|
1147
1159
|
const name = opts.listener ?? opts.defaultName;
|
|
1148
1160
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1151,7 +1163,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1151
1163
|
let changes = "";
|
|
1152
1164
|
if (!listener) {
|
|
1153
1165
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1154
|
-
listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1166
|
+
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1155
1167
|
config.listeners.push(listener);
|
|
1156
1168
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1157
1169
|
} else {
|
|
@@ -1161,24 +1173,100 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1161
1173
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
1162
1174
|
}
|
|
1163
1175
|
await writeListeners(config);
|
|
1164
|
-
|
|
1176
|
+
if (changes.startsWith("created") && snapshot.length === 1) console.error(`New link "${name}" for ${snapshot[0]}.`);
|
|
1177
|
+
else console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
|
|
1165
1178
|
if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
|
|
1166
1179
|
else if (changes !== "no changes" && !changes.startsWith("created")) console.error(`[rech] links already given out for "${name}" now reach exactly these profiles too (same key). For a fresh key: rech listener rotate-key ${name}`);
|
|
1167
1180
|
// The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
|
|
1168
|
-
|
|
1181
|
+
// A one-profile link names its profile, so the other machine needs no --profile.
|
|
1182
|
+
const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
|
|
1183
|
+
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1169
1184
|
let ready = false;
|
|
1170
1185
|
for (let i = 0; i < 20 && !ready; i++) {
|
|
1171
1186
|
ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
|
|
1172
1187
|
if (!ready) await Bun.sleep(250);
|
|
1173
1188
|
}
|
|
1174
1189
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1190
|
+
if (opts.tailscaleServe && !listener.publicUrl) {
|
|
1191
|
+
const publicUrl = await exposeWithTailscaleServe(listener);
|
|
1192
|
+
if (publicUrl) {
|
|
1193
|
+
listener.publicUrl = publicUrl;
|
|
1194
|
+
await writeListeners(config);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1175
1197
|
const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
|
|
1176
|
-
if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1198
|
+
if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1177
1199
|
console.log(uri);
|
|
1178
|
-
console.error(`
|
|
1200
|
+
console.error(snapshot.length === 1 ? `On the other machine: rech connect '<this link>'` : `On the other machine: rech connect '<this link>', then rech --profile <name> open <url>`);
|
|
1179
1201
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
1180
1202
|
}
|
|
1181
1203
|
|
|
1204
|
+
/** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
|
|
1205
|
+
export function profileSlug(profile: string): string {
|
|
1206
|
+
const slug = profile.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
1207
|
+
return slug || "profile";
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* `base`, or `base-2`, `base-3`… — the first name whose listener and path are free. Never a
|
|
1212
|
+
* reserved name (`share --all` and `share a b` reuse theirs and rewrite their allowlists, so a
|
|
1213
|
+
* one-profile link under such a name would later grant its key more profiles), and never a
|
|
1214
|
+
* mount a proxy already serves (Tailscale Serve would replace that route).
|
|
1215
|
+
*/
|
|
1216
|
+
export function uniqueShareName(base: string, listeners: Listener[], servedMounts: string[] = []): string {
|
|
1217
|
+
const reserved = (name: string) => name === "local" || name === "share-all" || /^share-[0-9a-f]{6}$/.test(name);
|
|
1218
|
+
const taken = (name: string) => reserved(name) || servedMounts.includes(`/rechrome/${name}`)
|
|
1219
|
+
|| listeners.some(l => l.name === name || normalizePrefix(l.prefix) === `/rechrome/${name}/`);
|
|
1220
|
+
for (let i = 1; ; i++) { const name = i === 1 ? base : `${base}-${i}`; if (!taken(name)) return name; }
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** Route a loopback listener through Tailscale Serve (HTTPS, tailnet only); returns its public URL. */
|
|
1224
|
+
async function exposeWithTailscaleServe(listener: Listener): Promise<string | null> {
|
|
1225
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
1226
|
+
const existing = await detectTailscaleServe(listener.port, prefix);
|
|
1227
|
+
if (existing.funnel) {
|
|
1228
|
+
console.error("Tailscale Funnel is on for this machine, so a Serve route here would be public, not tailnet-only. Not exposing it.\n Turn Funnel off (tailscale funnel reset), then rech share again.");
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
if (existing.routeUrl) return existing.routeUrl;
|
|
1232
|
+
const binary = tailscaleBinary();
|
|
1233
|
+
if (!binary) return null;
|
|
1234
|
+
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1235
|
+
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1236
|
+
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1237
|
+
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1238
|
+
if (await proc.exited !== 0) {
|
|
1239
|
+
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Give one profile its own link. In a terminal, ask how the other machines will reach it
|
|
1247
|
+
* (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
|
|
1248
|
+
* loopback link, like `rech share a b`.
|
|
1249
|
+
*/
|
|
1250
|
+
async function shareNewLink(profile: string, opts: { local?: boolean; save?: boolean }): Promise<void> {
|
|
1251
|
+
const served = tailscaleBinary() ? (await detectTailscaleServe(0, "/")).mounts : [];
|
|
1252
|
+
const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
|
|
1253
|
+
let host = "127.0.0.1", tailscaleServe = false;
|
|
1254
|
+
if (isInteractive()) {
|
|
1255
|
+
const serve = !!tailscaleBinary();
|
|
1256
|
+
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1257
|
+
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1258
|
+
const how = await promptChoice(`How will your other machines reach it?`, [
|
|
1259
|
+
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1260
|
+
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1261
|
+
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1262
|
+
], 0);
|
|
1263
|
+
if (!how) throw new Error("Cancelled; nothing shared.");
|
|
1264
|
+
tailscaleServe = how === "tailscale-serve";
|
|
1265
|
+
host = tailscaleServe ? "127.0.0.1" : how;
|
|
1266
|
+
}
|
|
1267
|
+
return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1182
1270
|
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
|
|
1183
1271
|
if (opts.all) {
|
|
1184
1272
|
if (selector) throw new Error("Pass a profile or --all, not both.");
|
|
@@ -1225,17 +1313,24 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1225
1313
|
if (!listener && config) {
|
|
1226
1314
|
try { listener = chooseShareListener(profile, listeners); }
|
|
1227
1315
|
catch (error) {
|
|
1228
|
-
if (!
|
|
1316
|
+
if (!(error instanceof ShareListenerError)) throw error;
|
|
1317
|
+
// Not shared yet: give it its own link rather than stopping with instructions.
|
|
1318
|
+
if (!interactive) { if (error.kind === "none") return shareNewLink(profile, opts); throw error; }
|
|
1229
1319
|
const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
|
|
1230
1320
|
if (error.kind === "several") {
|
|
1231
1321
|
listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
|
|
1232
1322
|
if (!listener) throw cancelled();
|
|
1233
1323
|
} else {
|
|
1234
|
-
|
|
1235
|
-
//
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1324
|
+
// Keep going until there is a URL (or the user cancels): a new link of its own is the
|
|
1325
|
+
// default, since allowing it on an existing listener hands it to that listener's key holders.
|
|
1326
|
+
const NEW_LINK = "\0new";
|
|
1327
|
+
const target = error.listeners.length ? await promptChoice(`${profile} isn't shared yet.`, [
|
|
1328
|
+
{ label: "Create a new link for it", value: NEW_LINK as string | null },
|
|
1329
|
+
...error.listeners.map(l => ({ label: `Add it to link "${l.name}" (its existing key)`, value: l.name as string | null })),
|
|
1330
|
+
{ label: "Cancel", value: null },
|
|
1331
|
+
], 0) : NEW_LINK;
|
|
1238
1332
|
if (!target) throw cancelled();
|
|
1333
|
+
if (target === NEW_LINK) return shareNewLink(profile, opts);
|
|
1239
1334
|
allowProfiles(config, target, [profile]);
|
|
1240
1335
|
await writeListeners(config);
|
|
1241
1336
|
console.error(`[rech] allowed "${profile}" on ${target}`);
|
|
@@ -1250,7 +1345,14 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1250
1345
|
console.error(`[rech] note: this link's key works for every profile on listener "${chosen.name}" (${chosen.profiles.join(", ")}); ?profile= only picks the default. For a one-profile link, give that profile its own listener.`);
|
|
1251
1346
|
let uri = profileConnectionUri(profile, url, listeners, listener);
|
|
1252
1347
|
// Prefer where a proxy exposes the listener, when it has been recorded.
|
|
1253
|
-
|
|
1348
|
+
// A link whose proxy was set up by hand since (e.g. after `tailscale serve` failed here):
|
|
1349
|
+
// record the tailnet-only route so this and later shares print it.
|
|
1350
|
+
const pending = listeners.find(l => l.key === parseUrl(uri).key);
|
|
1351
|
+
if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && tailscaleBinary()) {
|
|
1352
|
+
const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
|
|
1353
|
+
if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
|
|
1354
|
+
}
|
|
1355
|
+
const publicUrl = pending?.publicUrl;
|
|
1254
1356
|
if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
|
|
1255
1357
|
console.log(uri);
|
|
1256
1358
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
@@ -1922,14 +2024,31 @@ async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<str
|
|
|
1922
2024
|
daemonManager();
|
|
1923
2025
|
}
|
|
1924
2026
|
|
|
2027
|
+
/**
|
|
2028
|
+
* Rewriting RECHROME_URL for a new daemon key must not drop the saved `?profile=`: without it
|
|
2029
|
+
* every client loses its Chrome profile (and a setup aborted before its final save step used
|
|
2030
|
+
* to leave it that way).
|
|
2031
|
+
*/
|
|
2032
|
+
export function keepProfileParam(next: string, previous?: string): string {
|
|
2033
|
+
try {
|
|
2034
|
+
const url = new URL(next);
|
|
2035
|
+
// A .env.local value may be quoted: RECHROME_URL="http://…/?profile=work".
|
|
2036
|
+
const raw = previous?.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
2037
|
+
const profile = raw ? new URL(raw).searchParams.get("profile") : null;
|
|
2038
|
+
if (profile && !url.searchParams.has("profile")) url.searchParams.set("profile", profile);
|
|
2039
|
+
return url.toString();
|
|
2040
|
+
} catch { return next; }
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1925
2043
|
export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
1926
2044
|
// Resolve the manager first so a missing dependency fails before config is mutated.
|
|
1927
2045
|
const mgr = daemonManager();
|
|
1928
2046
|
// Persist the URL for future clients without an explicit environment override.
|
|
1929
2047
|
// The daemon's explicit environment takes precedence over this saved default.
|
|
1930
2048
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
1931
|
-
const
|
|
1932
|
-
|
|
2049
|
+
const lines = envRaw.trimEnd().split("\n");
|
|
2050
|
+
const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
2051
|
+
await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))}`, ""].join("\n"));
|
|
1933
2052
|
|
|
1934
2053
|
const home = HOME;
|
|
1935
2054
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
package/serve.js
CHANGED
|
@@ -691,6 +691,39 @@ export async function serve() {
|
|
|
691
691
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
692
692
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
693
693
|
|
|
694
|
+
// Merge passthrough env: server .env.local defaults, then client overrides
|
|
695
|
+
const passthroughEnv: Record<string, string | undefined> = {};
|
|
696
|
+
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
697
|
+
if (process.env[key]) passthroughEnv[key] = process.env[key];
|
|
698
|
+
}
|
|
699
|
+
Object.assign(passthroughEnv, clientEnv, profileEnv);
|
|
700
|
+
|
|
701
|
+
// Resolve profile name/email → directory name
|
|
702
|
+
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
703
|
+
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
704
|
+
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
705
|
+
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
706
|
+
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Never fall back silently: without extension credentials (a registered profile) or a managed
|
|
710
|
+
// user-data dir, `open` would launch a separate headless browser with a throwaway profile —
|
|
711
|
+
// the command "succeeds" and nothing shows up in the user's Chrome.
|
|
712
|
+
const extensionMode = !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN);
|
|
713
|
+
// Checked before the existing-session shortcuts below: reusing a leftover headless session
|
|
714
|
+
// (bare `open` answered with its tab list, or `open <url>` rewritten to goto) is the same
|
|
715
|
+
// silent fallback.
|
|
716
|
+
if (isOpenCmd && !extensionMode && !passthroughEnv.PLAYWRIGHT_MCP_USER_DATA_DIR) {
|
|
717
|
+
const requested = passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
718
|
+
const registered = Object.keys(await readTokenRegistry().catch(() => ({})));
|
|
719
|
+
const known = registered.length ? `Registered here: ${registered.join(", ")}` : "No profiles are registered here yet: rech setup";
|
|
720
|
+
const stderr = requested
|
|
721
|
+
? `[rech] profile "${requested}" has no extension token on this machine, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Register it: rech setup --profile ${JSON.stringify(requested)}\n ${known}\n`
|
|
722
|
+
: `[rech] no Chrome profile selected, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Pick one: rech --profile <email> open <url>, or add ?profile=<email> to RECHROME_URL\n ${known}\n`;
|
|
723
|
+
log(`refused open without a profile (session=${namespacedSession}, requested=${requested ?? "-"})`);
|
|
724
|
+
return Response.json({ status: 1, stdout: "", stderr, files: [] });
|
|
725
|
+
}
|
|
726
|
+
|
|
694
727
|
// open against an existing session: bare `open` returns a tab-list hint; `open <url>`
|
|
695
728
|
// converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
|
|
696
729
|
// was dead — about:blank/<url> is already appended above, so length is always >=2.)
|
|
@@ -730,21 +763,6 @@ export async function serve() {
|
|
|
730
763
|
}
|
|
731
764
|
}
|
|
732
765
|
|
|
733
|
-
// Merge passthrough env: server .env.local defaults, then client overrides
|
|
734
|
-
const passthroughEnv: Record<string, string | undefined> = {};
|
|
735
|
-
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
736
|
-
if (process.env[key]) passthroughEnv[key] = process.env[key];
|
|
737
|
-
}
|
|
738
|
-
Object.assign(passthroughEnv, clientEnv, profileEnv);
|
|
739
|
-
|
|
740
|
-
// Resolve profile name/email → directory name
|
|
741
|
-
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
742
|
-
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
743
|
-
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
744
|
-
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
745
|
-
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
766
|
const childEnv: Record<string, string | undefined> = {
|
|
749
767
|
PATH: process.env.PATH,
|
|
750
768
|
HOME: HOME,
|
package/serve.ts
CHANGED
|
@@ -691,6 +691,39 @@ export async function serve() {
|
|
|
691
691
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
692
692
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
693
693
|
|
|
694
|
+
// Merge passthrough env: server .env.local defaults, then client overrides
|
|
695
|
+
const passthroughEnv: Record<string, string | undefined> = {};
|
|
696
|
+
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
697
|
+
if (process.env[key]) passthroughEnv[key] = process.env[key];
|
|
698
|
+
}
|
|
699
|
+
Object.assign(passthroughEnv, clientEnv, profileEnv);
|
|
700
|
+
|
|
701
|
+
// Resolve profile name/email → directory name
|
|
702
|
+
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
703
|
+
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
704
|
+
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
705
|
+
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
706
|
+
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Never fall back silently: without extension credentials (a registered profile) or a managed
|
|
710
|
+
// user-data dir, `open` would launch a separate headless browser with a throwaway profile —
|
|
711
|
+
// the command "succeeds" and nothing shows up in the user's Chrome.
|
|
712
|
+
const extensionMode = !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN);
|
|
713
|
+
// Checked before the existing-session shortcuts below: reusing a leftover headless session
|
|
714
|
+
// (bare `open` answered with its tab list, or `open <url>` rewritten to goto) is the same
|
|
715
|
+
// silent fallback.
|
|
716
|
+
if (isOpenCmd && !extensionMode && !passthroughEnv.PLAYWRIGHT_MCP_USER_DATA_DIR) {
|
|
717
|
+
const requested = passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
718
|
+
const registered = Object.keys(await readTokenRegistry().catch(() => ({})));
|
|
719
|
+
const known = registered.length ? `Registered here: ${registered.join(", ")}` : "No profiles are registered here yet: rech setup";
|
|
720
|
+
const stderr = requested
|
|
721
|
+
? `[rech] profile "${requested}" has no extension token on this machine, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Register it: rech setup --profile ${JSON.stringify(requested)}\n ${known}\n`
|
|
722
|
+
: `[rech] no Chrome profile selected, so rech would open a separate headless browser instead of your Chrome. Refusing.\n Pick one: rech --profile <email> open <url>, or add ?profile=<email> to RECHROME_URL\n ${known}\n`;
|
|
723
|
+
log(`refused open without a profile (session=${namespacedSession}, requested=${requested ?? "-"})`);
|
|
724
|
+
return Response.json({ status: 1, stdout: "", stderr, files: [] });
|
|
725
|
+
}
|
|
726
|
+
|
|
694
727
|
// open against an existing session: bare `open` returns a tab-list hint; `open <url>`
|
|
695
728
|
// converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
|
|
696
729
|
// was dead — about:blank/<url> is already appended above, so length is always >=2.)
|
|
@@ -730,21 +763,6 @@ export async function serve() {
|
|
|
730
763
|
}
|
|
731
764
|
}
|
|
732
765
|
|
|
733
|
-
// Merge passthrough env: server .env.local defaults, then client overrides
|
|
734
|
-
const passthroughEnv: Record<string, string | undefined> = {};
|
|
735
|
-
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
736
|
-
if (process.env[key]) passthroughEnv[key] = process.env[key];
|
|
737
|
-
}
|
|
738
|
-
Object.assign(passthroughEnv, clientEnv, profileEnv);
|
|
739
|
-
|
|
740
|
-
// Resolve profile name/email → directory name
|
|
741
|
-
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
742
|
-
const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
743
|
-
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
744
|
-
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
745
|
-
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
766
|
const childEnv: Record<string, string | undefined> = {
|
|
749
767
|
PATH: process.env.PATH,
|
|
750
768
|
HOME: HOME,
|