rechrome 1.30.0 → 1.31.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/README.md +12 -0
- package/listeners.js +11 -0
- package/listeners.ts +24 -0
- package/package.json +1 -1
- package/rechrome.js +139 -17
- package/rechrome.ts +139 -17
package/README.md
CHANGED
|
@@ -106,6 +106,11 @@ rech open https://example.com
|
|
|
106
106
|
`.rechrome/.env.local`. Remote listeners allow navigation, tabs, snapshots, screenshots and basic
|
|
107
107
|
interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
|
|
108
108
|
|
|
109
|
+
**Share several profiles in one link.** `rech share work@example.com taku2 symval-dev` puts
|
|
110
|
+
exactly those profiles on one listener (its own key; running it again reuses it). Add
|
|
111
|
+
`--listener <name>` to use a listener that already has a proxy route. Its links then reach
|
|
112
|
+
exactly this list, and `rech listener rotate-key <name>` issues a fresh key if old links shouldn't.
|
|
113
|
+
|
|
109
114
|
**Share every profile at once.** `rech share --all` gives one link for all Chrome profiles
|
|
110
115
|
registered on the host. It uses its own listener (`share-all`) and key, so one-profile links you
|
|
111
116
|
already handed out never gain access to the others. It is a snapshot: after registering another
|
|
@@ -130,8 +135,15 @@ rech listener allow share teammate@example.com
|
|
|
130
135
|
rech listener deny share teammate@example.com
|
|
131
136
|
rech listener rotate-key share # revoke: every URL for this listener stops working
|
|
132
137
|
rech listener remove share
|
|
138
|
+
rech profile rm old-test-profile # unregister a profile everywhere (asks first)
|
|
133
139
|
```
|
|
134
140
|
|
|
141
|
+
`rech profile rm <name>` shows its plan and asks before changing anything (`--yes` skips the
|
|
142
|
+
question). It removes every alias of the profile from rech's registry and from every listener,
|
|
143
|
+
dropping a listener left with no profiles. A managed test profile's own folder goes to the Trash,
|
|
144
|
+
and its running window is closed only with your consent (`--close` when not in a terminal). A real
|
|
145
|
+
Chrome profile's data is never touched: it is only unregistered from rech.
|
|
146
|
+
|
|
135
147
|
Changes apply immediately; the daemon reloads its listeners without restarting Chrome.
|
|
136
148
|
A link's key opens **every** profile its listener allows (`?profile=` only picks the default),
|
|
137
149
|
so give a profile its own listener when a link should reach only that one. `rech share ls`
|
package/listeners.js
CHANGED
|
@@ -178,6 +178,16 @@ function resolveAllowedProfile(selector, allowed, registry, chromeNames = {}) {
|
|
|
178
178
|
}
|
|
179
179
|
throw new Error(`"${selector}" is not shared by this link. It shares: ${list}.`);
|
|
180
180
|
}
|
|
181
|
+
function planProfileRemoval(key, registry, listeners, managedRoot) {
|
|
182
|
+
const entry = registry[key];
|
|
183
|
+
if (!entry)
|
|
184
|
+
throw new Error(`"${key}" is not a registered profile.`);
|
|
185
|
+
const keys = Object.keys(registry).filter((k) => profileIdentity(registry[k]) === profileIdentity(entry));
|
|
186
|
+
const edits = listeners.filter((l) => l.profiles !== "*" && l.profiles.some((p) => keys.includes(p))).map((l) => ({ name: l.name, remove: l.profiles.filter((p) => keys.includes(p)), drop: l.profiles.every((p) => keys.includes(p)) }));
|
|
187
|
+
const root = managedRoot.replace(/[\\/]+$/, "");
|
|
188
|
+
const managed = !!entry.loadExtension && !!entry.userDataDir && (entry.userDataDir.startsWith(root + "/") || entry.userDataDir.startsWith(root + "\\"));
|
|
189
|
+
return { keys, listeners: edits, ...managed ? { dataDir: entry.userDataDir } : {} };
|
|
190
|
+
}
|
|
181
191
|
export {
|
|
182
192
|
LISTENERS_FILE,
|
|
183
193
|
allowProfiles,
|
|
@@ -189,6 +199,7 @@ export {
|
|
|
189
199
|
listenerAddress,
|
|
190
200
|
normalizePrefix,
|
|
191
201
|
normalizePublicUrl,
|
|
202
|
+
planProfileRemoval,
|
|
192
203
|
profileOutputPrefix,
|
|
193
204
|
readListeners,
|
|
194
205
|
resolveAllowedProfile,
|
package/listeners.ts
CHANGED
|
@@ -187,3 +187,27 @@ export function resolveAllowedProfile(
|
|
|
187
187
|
}
|
|
188
188
|
throw new Error(`"${selector}" is not shared by this link. It shares: ${list}.`);
|
|
189
189
|
}
|
|
190
|
+
|
|
191
|
+
export type ProfileRemovalPlan = {
|
|
192
|
+
keys: string[]; // every registry alias of the profile
|
|
193
|
+
listeners: { name: string; remove: string[]; drop: boolean }[]; // allowlist edits; drop = no profiles left
|
|
194
|
+
dataDir?: string; // a managed profile's own folder (never real Chrome data)
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* What `rech profile rm` would change for one registry key: all aliases of that Chrome profile,
|
|
199
|
+
* their listener entries (dropping a listener left empty), and, for a managed test profile
|
|
200
|
+
* whose data lives under `managedRoot`, its folder. Real Chrome profiles only get unregistered.
|
|
201
|
+
*/
|
|
202
|
+
export function planProfileRemoval(
|
|
203
|
+
key: string, registry: Record<string, RegisteredProfile & { loadExtension?: string }>, listeners: Listener[], managedRoot: string,
|
|
204
|
+
): ProfileRemovalPlan {
|
|
205
|
+
const entry = registry[key];
|
|
206
|
+
if (!entry) throw new Error(`"${key}" is not a registered profile.`);
|
|
207
|
+
const keys = Object.keys(registry).filter(k => profileIdentity(registry[k]) === profileIdentity(entry));
|
|
208
|
+
const edits = listeners.filter(l => l.profiles !== "*" && l.profiles.some(p => keys.includes(p)))
|
|
209
|
+
.map(l => ({ name: l.name, remove: (l.profiles as string[]).filter(p => keys.includes(p)), drop: (l.profiles as string[]).every(p => keys.includes(p)) }));
|
|
210
|
+
const root = managedRoot.replace(/[\\/]+$/, "");
|
|
211
|
+
const managed = !!entry.loadExtension && !!entry.userDataDir && (entry.userDataDir.startsWith(root + "/") || entry.userDataDir.startsWith(root + "\\"));
|
|
212
|
+
return { keys, listeners: edits, ...(managed ? { dataDir: entry.userDataDir } : {}) };
|
|
213
|
+
}
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, type Listener } from "./listeners.js";
|
|
2
|
+
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.js";
|
|
3
3
|
|
|
4
4
|
import { file } from "bun";
|
|
5
5
|
import yargs from "yargs";
|
|
6
6
|
import { readExtensionTokenFromProfile } from "./extension-token.js";
|
|
7
|
-
import { randomBytes } from "crypto";
|
|
7
|
+
import { createHash, randomBytes } from "crypto";
|
|
8
8
|
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
|
|
9
9
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
10
10
|
import { isIPv4 } from "net";
|
|
@@ -893,6 +893,78 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
|
|
|
893
893
|
return rows;
|
|
894
894
|
}
|
|
895
895
|
|
|
896
|
+
/** PIDs of browser processes (not helpers) running on a Chrome user-data dir. POSIX only. */
|
|
897
|
+
function browsersUsing(userDataDir: string): number[] {
|
|
898
|
+
if (process.platform === "win32") return [];
|
|
899
|
+
const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
|
|
900
|
+
return ps.split("\n").filter(line => line.includes(`--user-data-dir=${userDataDir}`) && !line.includes("--type="))
|
|
901
|
+
.map(line => Number(line.trim().split(/\s+/)[0])).filter(Boolean);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
|
|
905
|
+
function moveToTrash(dir: string): string | null {
|
|
906
|
+
const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
|
|
907
|
+
if (!trash) return null;
|
|
908
|
+
mkdirSync(trash, { recursive: true });
|
|
909
|
+
let dest = join(trash, basename(dir));
|
|
910
|
+
if (existsSync(dest)) dest = `${dest} ${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
911
|
+
renameSync(dir, dest);
|
|
912
|
+
return dest;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* `rech profile rm <name>`: unregister a profile everywhere (all its aliases, every listener
|
|
917
|
+
* allowlist, dropping listeners left empty) and move a managed test profile's own folder to the
|
|
918
|
+
* Trash. Real Chrome profile data is never touched. Shows the plan and asks first (--yes skips);
|
|
919
|
+
* a browser still running on a managed folder is closed only with consent (--close).
|
|
920
|
+
*/
|
|
921
|
+
async function removeProfile(selector: string, opts: { yes?: boolean; close?: boolean } = {}): Promise<void> {
|
|
922
|
+
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
923
|
+
const key = (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
924
|
+
const config = await readListeners();
|
|
925
|
+
const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
|
|
926
|
+
const running = plan.dataDir ? browsersUsing(plan.dataDir) : [];
|
|
927
|
+
console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
|
|
928
|
+
for (const edit of plan.listeners) console.log(edit.drop ? ` - remove listener "${edit.name}" (it serves only this profile)` : ` - stop sharing it on listener "${edit.name}"`);
|
|
929
|
+
console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
|
|
930
|
+
if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
|
|
931
|
+
else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
|
|
932
|
+
if (running.length) console.log(` - close its browser window, which is running now (pid ${running.join(", ")})`);
|
|
933
|
+
const interactive = isInteractive();
|
|
934
|
+
if (!opts.yes) {
|
|
935
|
+
if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
|
|
936
|
+
const ok = await promptChoice("Go ahead?", [{ label: "No, keep it", value: false }, { label: "Yes, remove it", value: true }], 0);
|
|
937
|
+
if (!ok) throw new Error("Cancelled; nothing changed.");
|
|
938
|
+
}
|
|
939
|
+
if (running.length) {
|
|
940
|
+
// In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
|
|
941
|
+
if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
|
|
942
|
+
for (const pid of running) process.kill(pid, "SIGTERM");
|
|
943
|
+
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
|
|
944
|
+
if (browsersUsing(plan.dataDir!).length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
|
|
945
|
+
}
|
|
946
|
+
if (config && plan.listeners.length) {
|
|
947
|
+
for (const edit of plan.listeners) {
|
|
948
|
+
if (edit.drop) config.listeners = config.listeners.filter(l => l.name !== edit.name);
|
|
949
|
+
else { const l = config.listeners.find(x => x.name === edit.name)!; l.profiles = (l.profiles as string[]).filter(p => !plan.keys.includes(p)); }
|
|
950
|
+
}
|
|
951
|
+
await writeListeners(config);
|
|
952
|
+
}
|
|
953
|
+
for (const k of plan.keys) delete registry[k];
|
|
954
|
+
await writeTokenRegistry(registry);
|
|
955
|
+
// The migrated profiles.json backup still holds tokens: drop the same keys there.
|
|
956
|
+
const legacy = join(RECH_DIR, "profiles.json");
|
|
957
|
+
if (existsSync(legacy)) {
|
|
958
|
+
try {
|
|
959
|
+
const old = JSON.parse(readFileSync(legacy, "utf8"));
|
|
960
|
+
if (plan.keys.some(k => k in old)) { for (const k of plan.keys) delete old[k]; await writeFile(legacy, JSON.stringify(old, null, 2) + "\n", { mode: 0o600 }); await chmod(legacy, 0o600); }
|
|
961
|
+
} catch { /* unreadable backup: leave it */ }
|
|
962
|
+
}
|
|
963
|
+
let moved: string | null = null;
|
|
964
|
+
if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
|
|
965
|
+
console.log(`Removed "${key}".${moved ? ` Its data folder is in the Trash: ${moved}` : plan.dataDir ? ` Its data folder was left at ${plan.dataDir}; delete it yourself if you like.` : ""}`);
|
|
966
|
+
}
|
|
967
|
+
|
|
896
968
|
/** On a client of a remote host, `rech profile` lists what that host's link shares. */
|
|
897
969
|
async function listRemoteProfiles(url: string): Promise<void> {
|
|
898
970
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
|
|
@@ -1039,17 +1111,47 @@ function freeListenerPort(listeners: Listener[]): number {
|
|
|
1039
1111
|
* already handed out never gain access to the other profiles.
|
|
1040
1112
|
*/
|
|
1041
1113
|
async function shareAll(opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1042
|
-
const config = await requireListeners();
|
|
1043
1114
|
const snapshot = canonicalProfileKeys(await readTokenRegistry());
|
|
1044
1115
|
if (!snapshot.length) throw new Error("No registered profiles to share. Set one up first: rech setup");
|
|
1045
|
-
|
|
1116
|
+
return shareSet(snapshot, { ...opts, defaultName: "share-all", prefix: "/rechrome-all/", again: "rech share --all" });
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** The registry key rech uses for a profile: one per Chrome profile, whatever alias it was named by. */
|
|
1120
|
+
function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): string {
|
|
1121
|
+
const entry = registry[key];
|
|
1122
|
+
return canonicalProfileKeys(registry).find(k => registry[k].profileDir === entry?.profileDir && (registry[k].userDataDir ?? "") === (entry?.userDataDir ?? "")) ?? key;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
|
|
1127
|
+
* sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
|
|
1128
|
+
* it reuses a listener with exactly this set, or creates one with its own key.
|
|
1129
|
+
*/
|
|
1130
|
+
async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1131
|
+
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
1132
|
+
const keys: string[] = [];
|
|
1133
|
+
for (const selector of selectors) {
|
|
1134
|
+
const key = canonicalKeyFor(registry, (await resolveGlobalProfile(registry, cache, selector)).email);
|
|
1135
|
+
if (!keys.includes(key)) keys.push(key);
|
|
1136
|
+
}
|
|
1137
|
+
if (keys.length === 1) return printProfileUri(keys[0], opts.listener, opts);
|
|
1138
|
+
const config = await requireListeners();
|
|
1139
|
+
const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
|
|
1140
|
+
const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
|
|
1141
|
+
return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** 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> {
|
|
1146
|
+
const config = await requireListeners();
|
|
1147
|
+
const name = opts.listener ?? opts.defaultName;
|
|
1046
1148
|
let listener = config.listeners.find(l => l.name === name);
|
|
1047
1149
|
if (listener?.profiles === "*")
|
|
1048
|
-
throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener
|
|
1150
|
+
throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener, or name a scoped one.`);
|
|
1049
1151
|
let changes = "";
|
|
1050
1152
|
if (!listener) {
|
|
1051
1153
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1052
|
-
listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix:
|
|
1154
|
+
listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1053
1155
|
config.listeners.push(listener);
|
|
1054
1156
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1055
1157
|
} else {
|
|
@@ -1060,7 +1162,8 @@ async function shareAll(opts: { listener?: string; local?: boolean; save?: boole
|
|
|
1060
1162
|
}
|
|
1061
1163
|
await writeListeners(config);
|
|
1062
1164
|
console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
|
|
1063
|
-
console.error(`[rech] this is a snapshot: after registering another profile, run
|
|
1165
|
+
if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
|
|
1166
|
+
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}`);
|
|
1064
1167
|
// The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
|
|
1065
1168
|
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
|
|
1066
1169
|
let ready = false;
|
|
@@ -1070,7 +1173,7 @@ async function shareAll(opts: { listener?: string; local?: boolean; save?: boole
|
|
|
1070
1173
|
}
|
|
1071
1174
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1072
1175
|
const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
|
|
1073
|
-
if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined,
|
|
1176
|
+
if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1074
1177
|
console.log(uri);
|
|
1075
1178
|
console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
|
|
1076
1179
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
@@ -1887,13 +1990,16 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
1887
1990
|
await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
1888
1991
|
} else {
|
|
1889
1992
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
1993
|
+
// oxmgr shell-splits the command string (backslashes are escapes, so `C:\Users\…` became
|
|
1994
|
+
// `C:Users…`); pass quoted forward-slash paths — Windows accepts `/`, quotes keep spaces.
|
|
1995
|
+
const q = (path: string) => `"${path.replaceAll("\\", "/")}"`;
|
|
1890
1996
|
startCode = await runPm(mgr, [
|
|
1891
1997
|
"start",
|
|
1892
1998
|
"--name", PM_PROCESS_NAME,
|
|
1893
1999
|
"--restart", "always",
|
|
1894
2000
|
"--cwd", home,
|
|
1895
2001
|
...envArgs,
|
|
1896
|
-
`${bunBin} ${rechScript} serve`,
|
|
2002
|
+
`${q(bunBin)} ${q(rechScript)} serve`,
|
|
1897
2003
|
]);
|
|
1898
2004
|
// Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
|
|
1899
2005
|
// on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
|
|
@@ -2845,8 +2951,10 @@ export type RechHandlers = {
|
|
|
2845
2951
|
addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
|
|
2846
2952
|
removeListener(name: string): Promise<void>;
|
|
2847
2953
|
listProfiles(): Promise<void>;
|
|
2954
|
+
removeProfile(selector: string, opts: { yes?: boolean; close?: boolean }): Promise<void>;
|
|
2848
2955
|
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
|
|
2849
2956
|
urlList(): Promise<void>;
|
|
2957
|
+
shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
|
|
2850
2958
|
connect(url: string): Promise<void>;
|
|
2851
2959
|
listenerPort(name?: string): Promise<void>;
|
|
2852
2960
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
@@ -2909,25 +3017,39 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
2909
3017
|
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2910
3018
|
a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
|
|
2911
3019
|
.command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
|
|
2912
|
-
.command(["profile [name]", "profiles [name]"], "List Chrome profiles
|
|
2913
|
-
.positional("name", { type: "string", describe: "ls/list lists all (the default)" })
|
|
3020
|
+
.command(["profile [name] [target]", "profiles [name] [target]"], "List Chrome profiles; `profile rm <name>` removes one from rech", y => y
|
|
3021
|
+
.positional("name", { type: "string", describe: "ls/list lists all (the default); rm/remove <name> removes a profile" })
|
|
3022
|
+
.positional("target", { type: "string", describe: "The profile to remove (with rm)" })
|
|
3023
|
+
.option("yes", { alias: "y", type: "boolean", describe: "With rm: don't ask for confirmation" })
|
|
3024
|
+
.option("close", { type: "boolean", describe: "With rm: close the profile's running window (managed profiles)" })
|
|
2914
3025
|
.option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
|
|
2915
3026
|
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
|
|
2916
3027
|
a => {
|
|
3028
|
+
if (a.name === "rm" || a.name === "remove") {
|
|
3029
|
+
if (!a.target) throw new Error("Which profile? rech profile rm <name> (see rech profile)");
|
|
3030
|
+
return handlers.removeProfile(a.target, { yes: a.yes, close: a.close });
|
|
3031
|
+
}
|
|
3032
|
+
if (a.target) throw new Error(`Unexpected "${a.target}". To remove a profile: rech profile rm <name>`);
|
|
2917
3033
|
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech share`
|
|
2918
3034
|
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2919
3035
|
throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
|
|
2920
3036
|
})
|
|
2921
3037
|
// Share with and connect from other machines
|
|
2922
|
-
.command("share [
|
|
2923
|
-
.positional("
|
|
3038
|
+
.command("share [profiles..]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
|
|
3039
|
+
.positional("profiles", { type: "string", array: true, describe: "Profile(s): email, name, folder, or a unique part of one. Several = one link for all of them. ls/list lists everything shared" })
|
|
2924
3040
|
.option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
|
|
2925
3041
|
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2926
3042
|
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
|
|
2927
3043
|
.option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
|
|
2928
|
-
a =>
|
|
2929
|
-
|
|
2930
|
-
|
|
3044
|
+
a => {
|
|
3045
|
+
const profiles = (a.profiles ?? []) as string[];
|
|
3046
|
+
if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.urlList();
|
|
3047
|
+
if (profiles.length > 1) {
|
|
3048
|
+
if (a.all) throw new Error("Pass profiles or --all, not both.");
|
|
3049
|
+
return handlers.shareProfiles(profiles, { listener: a.listener, local: a.local, save: a.save });
|
|
3050
|
+
}
|
|
3051
|
+
return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
|
|
3052
|
+
})
|
|
2931
3053
|
.command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
|
|
2932
3054
|
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
|
|
2933
3055
|
.example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
|
|
@@ -2998,7 +3120,7 @@ if (import.meta.main) {
|
|
|
2998
3120
|
const url = process.env[ENV_KEY];
|
|
2999
3121
|
return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
|
|
3000
3122
|
},
|
|
3001
|
-
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
3123
|
+
urlList, shareProfiles, removeProfile, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
3002
3124
|
setup: async (opts) => {
|
|
3003
3125
|
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
3004
3126
|
// Auto-start the tray (best-effort, silent on headless / missing binary).
|
package/rechrome.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, type Listener } from "./listeners.ts";
|
|
2
|
+
import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener } from "./listeners.ts";
|
|
3
3
|
|
|
4
4
|
import { file } from "bun";
|
|
5
5
|
import yargs from "yargs";
|
|
6
6
|
import { readExtensionTokenFromProfile } from "./extension-token.ts";
|
|
7
|
-
import { randomBytes } from "crypto";
|
|
7
|
+
import { createHash, randomBytes } from "crypto";
|
|
8
8
|
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
|
|
9
9
|
import { hostname, homedir, networkInterfaces } from "os";
|
|
10
10
|
import { isIPv4 } from "net";
|
|
@@ -893,6 +893,78 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
|
|
|
893
893
|
return rows;
|
|
894
894
|
}
|
|
895
895
|
|
|
896
|
+
/** PIDs of browser processes (not helpers) running on a Chrome user-data dir. POSIX only. */
|
|
897
|
+
function browsersUsing(userDataDir: string): number[] {
|
|
898
|
+
if (process.platform === "win32") return [];
|
|
899
|
+
const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
|
|
900
|
+
return ps.split("\n").filter(line => line.includes(`--user-data-dir=${userDataDir}`) && !line.includes("--type="))
|
|
901
|
+
.map(line => Number(line.trim().split(/\s+/)[0])).filter(Boolean);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
|
|
905
|
+
function moveToTrash(dir: string): string | null {
|
|
906
|
+
const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
|
|
907
|
+
if (!trash) return null;
|
|
908
|
+
mkdirSync(trash, { recursive: true });
|
|
909
|
+
let dest = join(trash, basename(dir));
|
|
910
|
+
if (existsSync(dest)) dest = `${dest} ${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
911
|
+
renameSync(dir, dest);
|
|
912
|
+
return dest;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* `rech profile rm <name>`: unregister a profile everywhere (all its aliases, every listener
|
|
917
|
+
* allowlist, dropping listeners left empty) and move a managed test profile's own folder to the
|
|
918
|
+
* Trash. Real Chrome profile data is never touched. Shows the plan and asks first (--yes skips);
|
|
919
|
+
* a browser still running on a managed folder is closed only with consent (--close).
|
|
920
|
+
*/
|
|
921
|
+
async function removeProfile(selector: string, opts: { yes?: boolean; close?: boolean } = {}): Promise<void> {
|
|
922
|
+
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
923
|
+
const key = (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
924
|
+
const config = await readListeners();
|
|
925
|
+
const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
|
|
926
|
+
const running = plan.dataDir ? browsersUsing(plan.dataDir) : [];
|
|
927
|
+
console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
|
|
928
|
+
for (const edit of plan.listeners) console.log(edit.drop ? ` - remove listener "${edit.name}" (it serves only this profile)` : ` - stop sharing it on listener "${edit.name}"`);
|
|
929
|
+
console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
|
|
930
|
+
if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
|
|
931
|
+
else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
|
|
932
|
+
if (running.length) console.log(` - close its browser window, which is running now (pid ${running.join(", ")})`);
|
|
933
|
+
const interactive = isInteractive();
|
|
934
|
+
if (!opts.yes) {
|
|
935
|
+
if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
|
|
936
|
+
const ok = await promptChoice("Go ahead?", [{ label: "No, keep it", value: false }, { label: "Yes, remove it", value: true }], 0);
|
|
937
|
+
if (!ok) throw new Error("Cancelled; nothing changed.");
|
|
938
|
+
}
|
|
939
|
+
if (running.length) {
|
|
940
|
+
// In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
|
|
941
|
+
if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
|
|
942
|
+
for (const pid of running) process.kill(pid, "SIGTERM");
|
|
943
|
+
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
|
|
944
|
+
if (browsersUsing(plan.dataDir!).length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
|
|
945
|
+
}
|
|
946
|
+
if (config && plan.listeners.length) {
|
|
947
|
+
for (const edit of plan.listeners) {
|
|
948
|
+
if (edit.drop) config.listeners = config.listeners.filter(l => l.name !== edit.name);
|
|
949
|
+
else { const l = config.listeners.find(x => x.name === edit.name)!; l.profiles = (l.profiles as string[]).filter(p => !plan.keys.includes(p)); }
|
|
950
|
+
}
|
|
951
|
+
await writeListeners(config);
|
|
952
|
+
}
|
|
953
|
+
for (const k of plan.keys) delete registry[k];
|
|
954
|
+
await writeTokenRegistry(registry);
|
|
955
|
+
// The migrated profiles.json backup still holds tokens: drop the same keys there.
|
|
956
|
+
const legacy = join(RECH_DIR, "profiles.json");
|
|
957
|
+
if (existsSync(legacy)) {
|
|
958
|
+
try {
|
|
959
|
+
const old = JSON.parse(readFileSync(legacy, "utf8"));
|
|
960
|
+
if (plan.keys.some(k => k in old)) { for (const k of plan.keys) delete old[k]; await writeFile(legacy, JSON.stringify(old, null, 2) + "\n", { mode: 0o600 }); await chmod(legacy, 0o600); }
|
|
961
|
+
} catch { /* unreadable backup: leave it */ }
|
|
962
|
+
}
|
|
963
|
+
let moved: string | null = null;
|
|
964
|
+
if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
|
|
965
|
+
console.log(`Removed "${key}".${moved ? ` Its data folder is in the Trash: ${moved}` : plan.dataDir ? ` Its data folder was left at ${plan.dataDir}; delete it yourself if you like.` : ""}`);
|
|
966
|
+
}
|
|
967
|
+
|
|
896
968
|
/** On a client of a remote host, `rech profile` lists what that host's link shares. */
|
|
897
969
|
async function listRemoteProfiles(url: string): Promise<void> {
|
|
898
970
|
const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
|
|
@@ -1039,17 +1111,47 @@ function freeListenerPort(listeners: Listener[]): number {
|
|
|
1039
1111
|
* already handed out never gain access to the other profiles.
|
|
1040
1112
|
*/
|
|
1041
1113
|
async function shareAll(opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1042
|
-
const config = await requireListeners();
|
|
1043
1114
|
const snapshot = canonicalProfileKeys(await readTokenRegistry());
|
|
1044
1115
|
if (!snapshot.length) throw new Error("No registered profiles to share. Set one up first: rech setup");
|
|
1045
|
-
|
|
1116
|
+
return shareSet(snapshot, { ...opts, defaultName: "share-all", prefix: "/rechrome-all/", again: "rech share --all" });
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** The registry key rech uses for a profile: one per Chrome profile, whatever alias it was named by. */
|
|
1120
|
+
function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): string {
|
|
1121
|
+
const entry = registry[key];
|
|
1122
|
+
return canonicalProfileKeys(registry).find(k => registry[k].profileDir === entry?.profileDir && (registry[k].userDataDir ?? "") === (entry?.userDataDir ?? "")) ?? key;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
|
|
1127
|
+
* sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
|
|
1128
|
+
* it reuses a listener with exactly this set, or creates one with its own key.
|
|
1129
|
+
*/
|
|
1130
|
+
async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
|
|
1131
|
+
const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
|
|
1132
|
+
const keys: string[] = [];
|
|
1133
|
+
for (const selector of selectors) {
|
|
1134
|
+
const key = canonicalKeyFor(registry, (await resolveGlobalProfile(registry, cache, selector)).email);
|
|
1135
|
+
if (!keys.includes(key)) keys.push(key);
|
|
1136
|
+
}
|
|
1137
|
+
if (keys.length === 1) return printProfileUri(keys[0], opts.listener, opts);
|
|
1138
|
+
const config = await requireListeners();
|
|
1139
|
+
const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
|
|
1140
|
+
const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
|
|
1141
|
+
return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** 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> {
|
|
1146
|
+
const config = await requireListeners();
|
|
1147
|
+
const name = opts.listener ?? opts.defaultName;
|
|
1046
1148
|
let listener = config.listeners.find(l => l.name === name);
|
|
1047
1149
|
if (listener?.profiles === "*")
|
|
1048
|
-
throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener
|
|
1150
|
+
throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener, or name a scoped one.`);
|
|
1049
1151
|
let changes = "";
|
|
1050
1152
|
if (!listener) {
|
|
1051
1153
|
if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
1052
|
-
listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix:
|
|
1154
|
+
listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
|
|
1053
1155
|
config.listeners.push(listener);
|
|
1054
1156
|
changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
|
|
1055
1157
|
} else {
|
|
@@ -1060,7 +1162,8 @@ async function shareAll(opts: { listener?: string; local?: boolean; save?: boole
|
|
|
1060
1162
|
}
|
|
1061
1163
|
await writeListeners(config);
|
|
1062
1164
|
console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
|
|
1063
|
-
console.error(`[rech] this is a snapshot: after registering another profile, run
|
|
1165
|
+
if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
|
|
1166
|
+
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}`);
|
|
1064
1167
|
// The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
|
|
1065
1168
|
const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
|
|
1066
1169
|
let ready = false;
|
|
@@ -1070,7 +1173,7 @@ async function shareAll(opts: { listener?: string; local?: boolean; save?: boole
|
|
|
1070
1173
|
}
|
|
1071
1174
|
if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
|
|
1072
1175
|
const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
|
|
1073
|
-
if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined,
|
|
1176
|
+
if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
|
|
1074
1177
|
console.log(uri);
|
|
1075
1178
|
console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
|
|
1076
1179
|
if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
|
|
@@ -1887,13 +1990,16 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
1887
1990
|
await runPm(mgr, ["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
1888
1991
|
} else {
|
|
1889
1992
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
1993
|
+
// oxmgr shell-splits the command string (backslashes are escapes, so `C:\Users\…` became
|
|
1994
|
+
// `C:Users…`); pass quoted forward-slash paths — Windows accepts `/`, quotes keep spaces.
|
|
1995
|
+
const q = (path: string) => `"${path.replaceAll("\\", "/")}"`;
|
|
1890
1996
|
startCode = await runPm(mgr, [
|
|
1891
1997
|
"start",
|
|
1892
1998
|
"--name", PM_PROCESS_NAME,
|
|
1893
1999
|
"--restart", "always",
|
|
1894
2000
|
"--cwd", home,
|
|
1895
2001
|
...envArgs,
|
|
1896
|
-
`${bunBin} ${rechScript} serve`,
|
|
2002
|
+
`${q(bunBin)} ${q(rechScript)} serve`,
|
|
1897
2003
|
]);
|
|
1898
2004
|
// Boot/login persistence: on Windows the winfix oxmgr wires Task Scheduler,
|
|
1899
2005
|
// on POSIX a systemd --user unit / launchd agent — the equivalent of the pm2
|
|
@@ -2845,8 +2951,10 @@ export type RechHandlers = {
|
|
|
2845
2951
|
addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
|
|
2846
2952
|
removeListener(name: string): Promise<void>;
|
|
2847
2953
|
listProfiles(): Promise<void>;
|
|
2954
|
+
removeProfile(selector: string, opts: { yes?: boolean; close?: boolean }): Promise<void>;
|
|
2848
2955
|
printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
|
|
2849
2956
|
urlList(): Promise<void>;
|
|
2957
|
+
shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
|
|
2850
2958
|
connect(url: string): Promise<void>;
|
|
2851
2959
|
listenerPort(name?: string): Promise<void>;
|
|
2852
2960
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
@@ -2909,25 +3017,39 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
2909
3017
|
.option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
|
|
2910
3018
|
a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
|
|
2911
3019
|
.command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
|
|
2912
|
-
.command(["profile [name]", "profiles [name]"], "List Chrome profiles
|
|
2913
|
-
.positional("name", { type: "string", describe: "ls/list lists all (the default)" })
|
|
3020
|
+
.command(["profile [name] [target]", "profiles [name] [target]"], "List Chrome profiles; `profile rm <name>` removes one from rech", y => y
|
|
3021
|
+
.positional("name", { type: "string", describe: "ls/list lists all (the default); rm/remove <name> removes a profile" })
|
|
3022
|
+
.positional("target", { type: "string", describe: "The profile to remove (with rm)" })
|
|
3023
|
+
.option("yes", { alias: "y", type: "boolean", describe: "With rm: don't ask for confirmation" })
|
|
3024
|
+
.option("close", { type: "boolean", describe: "With rm: close the profile's running window (managed profiles)" })
|
|
2914
3025
|
.option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
|
|
2915
3026
|
.option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
|
|
2916
3027
|
a => {
|
|
3028
|
+
if (a.name === "rm" || a.name === "remove") {
|
|
3029
|
+
if (!a.target) throw new Error("Which profile? rech profile rm <name> (see rech profile)");
|
|
3030
|
+
return handlers.removeProfile(a.target, { yes: a.yes, close: a.close });
|
|
3031
|
+
}
|
|
3032
|
+
if (a.target) throw new Error(`Unexpected "${a.target}". To remove a profile: rech profile rm <name>`);
|
|
2917
3033
|
if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech share`
|
|
2918
3034
|
if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
|
|
2919
3035
|
throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
|
|
2920
3036
|
})
|
|
2921
3037
|
// Share with and connect from other machines
|
|
2922
|
-
.command("share [
|
|
2923
|
-
.positional("
|
|
3038
|
+
.command("share [profiles..]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
|
|
3039
|
+
.positional("profiles", { type: "string", array: true, describe: "Profile(s): email, name, folder, or a unique part of one. Several = one link for all of them. ls/list lists everything shared" })
|
|
2924
3040
|
.option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
|
|
2925
3041
|
.option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
|
|
2926
3042
|
.option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
|
|
2927
3043
|
.option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
|
|
2928
|
-
a =>
|
|
2929
|
-
|
|
2930
|
-
|
|
3044
|
+
a => {
|
|
3045
|
+
const profiles = (a.profiles ?? []) as string[];
|
|
3046
|
+
if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.urlList();
|
|
3047
|
+
if (profiles.length > 1) {
|
|
3048
|
+
if (a.all) throw new Error("Pass profiles or --all, not both.");
|
|
3049
|
+
return handlers.shareProfiles(profiles, { listener: a.listener, local: a.local, save: a.save });
|
|
3050
|
+
}
|
|
3051
|
+
return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
|
|
3052
|
+
})
|
|
2931
3053
|
.command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
|
|
2932
3054
|
.positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
|
|
2933
3055
|
.example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", ""),
|
|
@@ -2998,7 +3120,7 @@ if (import.meta.main) {
|
|
|
2998
3120
|
const url = process.env[ENV_KEY];
|
|
2999
3121
|
return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
|
|
3000
3122
|
},
|
|
3001
|
-
urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
3123
|
+
urlList, shareProfiles, removeProfile, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
|
|
3002
3124
|
setup: async (opts) => {
|
|
3003
3125
|
await setup(opts); // setup closes envWatcher itself before printing Done
|
|
3004
3126
|
// Auto-start the tray (best-effort, silent on headless / missing binary).
|