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 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 taku2 symval-dev` puts
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 qamac-remote --port 13776
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 "taku2"): prefer an email, else the alphabetically first key.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.31.2",
3
+ "version": "1.31.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
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
- const inheritedEnvKeys = new Set(Object.keys(process.env));
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
- export type TailscaleServe = { dnsName: string | null; routeUrl: string | null };
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
- return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
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 = `${ENV_KEY}=${url}`;
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 create one: rech listener add share --listen local --prefix=rechrome --port 13776 --profile ${JSON.stringify(profile)}`,
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
- console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
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
- const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
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(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
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 (!interactive || !(error instanceof ShareListenerError)) throw error;
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
- if (!error.listeners.length) throw error; // nothing to allow it on: the error names `rech listener add`
1235
- // Allowing is a config change, so the default is to cancel.
1236
- const target = await promptChoice(`"${profile}" isn't shared on any listener yet. Allow it on:`,
1237
- [...error.listeners.map(l => ({ label: describe(l), value: l.name as string | null })), { label: "Cancel (change nothing)", value: null }], error.listeners.length);
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
- const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
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, `${ENV_KEY}=${keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))}`, ""].join("\n"));
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 newLine = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
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 ${newLine.replace("RECHROME_URL=", "RECHROME_URL='")}' rech open https://example.com\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, `${ENV_KEY}=${url}`, ""].join("\n"), { mode: 0o600 });
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 = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
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
- const inheritedEnvKeys = new Set(Object.keys(process.env));
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
- export type TailscaleServe = { dnsName: string | null; routeUrl: string | null };
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
- return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
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 = `${ENV_KEY}=${url}`;
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 create one: rech listener add share --listen local --prefix=rechrome --port 13776 --profile ${JSON.stringify(profile)}`,
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
- console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
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
- const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
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(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
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 (!interactive || !(error instanceof ShareListenerError)) throw error;
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
- if (!error.listeners.length) throw error; // nothing to allow it on: the error names `rech listener add`
1235
- // Allowing is a config change, so the default is to cancel.
1236
- const target = await promptChoice(`"${profile}" isn't shared on any listener yet. Allow it on:`,
1237
- [...error.listeners.map(l => ({ label: describe(l), value: l.name as string | null })), { label: "Cancel (change nothing)", value: null }], error.listeners.length);
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
- const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
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, `${ENV_KEY}=${keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))}`, ""].join("\n"));
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 newLine = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
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 ${newLine.replace("RECHROME_URL=", "RECHROME_URL='")}' rech open https://example.com\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, `${ENV_KEY}=${url}`, ""].join("\n"), { mode: 0o600 });
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 = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
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 `taku3` is also accepted with `--profile`.
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 taku3` is not supported by the current CLI.
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.