rechrome 1.31.2 → 1.31.4
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 +4 -2
- package/listeners.ts +1 -1
- package/package.json +1 -1
- package/rechrome.js +162 -22
- package/rechrome.ts +162 -22
- package/skills/rechrome-setup/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -74,6 +74,8 @@ between calls; another worktree gets its own. `-s=<name>` opens a named sub-sess
|
|
|
74
74
|
`<project>` is the git worktree root (submodules count as their parent project), or the current
|
|
75
75
|
directory outside git. rechrome reads `RECHROME_URL` from the nearest `.rechrome/.env.local` or
|
|
76
76
|
`.env.local` walking up from the current directory; an explicit environment variable wins.
|
|
77
|
+
When editing a file by hand, quote the URL (`RECHROME_URL="…#key=…"`): unquoted, a `#` starts a
|
|
78
|
+
comment for some .env loaders. rech quotes it when it saves one.
|
|
77
79
|
|
|
78
80
|
### 4. Use it from another machine
|
|
79
81
|
|
|
@@ -106,7 +108,7 @@ rech open https://example.com
|
|
|
106
108
|
`.rechrome/.env.local`. Remote listeners allow navigation, tabs, snapshots, screenshots and basic
|
|
107
109
|
interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
|
|
108
110
|
|
|
109
|
-
**Share several profiles in one link.** `rech share work@example.com
|
|
111
|
+
**Share several profiles in one link.** `rech share work@example.com personal team-dev` puts
|
|
110
112
|
exactly those profiles on one listener (its own key; running it again reuses it). Add
|
|
111
113
|
`--listener <name>` to use a listener that already has a proxy route. Its links then reach
|
|
112
114
|
exactly this list, and `rech listener rotate-key <name>` issues a fresh key if old links shouldn't.
|
|
@@ -262,7 +264,7 @@ the last working configuration. Each listener has its own bearer key. Use `rech
|
|
|
262
264
|
to see configured exposure, and `rech listener ls` to list bindings without credentials.
|
|
263
265
|
|
|
264
266
|
```bash
|
|
265
|
-
rech listener add qa --listen tailscale --profile
|
|
267
|
+
rech listener add qa --listen tailscale --profile qa-remote --port 13776
|
|
266
268
|
rech listener remove qa
|
|
267
269
|
```
|
|
268
270
|
|
package/listeners.ts
CHANGED
|
@@ -142,7 +142,7 @@ const profileIdentity = (e: RegisteredProfile) => `${e.userDataDir ?? ""}\0${e.p
|
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
* One registry key per Chrome profile (a profile can be registered under several aliases,
|
|
145
|
-
* e.g. "Profile 5" and "
|
|
145
|
+
* e.g. "Profile 5" and "personal"): prefer an email, else the alphabetically first key.
|
|
146
146
|
*/
|
|
147
147
|
export function canonicalProfileKeys(registry: Record<string, RegisteredProfile>): string[] {
|
|
148
148
|
const byProfile = new Map<string, string>();
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -88,7 +88,44 @@ const globalEnvFile = join(HOME || "~", ".env.local");
|
|
|
88
88
|
|
|
89
89
|
// Capture inherited values once so explicit environment overrides survive reloads,
|
|
90
90
|
// while values loaded from files can still change when those files are edited.
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A KEY=value line for an env file. A value with "#" (every connection URL's #key=…) or
|
|
93
|
+
* whitespace is double-quoted: Bun's own .env loader treats an unquoted "#" as a comment and
|
|
94
|
+
* would drop the key. rech's loader strips the quotes again.
|
|
95
|
+
*/
|
|
96
|
+
export function envAssignment(key: string, value: string): string {
|
|
97
|
+
return /[#\s]/.test(value) ? `${key}="${value}"` : `${key}=${value}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse KEY=value lines the way Bun's .env loader does (unquoted values end at "#"). */
|
|
101
|
+
function parseBunEnvFile(text: string): Record<string, string> {
|
|
102
|
+
const out: Record<string, string> = {};
|
|
103
|
+
for (const line of text.split("\n")) {
|
|
104
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
const raw = m[2].trim();
|
|
107
|
+
const quoted = raw.match(/^(["'`])(.*)\1/);
|
|
108
|
+
out[m[1]] = quoted ? quoted[2] : raw.replace(/\s*#.*$/, "").trim();
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Keys Bun itself loaded from the current folder's .env files before rech started. They look
|
|
115
|
+
* like shell exports, but are file values: counting them as inherited would let a folder's
|
|
116
|
+
* .env.local beat its .rechrome/.env.local, and a keyless (cut at "#") URL beat a good one.
|
|
117
|
+
*/
|
|
118
|
+
export function bunAutoloadedKeys(env: Record<string, string | undefined>, files: string[]): Set<string> {
|
|
119
|
+
const keys = new Set<string>();
|
|
120
|
+
for (const text of files)
|
|
121
|
+
for (const [key, value] of Object.entries(parseBunEnvFile(text))) if (env[key] === value) keys.add(key);
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bunLoaded = bunAutoloadedKeys(process.env,
|
|
126
|
+
[".env", `.env.${process.env.NODE_ENV || "development"}`, ".env.local", `.env.${process.env.NODE_ENV || "development"}.local`]
|
|
127
|
+
.map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
|
|
128
|
+
const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
|
|
92
129
|
|
|
93
130
|
// Walk CWD→root loading env files nearest-first; inherited environment wins over files.
|
|
94
131
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
@@ -362,7 +399,18 @@ export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
|
362
399
|
return buildListenChoices(networkInterfaces(), ips);
|
|
363
400
|
}
|
|
364
401
|
|
|
365
|
-
|
|
402
|
+
/**
|
|
403
|
+
* `funnel`: Funnel (public internet) is on for the host that serves — or would serve — this
|
|
404
|
+
* node's routes, so a route there is not tailnet-only. `mounts`: every Serve path in use.
|
|
405
|
+
*/
|
|
406
|
+
export type TailscaleServe = { dnsName: string | null; routeUrl: string | null; funnel: boolean; mounts: string[] };
|
|
407
|
+
|
|
408
|
+
export function serveMountsAndFunnel(serveStatus: unknown, host: string | null): { mounts: string[]; funnel: boolean } {
|
|
409
|
+
const status = serveStatus as { Web?: Record<string, { Handlers?: Record<string, unknown> }>; AllowFunnel?: Record<string, boolean> } | null;
|
|
410
|
+
const mounts = Object.values(status?.Web ?? {}).flatMap(config => Object.keys(config.Handlers ?? {}).map(path => path.replace(/\/+$/, "") || "/"));
|
|
411
|
+
const funnel = Object.entries(status?.AllowFunnel ?? {}).some(([hostPort, on]) => on && (!host || hostPort.split(/:(?=\d+$)/)[0] === host));
|
|
412
|
+
return { mounts: [...new Set(mounts)], funnel };
|
|
413
|
+
}
|
|
366
414
|
|
|
367
415
|
/**
|
|
368
416
|
* Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
|
|
@@ -393,7 +441,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
393
441
|
let serveStatus: unknown = null;
|
|
394
442
|
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
395
443
|
try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
|
|
396
|
-
|
|
444
|
+
const routeUrl = findTailscaleServeRoute(dnsName, serveStatus, port, prefix);
|
|
445
|
+
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
397
446
|
}
|
|
398
447
|
|
|
399
448
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
@@ -425,7 +474,7 @@ export async function getOrCreateUrl(persist = true): Promise<string> {
|
|
|
425
474
|
const key = randomBytes(12).toString("base64url"); // 16 chars
|
|
426
475
|
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
427
476
|
if (persist) {
|
|
428
|
-
const newLine =
|
|
477
|
+
const newLine = envAssignment(ENV_KEY, url);
|
|
429
478
|
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
430
479
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
431
480
|
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
@@ -1091,7 +1140,7 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
|
|
|
1091
1140
|
throw new ShareListenerError([
|
|
1092
1141
|
`"${profile}" isn't shared on any listener yet.`,
|
|
1093
1142
|
scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
|
|
1094
|
-
` Or
|
|
1143
|
+
` ${scoped.length ? "Or give" : "Give"} it its own link: rech share ${JSON.stringify(profile)} (in a terminal it asks who should connect)`,
|
|
1095
1144
|
].filter(Boolean).join("\n"), "none", scoped);
|
|
1096
1145
|
}
|
|
1097
1146
|
|
|
@@ -1142,7 +1191,7 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1142
1191
|
}
|
|
1143
1192
|
|
|
1144
1193
|
/** 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> {
|
|
1194
|
+
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
1195
|
const config = await requireListeners();
|
|
1147
1196
|
const name = opts.listener ?? opts.defaultName;
|
|
1148
1197
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1151,7 +1200,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1151
1200
|
let changes = "";
|
|
1152
1201
|
if (!listener) {
|
|
1153
1202
|
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 };
|
|
1203
|
+
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1155
1204
|
config.listeners.push(listener);
|
|
1156
1205
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1157
1206
|
} else {
|
|
@@ -1161,24 +1210,100 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1161
1210
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
1162
1211
|
}
|
|
1163
1212
|
await writeListeners(config);
|
|
1164
|
-
|
|
1213
|
+
if (changes.startsWith("created") && snapshot.length === 1) console.error(`New link "${name}" for ${snapshot[0]}.`);
|
|
1214
|
+
else console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
|
|
1165
1215
|
if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
|
|
1166
1216
|
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
1217
|
// The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
|
|
1168
|
-
|
|
1218
|
+
// A one-profile link names its profile, so the other machine needs no --profile.
|
|
1219
|
+
const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
|
|
1220
|
+
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1169
1221
|
let ready = false;
|
|
1170
1222
|
for (let i = 0; i < 20 && !ready; i++) {
|
|
1171
1223
|
ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
|
|
1172
1224
|
if (!ready) await Bun.sleep(250);
|
|
1173
1225
|
}
|
|
1174
1226
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1227
|
+
if (opts.tailscaleServe && !listener.publicUrl) {
|
|
1228
|
+
const publicUrl = await exposeWithTailscaleServe(listener);
|
|
1229
|
+
if (publicUrl) {
|
|
1230
|
+
listener.publicUrl = publicUrl;
|
|
1231
|
+
await writeListeners(config);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1175
1234
|
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);
|
|
1235
|
+
if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1177
1236
|
console.log(uri);
|
|
1178
|
-
console.error(`
|
|
1237
|
+
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
1238
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
1180
1239
|
}
|
|
1181
1240
|
|
|
1241
|
+
/** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
|
|
1242
|
+
export function profileSlug(profile: string): string {
|
|
1243
|
+
const slug = profile.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
1244
|
+
return slug || "profile";
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* `base`, or `base-2`, `base-3`… — the first name whose listener and path are free. Never a
|
|
1249
|
+
* reserved name (`share --all` and `share a b` reuse theirs and rewrite their allowlists, so a
|
|
1250
|
+
* one-profile link under such a name would later grant its key more profiles), and never a
|
|
1251
|
+
* mount a proxy already serves (Tailscale Serve would replace that route).
|
|
1252
|
+
*/
|
|
1253
|
+
export function uniqueShareName(base: string, listeners: Listener[], servedMounts: string[] = []): string {
|
|
1254
|
+
const reserved = (name: string) => name === "local" || name === "share-all" || /^share-[0-9a-f]{6}$/.test(name);
|
|
1255
|
+
const taken = (name: string) => reserved(name) || servedMounts.includes(`/rechrome/${name}`)
|
|
1256
|
+
|| listeners.some(l => l.name === name || normalizePrefix(l.prefix) === `/rechrome/${name}/`);
|
|
1257
|
+
for (let i = 1; ; i++) { const name = i === 1 ? base : `${base}-${i}`; if (!taken(name)) return name; }
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Route a loopback listener through Tailscale Serve (HTTPS, tailnet only); returns its public URL. */
|
|
1261
|
+
async function exposeWithTailscaleServe(listener: Listener): Promise<string | null> {
|
|
1262
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
1263
|
+
const existing = await detectTailscaleServe(listener.port, prefix);
|
|
1264
|
+
if (existing.funnel) {
|
|
1265
|
+
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.");
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
if (existing.routeUrl) return existing.routeUrl;
|
|
1269
|
+
const binary = tailscaleBinary();
|
|
1270
|
+
if (!binary) return null;
|
|
1271
|
+
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1272
|
+
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1273
|
+
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1274
|
+
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1275
|
+
if (await proc.exited !== 0) {
|
|
1276
|
+
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Give one profile its own link. In a terminal, ask how the other machines will reach it
|
|
1284
|
+
* (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
|
|
1285
|
+
* loopback link, like `rech share a b`.
|
|
1286
|
+
*/
|
|
1287
|
+
async function shareNewLink(profile: string, opts: { local?: boolean; save?: boolean }): Promise<void> {
|
|
1288
|
+
const served = tailscaleBinary() ? (await detectTailscaleServe(0, "/")).mounts : [];
|
|
1289
|
+
const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
|
|
1290
|
+
let host = "127.0.0.1", tailscaleServe = false;
|
|
1291
|
+
if (isInteractive()) {
|
|
1292
|
+
const serve = !!tailscaleBinary();
|
|
1293
|
+
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1294
|
+
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1295
|
+
const how = await promptChoice(`How will your other machines reach it?`, [
|
|
1296
|
+
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1297
|
+
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1298
|
+
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1299
|
+
], 0);
|
|
1300
|
+
if (!how) throw new Error("Cancelled; nothing shared.");
|
|
1301
|
+
tailscaleServe = how === "tailscale-serve";
|
|
1302
|
+
host = tailscaleServe ? "127.0.0.1" : how;
|
|
1303
|
+
}
|
|
1304
|
+
return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1182
1307
|
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
|
|
1183
1308
|
if (opts.all) {
|
|
1184
1309
|
if (selector) throw new Error("Pass a profile or --all, not both.");
|
|
@@ -1225,17 +1350,24 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1225
1350
|
if (!listener && config) {
|
|
1226
1351
|
try { listener = chooseShareListener(profile, listeners); }
|
|
1227
1352
|
catch (error) {
|
|
1228
|
-
if (!
|
|
1353
|
+
if (!(error instanceof ShareListenerError)) throw error;
|
|
1354
|
+
// Not shared yet: give it its own link rather than stopping with instructions.
|
|
1355
|
+
if (!interactive) { if (error.kind === "none") return shareNewLink(profile, opts); throw error; }
|
|
1229
1356
|
const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
|
|
1230
1357
|
if (error.kind === "several") {
|
|
1231
1358
|
listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
|
|
1232
1359
|
if (!listener) throw cancelled();
|
|
1233
1360
|
} else {
|
|
1234
|
-
|
|
1235
|
-
//
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1361
|
+
// Keep going until there is a URL (or the user cancels): a new link of its own is the
|
|
1362
|
+
// default, since allowing it on an existing listener hands it to that listener's key holders.
|
|
1363
|
+
const NEW_LINK = "\0new";
|
|
1364
|
+
const target = error.listeners.length ? await promptChoice(`${profile} isn't shared yet.`, [
|
|
1365
|
+
{ label: "Create a new link for it", value: NEW_LINK as string | null },
|
|
1366
|
+
...error.listeners.map(l => ({ label: `Add it to link "${l.name}" (its existing key)`, value: l.name as string | null })),
|
|
1367
|
+
{ label: "Cancel", value: null },
|
|
1368
|
+
], 0) : NEW_LINK;
|
|
1238
1369
|
if (!target) throw cancelled();
|
|
1370
|
+
if (target === NEW_LINK) return shareNewLink(profile, opts);
|
|
1239
1371
|
allowProfiles(config, target, [profile]);
|
|
1240
1372
|
await writeListeners(config);
|
|
1241
1373
|
console.error(`[rech] allowed "${profile}" on ${target}`);
|
|
@@ -1250,7 +1382,14 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1250
1382
|
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
1383
|
let uri = profileConnectionUri(profile, url, listeners, listener);
|
|
1252
1384
|
// Prefer where a proxy exposes the listener, when it has been recorded.
|
|
1253
|
-
|
|
1385
|
+
// A link whose proxy was set up by hand since (e.g. after `tailscale serve` failed here):
|
|
1386
|
+
// record the tailnet-only route so this and later shares print it.
|
|
1387
|
+
const pending = listeners.find(l => l.key === parseUrl(uri).key);
|
|
1388
|
+
if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && tailscaleBinary()) {
|
|
1389
|
+
const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
|
|
1390
|
+
if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
|
|
1391
|
+
}
|
|
1392
|
+
const publicUrl = pending?.publicUrl;
|
|
1254
1393
|
if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
|
|
1255
1394
|
console.log(uri);
|
|
1256
1395
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
@@ -1946,7 +2085,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
1946
2085
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
1947
2086
|
const lines = envRaw.trimEnd().split("\n");
|
|
1948
2087
|
const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
1949
|
-
await Bun.write(globalEnvFile, [...filtered,
|
|
2088
|
+
await Bun.write(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"));
|
|
1950
2089
|
|
|
1951
2090
|
const home = HOME;
|
|
1952
2091
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
@@ -2332,14 +2471,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
2332
2471
|
rechUrl.searchParams.set("load_extension", dist);
|
|
2333
2472
|
|
|
2334
2473
|
|
|
2335
|
-
const
|
|
2474
|
+
const profileUrl = registeredProfileUrl(rechUrl.toString());
|
|
2475
|
+
const newLine = envAssignment(ENV_KEY, profileUrl);
|
|
2336
2476
|
|
|
2337
2477
|
// [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
|
|
2338
2478
|
await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
|
|
2339
2479
|
console.log(`\n[3/3] Registered "${name}" in ${TOKENS_FILE}`);
|
|
2340
2480
|
|
|
2341
2481
|
console.log(`\nDone! RECHROME_URL for "${name}":\n\n ${newLine}\n`);
|
|
2342
|
-
console.log(`Use it per-call:\n ${
|
|
2482
|
+
console.log(`Use it per-call:\n ${ENV_KEY}='${profileUrl}' rech open https://example.com\n`);
|
|
2343
2483
|
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
2344
2484
|
}
|
|
2345
2485
|
|
|
@@ -2496,7 +2636,7 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2496
2636
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
2497
2637
|
const envPath = join(dataDir, ".env.local");
|
|
2498
2638
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
2499
|
-
await writeFile(envPath, [...lines,
|
|
2639
|
+
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
2500
2640
|
await chmod(envPath, 0o600);
|
|
2501
2641
|
return envPath;
|
|
2502
2642
|
}
|
|
@@ -2846,7 +2986,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2846
2986
|
console.log(` rech setup --profile ${JSON.stringify(profileEmail)} --listen local --prefix=rechrome`);
|
|
2847
2987
|
console.log(` then expose it with tailscale serve (setup prints the command and the remote URL).`);
|
|
2848
2988
|
}
|
|
2849
|
-
const newLine =
|
|
2989
|
+
const newLine = envAssignment(ENV_KEY, registeredProfileUrl(rechUrl.toString()));
|
|
2850
2990
|
|
|
2851
2991
|
console.log("\n○ [5/5] Connection verified. Save connection configuration:");
|
|
2852
2992
|
console.log(`\n${newLine}\n`);
|
package/rechrome.ts
CHANGED
|
@@ -88,7 +88,44 @@ const globalEnvFile = join(HOME || "~", ".env.local");
|
|
|
88
88
|
|
|
89
89
|
// Capture inherited values once so explicit environment overrides survive reloads,
|
|
90
90
|
// while values loaded from files can still change when those files are edited.
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A KEY=value line for an env file. A value with "#" (every connection URL's #key=…) or
|
|
93
|
+
* whitespace is double-quoted: Bun's own .env loader treats an unquoted "#" as a comment and
|
|
94
|
+
* would drop the key. rech's loader strips the quotes again.
|
|
95
|
+
*/
|
|
96
|
+
export function envAssignment(key: string, value: string): string {
|
|
97
|
+
return /[#\s]/.test(value) ? `${key}="${value}"` : `${key}=${value}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse KEY=value lines the way Bun's .env loader does (unquoted values end at "#"). */
|
|
101
|
+
function parseBunEnvFile(text: string): Record<string, string> {
|
|
102
|
+
const out: Record<string, string> = {};
|
|
103
|
+
for (const line of text.split("\n")) {
|
|
104
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
const raw = m[2].trim();
|
|
107
|
+
const quoted = raw.match(/^(["'`])(.*)\1/);
|
|
108
|
+
out[m[1]] = quoted ? quoted[2] : raw.replace(/\s*#.*$/, "").trim();
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Keys Bun itself loaded from the current folder's .env files before rech started. They look
|
|
115
|
+
* like shell exports, but are file values: counting them as inherited would let a folder's
|
|
116
|
+
* .env.local beat its .rechrome/.env.local, and a keyless (cut at "#") URL beat a good one.
|
|
117
|
+
*/
|
|
118
|
+
export function bunAutoloadedKeys(env: Record<string, string | undefined>, files: string[]): Set<string> {
|
|
119
|
+
const keys = new Set<string>();
|
|
120
|
+
for (const text of files)
|
|
121
|
+
for (const [key, value] of Object.entries(parseBunEnvFile(text))) if (env[key] === value) keys.add(key);
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bunLoaded = bunAutoloadedKeys(process.env,
|
|
126
|
+
[".env", `.env.${process.env.NODE_ENV || "development"}`, ".env.local", `.env.${process.env.NODE_ENV || "development"}.local`]
|
|
127
|
+
.map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
|
|
128
|
+
const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
|
|
92
129
|
|
|
93
130
|
// Walk CWD→root loading env files nearest-first; inherited environment wins over files.
|
|
94
131
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
@@ -362,7 +399,18 @@ export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
|
362
399
|
return buildListenChoices(networkInterfaces(), ips);
|
|
363
400
|
}
|
|
364
401
|
|
|
365
|
-
|
|
402
|
+
/**
|
|
403
|
+
* `funnel`: Funnel (public internet) is on for the host that serves — or would serve — this
|
|
404
|
+
* node's routes, so a route there is not tailnet-only. `mounts`: every Serve path in use.
|
|
405
|
+
*/
|
|
406
|
+
export type TailscaleServe = { dnsName: string | null; routeUrl: string | null; funnel: boolean; mounts: string[] };
|
|
407
|
+
|
|
408
|
+
export function serveMountsAndFunnel(serveStatus: unknown, host: string | null): { mounts: string[]; funnel: boolean } {
|
|
409
|
+
const status = serveStatus as { Web?: Record<string, { Handlers?: Record<string, unknown> }>; AllowFunnel?: Record<string, boolean> } | null;
|
|
410
|
+
const mounts = Object.values(status?.Web ?? {}).flatMap(config => Object.keys(config.Handlers ?? {}).map(path => path.replace(/\/+$/, "") || "/"));
|
|
411
|
+
const funnel = Object.entries(status?.AllowFunnel ?? {}).some(([hostPort, on]) => on && (!host || hostPort.split(/:(?=\d+$)/)[0] === host));
|
|
412
|
+
return { mounts: [...new Set(mounts)], funnel };
|
|
413
|
+
}
|
|
366
414
|
|
|
367
415
|
/**
|
|
368
416
|
* Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
|
|
@@ -393,7 +441,8 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
393
441
|
let serveStatus: unknown = null;
|
|
394
442
|
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
395
443
|
try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
|
|
396
|
-
|
|
444
|
+
const routeUrl = findTailscaleServeRoute(dnsName, serveStatus, port, prefix);
|
|
445
|
+
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
397
446
|
}
|
|
398
447
|
|
|
399
448
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
@@ -425,7 +474,7 @@ export async function getOrCreateUrl(persist = true): Promise<string> {
|
|
|
425
474
|
const key = randomBytes(12).toString("base64url"); // 16 chars
|
|
426
475
|
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
427
476
|
if (persist) {
|
|
428
|
-
const newLine =
|
|
477
|
+
const newLine = envAssignment(ENV_KEY, url);
|
|
429
478
|
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
430
479
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
431
480
|
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
@@ -1091,7 +1140,7 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
|
|
|
1091
1140
|
throw new ShareListenerError([
|
|
1092
1141
|
`"${profile}" isn't shared on any listener yet.`,
|
|
1093
1142
|
scoped.length ? ` Allow it on one: rech listener allow ${scoped[0].name} ${JSON.stringify(profile)} (listeners: ${scoped.map(l => l.name).join(", ")})` : "",
|
|
1094
|
-
` Or
|
|
1143
|
+
` ${scoped.length ? "Or give" : "Give"} it its own link: rech share ${JSON.stringify(profile)} (in a terminal it asks who should connect)`,
|
|
1095
1144
|
].filter(Boolean).join("\n"), "none", scoped);
|
|
1096
1145
|
}
|
|
1097
1146
|
|
|
@@ -1142,7 +1191,7 @@ async function shareProfiles(selectors: string[], opts: { listener?: string; loc
|
|
|
1142
1191
|
}
|
|
1143
1192
|
|
|
1144
1193
|
/** 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> {
|
|
1194
|
+
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
1195
|
const config = await requireListeners();
|
|
1147
1196
|
const name = opts.listener ?? opts.defaultName;
|
|
1148
1197
|
let listener = config.listeners.find(l => l.name === name);
|
|
@@ -1151,7 +1200,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1151
1200
|
let changes = "";
|
|
1152
1201
|
if (!listener) {
|
|
1153
1202
|
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 };
|
|
1203
|
+
listener = { name, host: opts.host ?? "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1155
1204
|
config.listeners.push(listener);
|
|
1156
1205
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1157
1206
|
} else {
|
|
@@ -1161,24 +1210,100 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1161
1210
|
changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
|
|
1162
1211
|
}
|
|
1163
1212
|
await writeListeners(config);
|
|
1164
|
-
|
|
1213
|
+
if (changes.startsWith("created") && snapshot.length === 1) console.error(`New link "${name}" for ${snapshot[0]}.`);
|
|
1214
|
+
else console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
|
|
1165
1215
|
if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
|
|
1166
1216
|
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
1217
|
// The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
|
|
1168
|
-
|
|
1218
|
+
// A one-profile link names its profile, so the other machine needs no --profile.
|
|
1219
|
+
const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
|
|
1220
|
+
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1169
1221
|
let ready = false;
|
|
1170
1222
|
for (let i = 0; i < 20 && !ready; i++) {
|
|
1171
1223
|
ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
|
|
1172
1224
|
if (!ready) await Bun.sleep(250);
|
|
1173
1225
|
}
|
|
1174
1226
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1227
|
+
if (opts.tailscaleServe && !listener.publicUrl) {
|
|
1228
|
+
const publicUrl = await exposeWithTailscaleServe(listener);
|
|
1229
|
+
if (publicUrl) {
|
|
1230
|
+
listener.publicUrl = publicUrl;
|
|
1231
|
+
await writeListeners(config);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1175
1234
|
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);
|
|
1235
|
+
if (!listener.publicUrl && isLoopback(listener.host)) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1177
1236
|
console.log(uri);
|
|
1178
|
-
console.error(`
|
|
1237
|
+
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
1238
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
1180
1239
|
}
|
|
1181
1240
|
|
|
1241
|
+
/** A readable, URL-safe name for a profile's link: the email's local part or the profile name. */
|
|
1242
|
+
export function profileSlug(profile: string): string {
|
|
1243
|
+
const slug = profile.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
1244
|
+
return slug || "profile";
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* `base`, or `base-2`, `base-3`… — the first name whose listener and path are free. Never a
|
|
1249
|
+
* reserved name (`share --all` and `share a b` reuse theirs and rewrite their allowlists, so a
|
|
1250
|
+
* one-profile link under such a name would later grant its key more profiles), and never a
|
|
1251
|
+
* mount a proxy already serves (Tailscale Serve would replace that route).
|
|
1252
|
+
*/
|
|
1253
|
+
export function uniqueShareName(base: string, listeners: Listener[], servedMounts: string[] = []): string {
|
|
1254
|
+
const reserved = (name: string) => name === "local" || name === "share-all" || /^share-[0-9a-f]{6}$/.test(name);
|
|
1255
|
+
const taken = (name: string) => reserved(name) || servedMounts.includes(`/rechrome/${name}`)
|
|
1256
|
+
|| listeners.some(l => l.name === name || normalizePrefix(l.prefix) === `/rechrome/${name}/`);
|
|
1257
|
+
for (let i = 1; ; i++) { const name = i === 1 ? base : `${base}-${i}`; if (!taken(name)) return name; }
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Route a loopback listener through Tailscale Serve (HTTPS, tailnet only); returns its public URL. */
|
|
1261
|
+
async function exposeWithTailscaleServe(listener: Listener): Promise<string | null> {
|
|
1262
|
+
const prefix = normalizePrefix(listener.prefix);
|
|
1263
|
+
const existing = await detectTailscaleServe(listener.port, prefix);
|
|
1264
|
+
if (existing.funnel) {
|
|
1265
|
+
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.");
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
if (existing.routeUrl) return existing.routeUrl;
|
|
1269
|
+
const binary = tailscaleBinary();
|
|
1270
|
+
if (!binary) return null;
|
|
1271
|
+
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1272
|
+
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1273
|
+
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1274
|
+
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1275
|
+
if (await proc.exited !== 0) {
|
|
1276
|
+
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Give one profile its own link. In a terminal, ask how the other machines will reach it
|
|
1284
|
+
* (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
|
|
1285
|
+
* loopback link, like `rech share a b`.
|
|
1286
|
+
*/
|
|
1287
|
+
async function shareNewLink(profile: string, opts: { local?: boolean; save?: boolean }): Promise<void> {
|
|
1288
|
+
const served = tailscaleBinary() ? (await detectTailscaleServe(0, "/")).mounts : [];
|
|
1289
|
+
const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
|
|
1290
|
+
let host = "127.0.0.1", tailscaleServe = false;
|
|
1291
|
+
if (isInteractive()) {
|
|
1292
|
+
const serve = !!tailscaleBinary();
|
|
1293
|
+
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1294
|
+
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1295
|
+
const how = await promptChoice(`How will your other machines reach it?`, [
|
|
1296
|
+
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1297
|
+
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1298
|
+
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1299
|
+
], 0);
|
|
1300
|
+
if (!how) throw new Error("Cancelled; nothing shared.");
|
|
1301
|
+
tailscaleServe = how === "tailscale-serve";
|
|
1302
|
+
host = tailscaleServe ? "127.0.0.1" : how;
|
|
1303
|
+
}
|
|
1304
|
+
return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1182
1307
|
async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
|
|
1183
1308
|
if (opts.all) {
|
|
1184
1309
|
if (selector) throw new Error("Pass a profile or --all, not both.");
|
|
@@ -1225,17 +1350,24 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1225
1350
|
if (!listener && config) {
|
|
1226
1351
|
try { listener = chooseShareListener(profile, listeners); }
|
|
1227
1352
|
catch (error) {
|
|
1228
|
-
if (!
|
|
1353
|
+
if (!(error instanceof ShareListenerError)) throw error;
|
|
1354
|
+
// Not shared yet: give it its own link rather than stopping with instructions.
|
|
1355
|
+
if (!interactive) { if (error.kind === "none") return shareNewLink(profile, opts); throw error; }
|
|
1229
1356
|
const describe = (l: Listener) => `${l.name} ${l.publicUrl ?? `${listenerAddress(l)}${normalizePrefix(l.prefix)}`}`;
|
|
1230
1357
|
if (error.kind === "several") {
|
|
1231
1358
|
listener = (await promptChoice(`"${profile}" is on several listeners. Share through which?`, error.listeners.map(l => ({ label: describe(l), value: l.name })))) ?? undefined;
|
|
1232
1359
|
if (!listener) throw cancelled();
|
|
1233
1360
|
} else {
|
|
1234
|
-
|
|
1235
|
-
//
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1361
|
+
// Keep going until there is a URL (or the user cancels): a new link of its own is the
|
|
1362
|
+
// default, since allowing it on an existing listener hands it to that listener's key holders.
|
|
1363
|
+
const NEW_LINK = "\0new";
|
|
1364
|
+
const target = error.listeners.length ? await promptChoice(`${profile} isn't shared yet.`, [
|
|
1365
|
+
{ label: "Create a new link for it", value: NEW_LINK as string | null },
|
|
1366
|
+
...error.listeners.map(l => ({ label: `Add it to link "${l.name}" (its existing key)`, value: l.name as string | null })),
|
|
1367
|
+
{ label: "Cancel", value: null },
|
|
1368
|
+
], 0) : NEW_LINK;
|
|
1238
1369
|
if (!target) throw cancelled();
|
|
1370
|
+
if (target === NEW_LINK) return shareNewLink(profile, opts);
|
|
1239
1371
|
allowProfiles(config, target, [profile]);
|
|
1240
1372
|
await writeListeners(config);
|
|
1241
1373
|
console.error(`[rech] allowed "${profile}" on ${target}`);
|
|
@@ -1250,7 +1382,14 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1250
1382
|
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
1383
|
let uri = profileConnectionUri(profile, url, listeners, listener);
|
|
1252
1384
|
// Prefer where a proxy exposes the listener, when it has been recorded.
|
|
1253
|
-
|
|
1385
|
+
// A link whose proxy was set up by hand since (e.g. after `tailscale serve` failed here):
|
|
1386
|
+
// record the tailnet-only route so this and later shares print it.
|
|
1387
|
+
const pending = listeners.find(l => l.key === parseUrl(uri).key);
|
|
1388
|
+
if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && tailscaleBinary()) {
|
|
1389
|
+
const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
|
|
1390
|
+
if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
|
|
1391
|
+
}
|
|
1392
|
+
const publicUrl = pending?.publicUrl;
|
|
1254
1393
|
if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
|
|
1255
1394
|
console.log(uri);
|
|
1256
1395
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
@@ -1946,7 +2085,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
1946
2085
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
1947
2086
|
const lines = envRaw.trimEnd().split("\n");
|
|
1948
2087
|
const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
1949
|
-
await Bun.write(globalEnvFile, [...filtered,
|
|
2088
|
+
await Bun.write(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"));
|
|
1950
2089
|
|
|
1951
2090
|
const home = HOME;
|
|
1952
2091
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
@@ -2332,14 +2471,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
2332
2471
|
rechUrl.searchParams.set("load_extension", dist);
|
|
2333
2472
|
|
|
2334
2473
|
|
|
2335
|
-
const
|
|
2474
|
+
const profileUrl = registeredProfileUrl(rechUrl.toString());
|
|
2475
|
+
const newLine = envAssignment(ENV_KEY, profileUrl);
|
|
2336
2476
|
|
|
2337
2477
|
// [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
|
|
2338
2478
|
await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
|
|
2339
2479
|
console.log(`\n[3/3] Registered "${name}" in ${TOKENS_FILE}`);
|
|
2340
2480
|
|
|
2341
2481
|
console.log(`\nDone! RECHROME_URL for "${name}":\n\n ${newLine}\n`);
|
|
2342
|
-
console.log(`Use it per-call:\n ${
|
|
2482
|
+
console.log(`Use it per-call:\n ${ENV_KEY}='${profileUrl}' rech open https://example.com\n`);
|
|
2343
2483
|
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
2344
2484
|
}
|
|
2345
2485
|
|
|
@@ -2496,7 +2636,7 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2496
2636
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
2497
2637
|
const envPath = join(dataDir, ".env.local");
|
|
2498
2638
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
2499
|
-
await writeFile(envPath, [...lines,
|
|
2639
|
+
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
2500
2640
|
await chmod(envPath, 0o600);
|
|
2501
2641
|
return envPath;
|
|
2502
2642
|
}
|
|
@@ -2846,7 +2986,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2846
2986
|
console.log(` rech setup --profile ${JSON.stringify(profileEmail)} --listen local --prefix=rechrome`);
|
|
2847
2987
|
console.log(` then expose it with tailscale serve (setup prints the command and the remote URL).`);
|
|
2848
2988
|
}
|
|
2849
|
-
const newLine =
|
|
2989
|
+
const newLine = envAssignment(ENV_KEY, registeredProfileUrl(rechUrl.toString()));
|
|
2850
2990
|
|
|
2851
2991
|
console.log("\n○ [5/5] Connection verified. Save connection configuration:");
|
|
2852
2992
|
console.log(`\n${newLine}\n`);
|
|
@@ -11,12 +11,12 @@ Use this workflow when the user requests rechrome setup for an existing Chrome p
|
|
|
11
11
|
|
|
12
12
|
- Preserve the user's browser choice. Installed Google Chrome profiles belong to the user; do not rename, create, delete, or replace them as part of setup.
|
|
13
13
|
- If the user wants an isolated Playwright browser, do not send them through real-Chrome extension installation. Playwright can control its own browser directly. Check the current CLI help for available managed-browser support: the legacy `provision-profile --experimental` flow still uses a bridge and may require the vendored relay patch described in the repo's `AGENTS.md`. Do not present a proposed direct-browser mode as implemented.
|
|
14
|
-
- Run `rech profile ls` and resolve the exact email, display name, or folder. For no-email profiles, use the folder (for example `Profile 7`) to avoid ambiguity. A requested display name such as `
|
|
14
|
+
- Run `rech profile ls` and resolve the exact email, display name, or folder. For no-email profiles, use the folder (for example `Profile 7`) to avoid ambiguity. A requested display name such as `Work 2` is also accepted with `--profile`.
|
|
15
15
|
- Use `rech` or `rechrome` if installed; from a checkout use `bun rechrome.ts`. The package is `rechrome`, not `rech`: use `bunx rechrome`, never `bunx rech`.
|
|
16
16
|
|
|
17
17
|
## Start and retain the setup process
|
|
18
18
|
|
|
19
|
-
Run `rech setup --profile "<selector>"` in a retained terminal session, preferably with a TTY. A bare positional selector such as `rech setup
|
|
19
|
+
Run `rech setup --profile "<selector>"` in a retained terminal session, preferably with a TTY. A bare positional selector such as `rech setup work-2` is not supported by the current CLI.
|
|
20
20
|
|
|
21
21
|
- The listen choice applies to the selected profile. Local is the default and removes that profile from remote allowlists; choose LAN or Tailscale when requested. Other profiles keep their exposure. The menu shows Tailscale only when detected. Use `rech listener ls` and `rech profile ls` to review configured bindings. Listener changes reload in one daemon without restarting Chrome; first migration may restart only the daemon.
|
|
22
22
|
- Keep the terminal session handle and poll it while working in Chrome. Non-TTY mode also works, but input is read until EOF before setup starts: provide all prompt answers and close stdin. EOF does not stop the guide server.
|