rechrome 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/rechrome.js +66 -9
  3. package/rechrome.ts +66 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -84,6 +84,8 @@ async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<
84
84
  }
85
85
 
86
86
  const envFile = join(import.meta.dir, ".env.local");
87
+ /** Machine-wide RECHROME_URL from `rech connect --global`: read from every folder, after the project's own files. */
88
+ export const GLOBAL_ENV_FILE = join(RECH_DIR, ".env.local");
87
89
  const globalEnvFile = join(HOME || "~", ".env.local");
88
90
 
89
91
  // Capture inherited values once so explicit environment overrides survive reloads,
@@ -127,10 +129,15 @@ const bunLoaded = bunAutoloadedKeys(process.env,
127
129
  .map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
128
130
  const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
129
131
 
132
+ /** Where RECHROME_URL came from: "environment", an env file's path, or undefined when unset. */
133
+ export let rechromeUrlSource: string | undefined;
134
+
130
135
  // Walk CWD→root loading env files nearest-first; inherited environment wins over files.
131
136
  // At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
132
- export async function loadNearestEnv(extraFallbacks: string[] = []) {
137
+ // The machine-wide file comes last, so it also applies outside $HOME (e.g. an agent's /tmp scratch dir).
138
+ export async function loadNearestEnv(extraFallbacks: string[] = [GLOBAL_ENV_FILE]) {
133
139
  const seen = new Set<string>(inheritedEnvKeys);
140
+ rechromeUrlSource = seen.has(ENV_KEY) ? "environment" : undefined;
134
141
  const applyFile = async (path: string) => {
135
142
  const raw = await file(path).text().catch(() => "");
136
143
  for (const line of raw.split("\n")) {
@@ -138,6 +145,7 @@ export async function loadNearestEnv(extraFallbacks: string[] = []) {
138
145
  if (!m || m[1].startsWith("#")) continue;
139
146
  if (seen.has(m[1])) continue;
140
147
  seen.add(m[1]);
148
+ if (m[1] === ENV_KEY) rechromeUrlSource = path;
141
149
  process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
142
150
  }
143
151
  };
@@ -3044,14 +3052,42 @@ async function saveProjectUrl(url: string): Promise<string> {
3044
3052
  mkdirSync(dataDir, { recursive: true });
3045
3053
  const gitignore = join(dataDir, ".gitignore");
3046
3054
  if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
3047
- const envPath = join(dataDir, ".env.local");
3055
+ return writeEnvUrl(join(dataDir, ".env.local"), url);
3056
+ }
3057
+
3058
+ /** Write RECHROME_URL to ~/.rechrome/.env.local: the default for every project without its own. */
3059
+ async function saveGlobalUrl(url: string): Promise<string> {
3060
+ mkdirSync(dirname(GLOBAL_ENV_FILE), { recursive: true });
3061
+ return writeEnvUrl(GLOBAL_ENV_FILE, url);
3062
+ }
3063
+
3064
+ /** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
3065
+ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3048
3066
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3049
3067
  await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
3050
3068
  await chmod(envPath, 0o600);
3051
3069
  return envPath;
3052
3070
  }
3053
3071
 
3054
- async function connect(url: string): Promise<void> {
3072
+ /**
3073
+ * What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
3074
+ * nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
3075
+ */
3076
+ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GLOBAL_ENV_FILE): string | null {
3077
+ if (inherited) return "your shell's RECHROME_URL";
3078
+ const globalReal = (() => { try { return realpathSync(globalFile); } catch { return globalFile; } })();
3079
+ for (let dir = cwd; ; dir = dirname(dir)) {
3080
+ for (const path of [join(dir, ".rechrome", ".env.local"), join(dir, ".env.local")]) {
3081
+ if (path === globalFile || path === globalReal) return null;
3082
+ const text = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
3083
+ if (text.split("\n").some(line => line.trimStart().startsWith(`${ENV_KEY}=`))) return path;
3084
+ }
3085
+ if (dirname(dir) === dir) return null;
3086
+ }
3087
+ }
3088
+
3089
+ async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
3090
+ if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
3055
3091
  const parsed = parseUrl(url);
3056
3092
  if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
3057
3093
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
@@ -3061,8 +3097,24 @@ async function connect(url: string): Promise<void> {
3061
3097
  const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
3062
3098
  if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
3063
3099
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
3064
- const saved = await saveProjectUrl(url);
3065
- console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
3100
+ // Where to save: the flag, else ask in a terminal (this project by default), else this project.
3101
+ let global = !!opts.global;
3102
+ if (!opts.global && !opts.project && isInteractive()) {
3103
+ const { dataDir } = await getClientIdentity();
3104
+ const choice = await promptChoice("Use this link for:", [
3105
+ { label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
3106
+ { label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
3107
+ ], 0);
3108
+ if (!choice) throw new Error("Cancelled; nothing saved.");
3109
+ global = choice === "global";
3110
+ }
3111
+ // A machine running its own daemon would stop reaching its own Chrome from every project without a link.
3112
+ if (global && !(await isLocalDaemon(url)) && (await readListeners().catch(() => null))?.listeners.length)
3113
+ console.error(`[rech] warning: this machine runs its own rech daemon. Projects without their own RECHROME_URL will now use ${serviceUrl(url)}, not this machine's Chrome. Undo: remove ${ENV_KEY} from ${GLOBAL_ENV_FILE}`);
3114
+ const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
3115
+ console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
3116
+ const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
3117
+ if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
3066
3118
  if (Array.isArray(body.profiles) && body.profiles.length > 1) {
3067
3119
  console.log(`This link shares ${body.profiles.length} profiles:`);
3068
3120
  for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
@@ -3491,6 +3543,7 @@ async function status(): Promise<void> {
3491
3543
  console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
3492
3544
  : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
3493
3545
  : `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
3546
+ if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
3494
3547
  if (pingResponse?.status === 401)
3495
3548
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3496
3549
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3533,7 +3586,7 @@ export type RechHandlers = {
3533
3586
  printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
3534
3587
  urlList(): Promise<void>;
3535
3588
  shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
3536
- connect(url: string): Promise<void>;
3589
+ connect(url: string, opts?: { global?: boolean; project?: boolean }): Promise<void>;
3537
3590
  listenerPort(name?: string): Promise<void>;
3538
3591
  allowListener(name: string, profiles: string[]): Promise<void>;
3539
3592
  denyListener(name: string, profiles: string[]): Promise<void>;
@@ -3628,10 +3681,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3628
3681
  }
3629
3682
  return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
3630
3683
  })
3631
- .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
3684
+ .command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
3632
3685
  .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
3633
- .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
3634
- a => handlers.connect(a.url))
3686
+ .option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
3687
+ .option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
3688
+ .conflicts("project", "global")
3689
+ .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", "")
3690
+ .example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
3691
+ a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
3635
3692
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
3636
3693
  .command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
3637
3694
  .command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
package/rechrome.ts CHANGED
@@ -84,6 +84,8 @@ async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<
84
84
  }
85
85
 
86
86
  const envFile = join(import.meta.dir, ".env.local");
87
+ /** Machine-wide RECHROME_URL from `rech connect --global`: read from every folder, after the project's own files. */
88
+ export const GLOBAL_ENV_FILE = join(RECH_DIR, ".env.local");
87
89
  const globalEnvFile = join(HOME || "~", ".env.local");
88
90
 
89
91
  // Capture inherited values once so explicit environment overrides survive reloads,
@@ -127,10 +129,15 @@ const bunLoaded = bunAutoloadedKeys(process.env,
127
129
  .map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
128
130
  const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
129
131
 
132
+ /** Where RECHROME_URL came from: "environment", an env file's path, or undefined when unset. */
133
+ export let rechromeUrlSource: string | undefined;
134
+
130
135
  // Walk CWD→root loading env files nearest-first; inherited environment wins over files.
131
136
  // At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
132
- export async function loadNearestEnv(extraFallbacks: string[] = []) {
137
+ // The machine-wide file comes last, so it also applies outside $HOME (e.g. an agent's /tmp scratch dir).
138
+ export async function loadNearestEnv(extraFallbacks: string[] = [GLOBAL_ENV_FILE]) {
133
139
  const seen = new Set<string>(inheritedEnvKeys);
140
+ rechromeUrlSource = seen.has(ENV_KEY) ? "environment" : undefined;
134
141
  const applyFile = async (path: string) => {
135
142
  const raw = await file(path).text().catch(() => "");
136
143
  for (const line of raw.split("\n")) {
@@ -138,6 +145,7 @@ export async function loadNearestEnv(extraFallbacks: string[] = []) {
138
145
  if (!m || m[1].startsWith("#")) continue;
139
146
  if (seen.has(m[1])) continue;
140
147
  seen.add(m[1]);
148
+ if (m[1] === ENV_KEY) rechromeUrlSource = path;
141
149
  process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
142
150
  }
143
151
  };
@@ -3044,14 +3052,42 @@ async function saveProjectUrl(url: string): Promise<string> {
3044
3052
  mkdirSync(dataDir, { recursive: true });
3045
3053
  const gitignore = join(dataDir, ".gitignore");
3046
3054
  if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
3047
- const envPath = join(dataDir, ".env.local");
3055
+ return writeEnvUrl(join(dataDir, ".env.local"), url);
3056
+ }
3057
+
3058
+ /** Write RECHROME_URL to ~/.rechrome/.env.local: the default for every project without its own. */
3059
+ async function saveGlobalUrl(url: string): Promise<string> {
3060
+ mkdirSync(dirname(GLOBAL_ENV_FILE), { recursive: true });
3061
+ return writeEnvUrl(GLOBAL_ENV_FILE, url);
3062
+ }
3063
+
3064
+ /** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
3065
+ async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3048
3066
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3049
3067
  await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
3050
3068
  await chmod(envPath, 0o600);
3051
3069
  return envPath;
3052
3070
  }
3053
3071
 
3054
- async function connect(url: string): Promise<void> {
3072
+ /**
3073
+ * What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
3074
+ * nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
3075
+ */
3076
+ export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GLOBAL_ENV_FILE): string | null {
3077
+ if (inherited) return "your shell's RECHROME_URL";
3078
+ const globalReal = (() => { try { return realpathSync(globalFile); } catch { return globalFile; } })();
3079
+ for (let dir = cwd; ; dir = dirname(dir)) {
3080
+ for (const path of [join(dir, ".rechrome", ".env.local"), join(dir, ".env.local")]) {
3081
+ if (path === globalFile || path === globalReal) return null;
3082
+ const text = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
3083
+ if (text.split("\n").some(line => line.trimStart().startsWith(`${ENV_KEY}=`))) return path;
3084
+ }
3085
+ if (dirname(dir) === dir) return null;
3086
+ }
3087
+ }
3088
+
3089
+ async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
3090
+ if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
3055
3091
  const parsed = parseUrl(url);
3056
3092
  if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
3057
3093
  const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
@@ -3061,8 +3097,24 @@ async function connect(url: string): Promise<void> {
3061
3097
  const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
3062
3098
  if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
3063
3099
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
3064
- const saved = await saveProjectUrl(url);
3065
- console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
3100
+ // Where to save: the flag, else ask in a terminal (this project by default), else this project.
3101
+ let global = !!opts.global;
3102
+ if (!opts.global && !opts.project && isInteractive()) {
3103
+ const { dataDir } = await getClientIdentity();
3104
+ const choice = await promptChoice("Use this link for:", [
3105
+ { label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
3106
+ { label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
3107
+ ], 0);
3108
+ if (!choice) throw new Error("Cancelled; nothing saved.");
3109
+ global = choice === "global";
3110
+ }
3111
+ // A machine running its own daemon would stop reaching its own Chrome from every project without a link.
3112
+ if (global && !(await isLocalDaemon(url)) && (await readListeners().catch(() => null))?.listeners.length)
3113
+ console.error(`[rech] warning: this machine runs its own rech daemon. Projects without their own RECHROME_URL will now use ${serviceUrl(url)}, not this machine's Chrome. Undo: remove ${ENV_KEY} from ${GLOBAL_ENV_FILE}`);
3114
+ const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
3115
+ console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL ${global ? "for every project on this machine" : "for this project"}: ${saved}`);
3116
+ const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
3117
+ if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
3066
3118
  if (Array.isArray(body.profiles) && body.profiles.length > 1) {
3067
3119
  console.log(`This link shares ${body.profiles.length} profiles:`);
3068
3120
  for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
@@ -3491,6 +3543,7 @@ async function status(): Promise<void> {
3491
3543
  console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
3492
3544
  : answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
3493
3545
  : `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
3546
+ if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
3494
3547
  if (pingResponse?.status === 401)
3495
3548
  console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
3496
3549
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
@@ -3533,7 +3586,7 @@ export type RechHandlers = {
3533
3586
  printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
3534
3587
  urlList(): Promise<void>;
3535
3588
  shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
3536
- connect(url: string): Promise<void>;
3589
+ connect(url: string, opts?: { global?: boolean; project?: boolean }): Promise<void>;
3537
3590
  listenerPort(name?: string): Promise<void>;
3538
3591
  allowListener(name: string, profiles: string[]): Promise<void>;
3539
3592
  denyListener(name: string, profiles: string[]): Promise<void>;
@@ -3628,10 +3681,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
3628
3681
  }
3629
3682
  return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
3630
3683
  })
3631
- .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
3684
+ .command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
3632
3685
  .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
3633
- .example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", ""),
3634
- a => handlers.connect(a.url))
3686
+ .option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
3687
+ .option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
3688
+ .conflicts("project", "global")
3689
+ .example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", "")
3690
+ .example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
3691
+ a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
3635
3692
  .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
3636
3693
  .command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
3637
3694
  .command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y