rechrome 1.34.0 → 1.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/rechrome.js +119 -22
- package/rechrome.ts +119 -22
package/package.json
CHANGED
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
|
-
|
|
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
|
};
|
|
@@ -1501,11 +1509,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1501
1509
|
// A one-profile link names its profile, so the other machine needs no --profile.
|
|
1502
1510
|
const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
|
|
1503
1511
|
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1504
|
-
|
|
1505
|
-
for (let i = 0; i < 20 && !ready; i++) {
|
|
1506
|
-
ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
|
|
1507
|
-
if (!ready) await Bun.sleep(250);
|
|
1508
|
-
}
|
|
1512
|
+
const ready = await waitForListener(listener);
|
|
1509
1513
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1510
1514
|
if (opts.tailscaleServe && !listener.publicUrl) {
|
|
1511
1515
|
const publicUrl = await exposeWithTailscaleServe(listener);
|
|
@@ -1562,6 +1566,32 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
|
|
|
1562
1566
|
return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
|
|
1563
1567
|
}
|
|
1564
1568
|
|
|
1569
|
+
/** The daemon reloads listeners.json about every second: wait (up to ~5s) until this listener answers. */
|
|
1570
|
+
async function waitForListener(listener: Listener): Promise<boolean> {
|
|
1571
|
+
const url = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
|
|
1572
|
+
for (let i = 0; i < 20; i++) {
|
|
1573
|
+
if (await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false)) return true;
|
|
1574
|
+
await Bun.sleep(250);
|
|
1575
|
+
}
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Ask how other machines will reach a link: "tailscale-serve", a detected network address
|
|
1581
|
+
* (plain HTTP), "127.0.0.1" for the user's own proxy, or `extra` choices; null when cancelled.
|
|
1582
|
+
*/
|
|
1583
|
+
async function askReachability(question: string, extra: { label: string; value: string }[] = []): Promise<string | null> {
|
|
1584
|
+
const serve = !!tailscaleBinary();
|
|
1585
|
+
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1586
|
+
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1587
|
+
return promptChoice(question, [
|
|
1588
|
+
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1589
|
+
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1590
|
+
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1591
|
+
...extra,
|
|
1592
|
+
], 0);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1565
1595
|
/**
|
|
1566
1596
|
* Give one profile its own link. In a terminal, ask how the other machines will reach it
|
|
1567
1597
|
* (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
|
|
@@ -1572,14 +1602,7 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
|
|
|
1572
1602
|
const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
|
|
1573
1603
|
let host = "127.0.0.1", tailscaleServe = false;
|
|
1574
1604
|
if (isInteractive()) {
|
|
1575
|
-
const
|
|
1576
|
-
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1577
|
-
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1578
|
-
const how = await promptChoice(`How will your other machines reach it?`, [
|
|
1579
|
-
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1580
|
-
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1581
|
-
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1582
|
-
], 0);
|
|
1605
|
+
const how = await askReachability(`How will your other machines reach it?`);
|
|
1583
1606
|
if (!how) throw new Error("Cancelled; nothing shared.");
|
|
1584
1607
|
tailscaleServe = how === "tailscale-serve";
|
|
1585
1608
|
host = tailscaleServe ? "127.0.0.1" : how;
|
|
@@ -1718,6 +1741,31 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1718
1741
|
const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
|
|
1719
1742
|
if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
|
|
1720
1743
|
}
|
|
1744
|
+
// Still only reachable from this machine (no proxy recorded or detected): a link like that is
|
|
1745
|
+
// useless to hand out, so ask how other machines reach it, or say how to expose it.
|
|
1746
|
+
if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && !opts.local) {
|
|
1747
|
+
const steps = () => { for (const line of listenerNextSteps(pending, profile)) console.error(line); };
|
|
1748
|
+
if (!interactive) {
|
|
1749
|
+
console.error(`[rech] this link only works on this machine: listener "${pending.name}" is on ${listenerAddress(pending)} and no proxy is recorded.`);
|
|
1750
|
+
steps();
|
|
1751
|
+
} else {
|
|
1752
|
+
const KEEP = "\0keep";
|
|
1753
|
+
const how = await askReachability(`Link "${pending.name}" only works on this machine (${listenerAddress(pending)}). How will your other machines reach it?`,
|
|
1754
|
+
[{ label: "Nothing: keep it on this machine", value: KEEP }]);
|
|
1755
|
+
if (!how) throw cancelled();
|
|
1756
|
+
if (how === "tailscale-serve") {
|
|
1757
|
+
const routeUrl = await exposeWithTailscaleServe(pending);
|
|
1758
|
+
if (routeUrl) { pending.publicUrl = routeUrl; await writeListeners(config); }
|
|
1759
|
+
} else if (how === "127.0.0.1") steps();
|
|
1760
|
+
else if (how !== KEEP) {
|
|
1761
|
+
// Same key and prefix, now bound on that network; links already handed out change host.
|
|
1762
|
+
pending.host = how;
|
|
1763
|
+
await writeListeners(config);
|
|
1764
|
+
if (!(await waitForListener(pending))) console.error(`[rech] warning: listener "${pending.name}" is not answering on ${listenerAddress(pending)} yet; check the daemon with rech status.`);
|
|
1765
|
+
uri = profileConnectionUri(profile, undefined, config.listeners, pending.name);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1721
1769
|
const publicUrl = pending?.publicUrl;
|
|
1722
1770
|
if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
|
|
1723
1771
|
console.log(uri);
|
|
@@ -3044,14 +3092,42 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
3044
3092
|
mkdirSync(dataDir, { recursive: true });
|
|
3045
3093
|
const gitignore = join(dataDir, ".gitignore");
|
|
3046
3094
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
3047
|
-
|
|
3095
|
+
return writeEnvUrl(join(dataDir, ".env.local"), url);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
/** Write RECHROME_URL to ~/.rechrome/.env.local: the default for every project without its own. */
|
|
3099
|
+
async function saveGlobalUrl(url: string): Promise<string> {
|
|
3100
|
+
mkdirSync(dirname(GLOBAL_ENV_FILE), { recursive: true });
|
|
3101
|
+
return writeEnvUrl(GLOBAL_ENV_FILE, url);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
/** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
|
|
3105
|
+
async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
3048
3106
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3049
3107
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
3050
3108
|
await chmod(envPath, 0o600);
|
|
3051
3109
|
return envPath;
|
|
3052
3110
|
}
|
|
3053
3111
|
|
|
3054
|
-
|
|
3112
|
+
/**
|
|
3113
|
+
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3114
|
+
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
3115
|
+
*/
|
|
3116
|
+
export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GLOBAL_ENV_FILE): string | null {
|
|
3117
|
+
if (inherited) return "your shell's RECHROME_URL";
|
|
3118
|
+
const globalReal = (() => { try { return realpathSync(globalFile); } catch { return globalFile; } })();
|
|
3119
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
3120
|
+
for (const path of [join(dir, ".rechrome", ".env.local"), join(dir, ".env.local")]) {
|
|
3121
|
+
if (path === globalFile || path === globalReal) return null;
|
|
3122
|
+
const text = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
|
|
3123
|
+
if (text.split("\n").some(line => line.trimStart().startsWith(`${ENV_KEY}=`))) return path;
|
|
3124
|
+
}
|
|
3125
|
+
if (dirname(dir) === dir) return null;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
|
|
3130
|
+
if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
|
|
3055
3131
|
const parsed = parseUrl(url);
|
|
3056
3132
|
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
3057
3133
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
@@ -3061,8 +3137,24 @@ async function connect(url: string): Promise<void> {
|
|
|
3061
3137
|
const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
|
|
3062
3138
|
if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
|
|
3063
3139
|
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
3064
|
-
|
|
3065
|
-
|
|
3140
|
+
// Where to save: the flag, else ask in a terminal (this project by default), else this project.
|
|
3141
|
+
let global = !!opts.global;
|
|
3142
|
+
if (!opts.global && !opts.project && isInteractive()) {
|
|
3143
|
+
const { dataDir } = await getClientIdentity();
|
|
3144
|
+
const choice = await promptChoice("Use this link for:", [
|
|
3145
|
+
{ label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
|
|
3146
|
+
{ label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
|
|
3147
|
+
], 0);
|
|
3148
|
+
if (!choice) throw new Error("Cancelled; nothing saved.");
|
|
3149
|
+
global = choice === "global";
|
|
3150
|
+
}
|
|
3151
|
+
// A machine running its own daemon would stop reaching its own Chrome from every project without a link.
|
|
3152
|
+
if (global && !(await isLocalDaemon(url)) && (await readListeners().catch(() => null))?.listeners.length)
|
|
3153
|
+
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}`);
|
|
3154
|
+
const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
|
|
3155
|
+
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}`);
|
|
3156
|
+
const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
|
|
3157
|
+
if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
|
|
3066
3158
|
if (Array.isArray(body.profiles) && body.profiles.length > 1) {
|
|
3067
3159
|
console.log(`This link shares ${body.profiles.length} profiles:`);
|
|
3068
3160
|
for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
|
|
@@ -3491,6 +3583,7 @@ async function status(): Promise<void> {
|
|
|
3491
3583
|
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
3492
3584
|
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
3493
3585
|
: `✗ ${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>'\``}`);
|
|
3586
|
+
if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
|
|
3494
3587
|
if (pingResponse?.status === 401)
|
|
3495
3588
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
3496
3589
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
@@ -3533,7 +3626,7 @@ export type RechHandlers = {
|
|
|
3533
3626
|
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
|
|
3534
3627
|
urlList(): Promise<void>;
|
|
3535
3628
|
shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
|
|
3536
|
-
connect(url: string): Promise<void>;
|
|
3629
|
+
connect(url: string, opts?: { global?: boolean; project?: boolean }): Promise<void>;
|
|
3537
3630
|
listenerPort(name?: string): Promise<void>;
|
|
3538
3631
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
3539
3632
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
@@ -3628,10 +3721,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
3628
3721
|
}
|
|
3629
3722
|
return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
|
|
3630
3723
|
})
|
|
3631
|
-
.command("connect <url>", "Use a URL from another machine
|
|
3724
|
+
.command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
|
|
3632
3725
|
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
|
|
3633
|
-
.
|
|
3634
|
-
|
|
3726
|
+
.option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
|
|
3727
|
+
.option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
|
|
3728
|
+
.conflicts("project", "global")
|
|
3729
|
+
.example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", "")
|
|
3730
|
+
.example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
|
|
3731
|
+
a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
|
|
3635
3732
|
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
3636
3733
|
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
3637
3734
|
.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
|
-
|
|
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
|
};
|
|
@@ -1501,11 +1509,7 @@ async function shareSet(snapshot: string[], opts: { listener?: string; local?: b
|
|
|
1501
1509
|
// A one-profile link names its profile, so the other machine needs no --profile.
|
|
1502
1510
|
const only = snapshot.length === 1 ? `?profile=${encodeURIComponent(snapshot[0])}` : "";
|
|
1503
1511
|
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
|
|
1504
|
-
|
|
1505
|
-
for (let i = 0; i < 20 && !ready; i++) {
|
|
1506
|
-
ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
|
|
1507
|
-
if (!ready) await Bun.sleep(250);
|
|
1508
|
-
}
|
|
1512
|
+
const ready = await waitForListener(listener);
|
|
1509
1513
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1510
1514
|
if (opts.tailscaleServe && !listener.publicUrl) {
|
|
1511
1515
|
const publicUrl = await exposeWithTailscaleServe(listener);
|
|
@@ -1562,6 +1566,32 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
|
|
|
1562
1566
|
return (await detectTailscaleServe(listener.port, prefix)).routeUrl;
|
|
1563
1567
|
}
|
|
1564
1568
|
|
|
1569
|
+
/** The daemon reloads listeners.json about every second: wait (up to ~5s) until this listener answers. */
|
|
1570
|
+
async function waitForListener(listener: Listener): Promise<boolean> {
|
|
1571
|
+
const url = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
|
|
1572
|
+
for (let i = 0; i < 20; i++) {
|
|
1573
|
+
if (await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false)) return true;
|
|
1574
|
+
await Bun.sleep(250);
|
|
1575
|
+
}
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Ask how other machines will reach a link: "tailscale-serve", a detected network address
|
|
1581
|
+
* (plain HTTP), "127.0.0.1" for the user's own proxy, or `extra` choices; null when cancelled.
|
|
1582
|
+
*/
|
|
1583
|
+
async function askReachability(question: string, extra: { label: string; value: string }[] = []): Promise<string | null> {
|
|
1584
|
+
const serve = !!tailscaleBinary();
|
|
1585
|
+
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1586
|
+
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1587
|
+
return promptChoice(question, [
|
|
1588
|
+
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1589
|
+
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1590
|
+
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1591
|
+
...extra,
|
|
1592
|
+
], 0);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1565
1595
|
/**
|
|
1566
1596
|
* Give one profile its own link. In a terminal, ask how the other machines will reach it
|
|
1567
1597
|
* (Tailscale Serve, a detected network, or the user's own proxy); without one, create a
|
|
@@ -1572,14 +1602,7 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
|
|
|
1572
1602
|
const name = uniqueShareName(profileSlug(profile), (await requireListeners()).listeners, served);
|
|
1573
1603
|
let host = "127.0.0.1", tailscaleServe = false;
|
|
1574
1604
|
if (isInteractive()) {
|
|
1575
|
-
const
|
|
1576
|
-
// With Tailscale Serve on offer, a plain-HTTP Tailscale IP would only be a worse duplicate.
|
|
1577
|
-
const networks = (await detectListenChoices()).filter(c => c.kind !== "local" && !(serve && c.kind === "tailscale"));
|
|
1578
|
-
const how = await promptChoice(`How will your other machines reach it?`, [
|
|
1579
|
-
...(serve ? [{ label: "Tailscale: HTTPS, only your tailnet", value: "tailscale-serve" }] : []),
|
|
1580
|
-
...networks.map(c => ({ label: `${c.label.split(" — ")[0]} network: http://${c.address}, plain HTTP`, value: c.address })),
|
|
1581
|
-
{ label: "My own reverse proxy", value: "127.0.0.1" },
|
|
1582
|
-
], 0);
|
|
1605
|
+
const how = await askReachability(`How will your other machines reach it?`);
|
|
1583
1606
|
if (!how) throw new Error("Cancelled; nothing shared.");
|
|
1584
1607
|
tailscaleServe = how === "tailscale-serve";
|
|
1585
1608
|
host = tailscaleServe ? "127.0.0.1" : how;
|
|
@@ -1718,6 +1741,31 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1718
1741
|
const serve = await detectTailscaleServe(pending.port, normalizePrefix(pending.prefix));
|
|
1719
1742
|
if (serve.routeUrl && !serve.funnel) { pending.publicUrl = serve.routeUrl; await writeListeners(config); }
|
|
1720
1743
|
}
|
|
1744
|
+
// Still only reachable from this machine (no proxy recorded or detected): a link like that is
|
|
1745
|
+
// useless to hand out, so ask how other machines reach it, or say how to expose it.
|
|
1746
|
+
if (config && pending && pending.profiles !== "*" && !pending.publicUrl && isLoopback(pending.host) && !opts.local) {
|
|
1747
|
+
const steps = () => { for (const line of listenerNextSteps(pending, profile)) console.error(line); };
|
|
1748
|
+
if (!interactive) {
|
|
1749
|
+
console.error(`[rech] this link only works on this machine: listener "${pending.name}" is on ${listenerAddress(pending)} and no proxy is recorded.`);
|
|
1750
|
+
steps();
|
|
1751
|
+
} else {
|
|
1752
|
+
const KEEP = "\0keep";
|
|
1753
|
+
const how = await askReachability(`Link "${pending.name}" only works on this machine (${listenerAddress(pending)}). How will your other machines reach it?`,
|
|
1754
|
+
[{ label: "Nothing: keep it on this machine", value: KEEP }]);
|
|
1755
|
+
if (!how) throw cancelled();
|
|
1756
|
+
if (how === "tailscale-serve") {
|
|
1757
|
+
const routeUrl = await exposeWithTailscaleServe(pending);
|
|
1758
|
+
if (routeUrl) { pending.publicUrl = routeUrl; await writeListeners(config); }
|
|
1759
|
+
} else if (how === "127.0.0.1") steps();
|
|
1760
|
+
else if (how !== KEEP) {
|
|
1761
|
+
// Same key and prefix, now bound on that network; links already handed out change host.
|
|
1762
|
+
pending.host = how;
|
|
1763
|
+
await writeListeners(config);
|
|
1764
|
+
if (!(await waitForListener(pending))) console.error(`[rech] warning: listener "${pending.name}" is not answering on ${listenerAddress(pending)} yet; check the daemon with rech status.`);
|
|
1765
|
+
uri = profileConnectionUri(profile, undefined, config.listeners, pending.name);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1721
1769
|
const publicUrl = pending?.publicUrl;
|
|
1722
1770
|
if (publicUrl && !opts.local) uri = rebaseConnectionUrl(publicUrl, uri);
|
|
1723
1771
|
console.log(uri);
|
|
@@ -3044,14 +3092,42 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
3044
3092
|
mkdirSync(dataDir, { recursive: true });
|
|
3045
3093
|
const gitignore = join(dataDir, ".gitignore");
|
|
3046
3094
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
3047
|
-
|
|
3095
|
+
return writeEnvUrl(join(dataDir, ".env.local"), url);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
/** Write RECHROME_URL to ~/.rechrome/.env.local: the default for every project without its own. */
|
|
3099
|
+
async function saveGlobalUrl(url: string): Promise<string> {
|
|
3100
|
+
mkdirSync(dirname(GLOBAL_ENV_FILE), { recursive: true });
|
|
3101
|
+
return writeEnvUrl(GLOBAL_ENV_FILE, url);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
/** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
|
|
3105
|
+
async function writeEnvUrl(envPath: string, url: string): Promise<string> {
|
|
3048
3106
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
3049
3107
|
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
3050
3108
|
await chmod(envPath, 0o600);
|
|
3051
3109
|
return envPath;
|
|
3052
3110
|
}
|
|
3053
3111
|
|
|
3054
|
-
|
|
3112
|
+
/**
|
|
3113
|
+
* What would shadow the machine-wide RECHROME_URL from `cwd`: the shell's own RECHROME_URL, or the
|
|
3114
|
+
* nearest project env file that sets one (searched the way loadNearestEnv walks). Null when none does.
|
|
3115
|
+
*/
|
|
3116
|
+
export function nearerUrlSource(cwd: string, inherited: boolean, globalFile = GLOBAL_ENV_FILE): string | null {
|
|
3117
|
+
if (inherited) return "your shell's RECHROME_URL";
|
|
3118
|
+
const globalReal = (() => { try { return realpathSync(globalFile); } catch { return globalFile; } })();
|
|
3119
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
3120
|
+
for (const path of [join(dir, ".rechrome", ".env.local"), join(dir, ".env.local")]) {
|
|
3121
|
+
if (path === globalFile || path === globalReal) return null;
|
|
3122
|
+
const text = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
|
|
3123
|
+
if (text.split("\n").some(line => line.trimStart().startsWith(`${ENV_KEY}=`))) return path;
|
|
3124
|
+
}
|
|
3125
|
+
if (dirname(dir) === dir) return null;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
async function connect(url: string, opts: { global?: boolean; project?: boolean } = {}): Promise<void> {
|
|
3130
|
+
if (opts.global && opts.project) throw new Error("Pass --global or --project, not both.");
|
|
3055
3131
|
const parsed = parseUrl(url);
|
|
3056
3132
|
if (!parsed.key) throw new Error("That URL has no key (#key=…). Ask the host for the full URL from: rech share <profile>");
|
|
3057
3133
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(5000) })
|
|
@@ -3061,8 +3137,24 @@ async function connect(url: string): Promise<void> {
|
|
|
3061
3137
|
const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
|
|
3062
3138
|
if (parsed.profileDirectory && Array.isArray(body.profiles) && !body.profiles.includes(parsed.profileDirectory))
|
|
3063
3139
|
throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
|
|
3064
|
-
|
|
3065
|
-
|
|
3140
|
+
// Where to save: the flag, else ask in a terminal (this project by default), else this project.
|
|
3141
|
+
let global = !!opts.global;
|
|
3142
|
+
if (!opts.global && !opts.project && isInteractive()) {
|
|
3143
|
+
const { dataDir } = await getClientIdentity();
|
|
3144
|
+
const choice = await promptChoice("Use this link for:", [
|
|
3145
|
+
{ label: `This project only (${join(dataDir, ".env.local")})`, value: "project" },
|
|
3146
|
+
{ label: `Every project on this machine without its own link (${GLOBAL_ENV_FILE})`, value: "global" },
|
|
3147
|
+
], 0);
|
|
3148
|
+
if (!choice) throw new Error("Cancelled; nothing saved.");
|
|
3149
|
+
global = choice === "global";
|
|
3150
|
+
}
|
|
3151
|
+
// A machine running its own daemon would stop reaching its own Chrome from every project without a link.
|
|
3152
|
+
if (global && !(await isLocalDaemon(url)) && (await readListeners().catch(() => null))?.listeners.length)
|
|
3153
|
+
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}`);
|
|
3154
|
+
const saved = global ? await saveGlobalUrl(url) : await saveProjectUrl(url);
|
|
3155
|
+
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}`);
|
|
3156
|
+
const shadow = global ? nearerUrlSource(process.cwd(), inheritedEnvKeys.has(ENV_KEY)) : null;
|
|
3157
|
+
if (shadow) console.log(`Note: here, ${shadow} takes precedence over it. Remove that, or run rech connect --project to update it.`);
|
|
3066
3158
|
if (Array.isArray(body.profiles) && body.profiles.length > 1) {
|
|
3067
3159
|
console.log(`This link shares ${body.profiles.length} profiles:`);
|
|
3068
3160
|
for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
|
|
@@ -3491,6 +3583,7 @@ async function status(): Promise<void> {
|
|
|
3491
3583
|
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
3492
3584
|
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
3493
3585
|
: `✗ ${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>'\``}`);
|
|
3586
|
+
if (rechromeUrlSource) console.log(`config: ${rechromeUrlSource === "environment" ? "RECHROME_URL from the environment" : rechromeUrlSource}${rechromeUrlSource === GLOBAL_ENV_FILE ? " (machine-wide)" : ""}`);
|
|
3494
3587
|
if (pingResponse?.status === 401)
|
|
3495
3588
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
3496
3589
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
@@ -3533,7 +3626,7 @@ export type RechHandlers = {
|
|
|
3533
3626
|
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
|
|
3534
3627
|
urlList(): Promise<void>;
|
|
3535
3628
|
shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
|
|
3536
|
-
connect(url: string): Promise<void>;
|
|
3629
|
+
connect(url: string, opts?: { global?: boolean; project?: boolean }): Promise<void>;
|
|
3537
3630
|
listenerPort(name?: string): Promise<void>;
|
|
3538
3631
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
3539
3632
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
@@ -3628,10 +3721,14 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
3628
3721
|
}
|
|
3629
3722
|
return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
|
|
3630
3723
|
})
|
|
3631
|
-
.command("connect <url>", "Use a URL from another machine
|
|
3724
|
+
.command("connect <url>", "Use a URL from another machine (checks it first; asks: this project or every project)", y => y
|
|
3632
3725
|
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
|
|
3633
|
-
.
|
|
3634
|
-
|
|
3726
|
+
.option("project", { type: "boolean", describe: "Save it for this project only (.rechrome/.env.local); the default without a terminal" })
|
|
3727
|
+
.option("global", { type: "boolean", describe: "Save it for every project on this machine without its own link (~/.rechrome/.env.local)" })
|
|
3728
|
+
.conflicts("project", "global")
|
|
3729
|
+
.example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", "")
|
|
3730
|
+
.example("rech connect --global '<url>'", "An agent machine: one link for every folder, /tmp included"),
|
|
3731
|
+
a => a.global || a.project ? handlers.connect(a.url, { global: a.global, project: a.project }) : handlers.connect(a.url))
|
|
3635
3732
|
.command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
|
|
3636
3733
|
.command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
|
|
3637
3734
|
.command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
|