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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.30.0",
3
+ "version": "1.31.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
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
- const name = opts.listener ?? "share-all";
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 to use "share-all".`);
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: "/rechrome-all/", key: randomBytes(24).toString("base64url"), profiles: snapshot };
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 rech share --all again.`);
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, "rech share --all")) console.error(line);
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 and whether each is connected", y => y
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 [profile]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
2923
- .positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
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 => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save && !a.all
2929
- ? handlers.urlList()
2930
- : handlers.printProfileUri(a.profile, a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save }))
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
- const name = opts.listener ?? "share-all";
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 to use "share-all".`);
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: "/rechrome-all/", key: randomBytes(24).toString("base64url"), profiles: snapshot };
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 rech share --all again.`);
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, "rech share --all")) console.error(line);
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 and whether each is connected", y => y
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 [profile]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
2923
- .positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
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 => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save && !a.all
2929
- ? handlers.urlList()
2930
- : handlers.printProfileUri(a.profile, a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save }))
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).