rechrome 1.31.3 → 1.31.5
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 +4 -2
- package/listeners.ts +1 -1
- package/package.json +1 -1
- package/rechrome.js +177 -16
- package/rechrome.ts +177 -16
- package/skills/rechrome-setup/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -74,6 +74,8 @@ between calls; another worktree gets its own. `-s=<name>` opens a named sub-sess
|
|
|
74
74
|
`<project>` is the git worktree root (submodules count as their parent project), or the current
|
|
75
75
|
directory outside git. rechrome reads `RECHROME_URL` from the nearest `.rechrome/.env.local` or
|
|
76
76
|
`.env.local` walking up from the current directory; an explicit environment variable wins.
|
|
77
|
+
When editing a file by hand, quote the URL (`RECHROME_URL="…#key=…"`): unquoted, a `#` starts a
|
|
78
|
+
comment for some .env loaders. rech quotes it when it saves one.
|
|
77
79
|
|
|
78
80
|
### 4. Use it from another machine
|
|
79
81
|
|
|
@@ -106,7 +108,7 @@ rech open https://example.com
|
|
|
106
108
|
`.rechrome/.env.local`. Remote listeners allow navigation, tabs, snapshots, screenshots and basic
|
|
107
109
|
interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
|
|
108
110
|
|
|
109
|
-
**Share several profiles in one link.** `rech share work@example.com
|
|
111
|
+
**Share several profiles in one link.** `rech share work@example.com personal team-dev` puts
|
|
110
112
|
exactly those profiles on one listener (its own key; running it again reuses it). Add
|
|
111
113
|
`--listener <name>` to use a listener that already has a proxy route. Its links then reach
|
|
112
114
|
exactly this list, and `rech listener rotate-key <name>` issues a fresh key if old links shouldn't.
|
|
@@ -262,7 +264,7 @@ the last working configuration. Each listener has its own bearer key. Use `rech
|
|
|
262
264
|
to see configured exposure, and `rech listener ls` to list bindings without credentials.
|
|
263
265
|
|
|
264
266
|
```bash
|
|
265
|
-
rech listener add qa --listen tailscale --profile
|
|
267
|
+
rech listener add qa --listen tailscale --profile qa-remote --port 13776
|
|
266
268
|
rech listener remove qa
|
|
267
269
|
```
|
|
268
270
|
|
package/listeners.ts
CHANGED
|
@@ -142,7 +142,7 @@ const profileIdentity = (e: RegisteredProfile) => `${e.userDataDir ?? ""}\0${e.p
|
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
* One registry key per Chrome profile (a profile can be registered under several aliases,
|
|
145
|
-
* e.g. "Profile 5" and "
|
|
145
|
+
* e.g. "Profile 5" and "personal"): prefer an email, else the alphabetically first key.
|
|
146
146
|
*/
|
|
147
147
|
export function canonicalProfileKeys(registry: Record<string, RegisteredProfile>): string[] {
|
|
148
148
|
const byProfile = new Map<string, string>();
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -88,7 +88,44 @@ const globalEnvFile = join(HOME || "~", ".env.local");
|
|
|
88
88
|
|
|
89
89
|
// Capture inherited values once so explicit environment overrides survive reloads,
|
|
90
90
|
// while values loaded from files can still change when those files are edited.
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A KEY=value line for an env file. A value with "#" (every connection URL's #key=…) or
|
|
93
|
+
* whitespace is double-quoted: Bun's own .env loader treats an unquoted "#" as a comment and
|
|
94
|
+
* would drop the key. rech's loader strips the quotes again.
|
|
95
|
+
*/
|
|
96
|
+
export function envAssignment(key: string, value: string): string {
|
|
97
|
+
return /[#\s]/.test(value) ? `${key}="${value}"` : `${key}=${value}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse KEY=value lines the way Bun's .env loader does (unquoted values end at "#"). */
|
|
101
|
+
function parseBunEnvFile(text: string): Record<string, string> {
|
|
102
|
+
const out: Record<string, string> = {};
|
|
103
|
+
for (const line of text.split("\n")) {
|
|
104
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
const raw = m[2].trim();
|
|
107
|
+
const quoted = raw.match(/^(["'`])(.*)\1/);
|
|
108
|
+
out[m[1]] = quoted ? quoted[2] : raw.replace(/\s*#.*$/, "").trim();
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Keys Bun itself loaded from the current folder's .env files before rech started. They look
|
|
115
|
+
* like shell exports, but are file values: counting them as inherited would let a folder's
|
|
116
|
+
* .env.local beat its .rechrome/.env.local, and a keyless (cut at "#") URL beat a good one.
|
|
117
|
+
*/
|
|
118
|
+
export function bunAutoloadedKeys(env: Record<string, string | undefined>, files: string[]): Set<string> {
|
|
119
|
+
const keys = new Set<string>();
|
|
120
|
+
for (const text of files)
|
|
121
|
+
for (const [key, value] of Object.entries(parseBunEnvFile(text))) if (env[key] === value) keys.add(key);
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bunLoaded = bunAutoloadedKeys(process.env,
|
|
126
|
+
[".env", `.env.${process.env.NODE_ENV || "development"}`, ".env.local", `.env.${process.env.NODE_ENV || "development"}.local`]
|
|
127
|
+
.map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
|
|
128
|
+
const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
|
|
92
129
|
|
|
93
130
|
// Walk CWD→root loading env files nearest-first; inherited environment wins over files.
|
|
94
131
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
@@ -437,7 +474,7 @@ export async function getOrCreateUrl(persist = true): Promise<string> {
|
|
|
437
474
|
const key = randomBytes(12).toString("base64url"); // 16 chars
|
|
438
475
|
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
439
476
|
if (persist) {
|
|
440
|
-
const newLine =
|
|
477
|
+
const newLine = envAssignment(ENV_KEY, url);
|
|
441
478
|
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
442
479
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
443
480
|
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
@@ -905,16 +942,135 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
|
|
|
905
942
|
return rows;
|
|
906
943
|
}
|
|
907
944
|
|
|
908
|
-
/**
|
|
909
|
-
|
|
910
|
-
|
|
945
|
+
/**
|
|
946
|
+
* Split a Windows command line into arguments the way the C runtime does (CommandLineToArgvW
|
|
947
|
+
* rules): whitespace separates outside quotes; quotes toggle and may sit mid-argument
|
|
948
|
+
* (`--x="C:\a"b` is `--x=C:\ab`); 2n backslashes before a quote are n backslashes and the quote
|
|
949
|
+
* toggles, 2n+1 are n and a literal quote; `""` inside quotes is a literal quote.
|
|
950
|
+
*/
|
|
951
|
+
export function splitWindowsCommandLine(commandLine: string): string[] {
|
|
952
|
+
const args: string[] = [];
|
|
953
|
+
let current = "", quoted = false, started = false;
|
|
954
|
+
for (let i = 0; i < commandLine.length; i++) {
|
|
955
|
+
const c = commandLine[i];
|
|
956
|
+
if (c === "\\") {
|
|
957
|
+
let n = 0;
|
|
958
|
+
while (commandLine[i] === "\\") { n++; i++; }
|
|
959
|
+
if (commandLine[i] === '"') {
|
|
960
|
+
current += "\\".repeat(n >> 1);
|
|
961
|
+
if (n % 2) current += '"'; else quoted = !quoted;
|
|
962
|
+
} else { current += "\\".repeat(n); i--; }
|
|
963
|
+
started = true;
|
|
964
|
+
} else if (c === '"') {
|
|
965
|
+
if (quoted && commandLine[i + 1] === '"') { current += '"'; i++; } else quoted = !quoted;
|
|
966
|
+
started = true;
|
|
967
|
+
} else if (!quoted && (c === " " || c === "\t")) {
|
|
968
|
+
if (started) { args.push(current); current = ""; started = false; }
|
|
969
|
+
} else { current += c; started = true; }
|
|
970
|
+
}
|
|
971
|
+
if (started) args.push(current);
|
|
972
|
+
return args;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** The `--user-data-dir` value among exact arguments, or null. */
|
|
976
|
+
export function userDataDirArg(args: string[]): string | null {
|
|
977
|
+
const arg = args.find(a => a.startsWith("--user-data-dir="));
|
|
978
|
+
return arg === undefined ? null : arg.slice("--user-data-dir=".length);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Same folder: exact path, ignoring trailing separators (and case on Windows). */
|
|
982
|
+
export function sameDataDir(a: string, b: string, windows = process.platform === "win32"): boolean {
|
|
983
|
+
const norm = (p: string) => { const t = p.replace(/[\\/]+$/, ""); return windows ? t.replaceAll("/", "\\").toLowerCase() : t; };
|
|
984
|
+
return norm(a) === norm(b);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Match a flattened POSIX `ps` command line (no quotes, argument boundaries lost) against a
|
|
989
|
+
* user-data dir: "exact" only when the path is the last thing on the line; "ambiguous" when
|
|
990
|
+
* more text follows (another flag, or a sibling folder like `qa -backup`); else "none".
|
|
991
|
+
*/
|
|
992
|
+
export function flatUserDataDirMatch(command: string, userDataDir: string): "exact" | "ambiguous" | "none" {
|
|
993
|
+
const flag = `--user-data-dir=${userDataDir.replace(/\/+$/, "")}`;
|
|
994
|
+
const at = command.indexOf(flag);
|
|
995
|
+
if (at < 0 || (at > 0 && !/\s/.test(command[at - 1]))) return "none";
|
|
996
|
+
const rest = command.slice(at + flag.length);
|
|
997
|
+
if (/^\/*\s*$/.test(rest)) return "exact";
|
|
998
|
+
return /^\/*\s/.test(rest) ? "ambiguous" : "none"; // `qa2` / `qa-x` are other folders
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Browser processes (not helpers) on this Chrome user-data dir. `exact`: certainly this folder
|
|
1003
|
+
* (Windows command lines are split with the C runtime's rules; Linux reads argv from /proc).
|
|
1004
|
+
* `ambiguous`: flattened `ps` output that may name a sibling folder — never killed.
|
|
1005
|
+
*/
|
|
1006
|
+
function browsersUsing(userDataDir: string): { exact: number[]; ambiguous: number[] } {
|
|
1007
|
+
const exact: number[] = [], ambiguous: number[] = [];
|
|
1008
|
+
const isHelper = (args: string[]) => args.some(a => a.startsWith("--type="));
|
|
1009
|
+
if (process.platform === "win32") {
|
|
1010
|
+
const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
1011
|
+
"Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }"], { windowsHide: true }).stdout.toString();
|
|
1012
|
+
for (const line of out.split(/\r?\n/)) {
|
|
1013
|
+
const tab = line.indexOf("\t"), pid = Number(line.slice(0, tab));
|
|
1014
|
+
if (!pid || pid === process.pid) continue;
|
|
1015
|
+
const args = splitWindowsCommandLine(line.slice(tab + 1));
|
|
1016
|
+
const dir = userDataDirArg(args);
|
|
1017
|
+
if (dir !== null && !isHelper(args) && sameDataDir(dir, userDataDir)) exact.push(pid);
|
|
1018
|
+
}
|
|
1019
|
+
return { exact, ambiguous };
|
|
1020
|
+
}
|
|
911
1021
|
const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
|
|
912
|
-
|
|
913
|
-
|
|
1022
|
+
for (const line of ps.split("\n")) {
|
|
1023
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
1024
|
+
if (!m || Number(m[1]) === process.pid || !m[2].includes("--user-data-dir=")) continue;
|
|
1025
|
+
const pid = Number(m[1]);
|
|
1026
|
+
let argv: string[] | null = null;
|
|
1027
|
+
try { argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean); } catch { /* not Linux, or gone */ }
|
|
1028
|
+
if (argv) {
|
|
1029
|
+
const dir = userDataDirArg(argv);
|
|
1030
|
+
if (dir !== null && !isHelper(argv) && sameDataDir(dir, userDataDir, false)) exact.push(pid);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (/(?:^|\s)--type=/.test(m[2])) continue;
|
|
1034
|
+
const match = flatUserDataDirMatch(m[2], userDataDir);
|
|
1035
|
+
if (match === "exact") exact.push(pid);
|
|
1036
|
+
else if (match === "ambiguous") ambiguous.push(pid);
|
|
1037
|
+
}
|
|
1038
|
+
return { exact, ambiguous };
|
|
914
1039
|
}
|
|
915
1040
|
|
|
916
1041
|
/** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
|
|
917
1042
|
function moveToTrash(dir: string): string | null {
|
|
1043
|
+
// An explicit trash folder (tests use it to keep the real Trash / Recycle Bin clean).
|
|
1044
|
+
const override = process.env.RECH_TRASH_DIR;
|
|
1045
|
+
if (override) {
|
|
1046
|
+
mkdirSync(override, { recursive: true });
|
|
1047
|
+
let dest = join(override, basename(dir));
|
|
1048
|
+
if (existsSync(dest)) dest = `${dest} ${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
1049
|
+
renameSync(dir, dest);
|
|
1050
|
+
return dest;
|
|
1051
|
+
}
|
|
1052
|
+
if (process.platform === "win32") {
|
|
1053
|
+
// SHFileOperation with ALLOWUNDO sends it to the Recycle Bin, with no prompts or progress UI.
|
|
1054
|
+
// WANTNUKEWARNING makes Windows ask rather than silently delete for good when the item
|
|
1055
|
+
// cannot be recycled. (.NET's FileSystem.DeleteDirectory(..., SendToRecycleBin) deleted
|
|
1056
|
+
// permanently here.) The path goes through the environment so quoting can't break it.
|
|
1057
|
+
const source = [
|
|
1058
|
+
"using System; using System.Runtime.InteropServices;",
|
|
1059
|
+
"public static class RechRecycle {",
|
|
1060
|
+
" [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]",
|
|
1061
|
+
" struct Op { public IntPtr hwnd; public uint wFunc; public string pFrom; public string pTo; public ushort fFlags; public bool aborted; public IntPtr mappings; public string title; }",
|
|
1062
|
+
" [DllImport(\"shell32.dll\", CharSet = CharSet.Unicode)] static extern int SHFileOperation(ref Op op);",
|
|
1063
|
+
" public static int Recycle(string path) {",
|
|
1064
|
+
" var op = new Op { wFunc = 3, pFrom = path + \"\\0\\0\", fFlags = 0x0040 | 0x0010 | 0x0004 | 0x0400 | 0x4000 };",
|
|
1065
|
+
" int r = SHFileOperation(ref op); return op.aborted ? -1 : r;",
|
|
1066
|
+
" }",
|
|
1067
|
+
"}",
|
|
1068
|
+
].join("\n");
|
|
1069
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
1070
|
+
"Add-Type -TypeDefinition $env:RECH_RECYCLE_SOURCE; exit [RechRecycle]::Recycle($env:RECH_RECYCLE_PATH)"],
|
|
1071
|
+
{ env: { ...process.env, RECH_RECYCLE_SOURCE: source, RECH_RECYCLE_PATH: dir.replaceAll("/", "\\").replace(/\\+$/, "") }, windowsHide: true }); // SHFileOperation rejects `/` and a trailing `\`
|
|
1072
|
+
return r.exitCode === 0 && !existsSync(dir) ? "the Recycle Bin" : null;
|
|
1073
|
+
}
|
|
918
1074
|
const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
|
|
919
1075
|
if (!trash) return null;
|
|
920
1076
|
mkdirSync(trash, { recursive: true });
|
|
@@ -935,13 +1091,17 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
935
1091
|
const key = (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
936
1092
|
const config = await readListeners();
|
|
937
1093
|
const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
|
|
938
|
-
const
|
|
1094
|
+
const using = plan.dataDir ? browsersUsing(plan.dataDir) : { exact: [], ambiguous: [] };
|
|
1095
|
+
const running = using.exact;
|
|
939
1096
|
console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
|
|
940
1097
|
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}"`);
|
|
941
1098
|
console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
|
|
942
1099
|
if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
|
|
943
1100
|
else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
|
|
944
1101
|
if (running.length) console.log(` - close its browser window, which is running now (pid ${running.join(", ")})`);
|
|
1102
|
+
// A process that may be on this folder or on a sibling one: never killed, and nothing changes.
|
|
1103
|
+
if (using.ambiguous.length)
|
|
1104
|
+
throw new Error(`A process that may be using ${plan.dataDir} is running (pid ${using.ambiguous.join(", ")}), and rech can't tell it from one on a similarly named folder. Close it yourself, then run this again. Nothing changed.`);
|
|
945
1105
|
const interactive = isInteractive();
|
|
946
1106
|
if (!opts.yes) {
|
|
947
1107
|
if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
|
|
@@ -952,8 +1112,8 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
952
1112
|
// In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
|
|
953
1113
|
if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
|
|
954
1114
|
for (const pid of running) process.kill(pid, "SIGTERM");
|
|
955
|
-
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
|
|
956
|
-
if (browsersUsing(plan.dataDir!).length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
|
|
1115
|
+
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).exact.length; i++) await Bun.sleep(250);
|
|
1116
|
+
if (browsersUsing(plan.dataDir!).exact.length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
|
|
957
1117
|
}
|
|
958
1118
|
if (config && plan.listeners.length) {
|
|
959
1119
|
for (const edit of plan.listeners) {
|
|
@@ -974,7 +1134,7 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
974
1134
|
}
|
|
975
1135
|
let moved: string | null = null;
|
|
976
1136
|
if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
|
|
977
|
-
console.log(`Removed "${key}".${moved ? ` Its data folder
|
|
1137
|
+
console.log(`Removed "${key}".${moved ? ` Its data folder was moved to ${moved}.` : plan.dataDir ? ` Its data folder was left at ${plan.dataDir}; delete it yourself if you like.` : ""}`);
|
|
978
1138
|
}
|
|
979
1139
|
|
|
980
1140
|
/** On a client of a remote host, `rech profile` lists what that host's link shares. */
|
|
@@ -2048,7 +2208,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
2048
2208
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
2049
2209
|
const lines = envRaw.trimEnd().split("\n");
|
|
2050
2210
|
const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
2051
|
-
await Bun.write(globalEnvFile, [...filtered,
|
|
2211
|
+
await Bun.write(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"));
|
|
2052
2212
|
|
|
2053
2213
|
const home = HOME;
|
|
2054
2214
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
@@ -2434,14 +2594,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
2434
2594
|
rechUrl.searchParams.set("load_extension", dist);
|
|
2435
2595
|
|
|
2436
2596
|
|
|
2437
|
-
const
|
|
2597
|
+
const profileUrl = registeredProfileUrl(rechUrl.toString());
|
|
2598
|
+
const newLine = envAssignment(ENV_KEY, profileUrl);
|
|
2438
2599
|
|
|
2439
2600
|
// [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
|
|
2440
2601
|
await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
|
|
2441
2602
|
console.log(`\n[3/3] Registered "${name}" in ${TOKENS_FILE}`);
|
|
2442
2603
|
|
|
2443
2604
|
console.log(`\nDone! RECHROME_URL for "${name}":\n\n ${newLine}\n`);
|
|
2444
|
-
console.log(`Use it per-call:\n ${
|
|
2605
|
+
console.log(`Use it per-call:\n ${ENV_KEY}='${profileUrl}' rech open https://example.com\n`);
|
|
2445
2606
|
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
2446
2607
|
}
|
|
2447
2608
|
|
|
@@ -2598,7 +2759,7 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2598
2759
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
2599
2760
|
const envPath = join(dataDir, ".env.local");
|
|
2600
2761
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
2601
|
-
await writeFile(envPath, [...lines,
|
|
2762
|
+
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
2602
2763
|
await chmod(envPath, 0o600);
|
|
2603
2764
|
return envPath;
|
|
2604
2765
|
}
|
|
@@ -2948,7 +3109,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2948
3109
|
console.log(` rech setup --profile ${JSON.stringify(profileEmail)} --listen local --prefix=rechrome`);
|
|
2949
3110
|
console.log(` then expose it with tailscale serve (setup prints the command and the remote URL).`);
|
|
2950
3111
|
}
|
|
2951
|
-
const newLine =
|
|
3112
|
+
const newLine = envAssignment(ENV_KEY, registeredProfileUrl(rechUrl.toString()));
|
|
2952
3113
|
|
|
2953
3114
|
console.log("\n○ [5/5] Connection verified. Save connection configuration:");
|
|
2954
3115
|
console.log(`\n${newLine}\n`);
|
package/rechrome.ts
CHANGED
|
@@ -88,7 +88,44 @@ const globalEnvFile = join(HOME || "~", ".env.local");
|
|
|
88
88
|
|
|
89
89
|
// Capture inherited values once so explicit environment overrides survive reloads,
|
|
90
90
|
// while values loaded from files can still change when those files are edited.
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A KEY=value line for an env file. A value with "#" (every connection URL's #key=…) or
|
|
93
|
+
* whitespace is double-quoted: Bun's own .env loader treats an unquoted "#" as a comment and
|
|
94
|
+
* would drop the key. rech's loader strips the quotes again.
|
|
95
|
+
*/
|
|
96
|
+
export function envAssignment(key: string, value: string): string {
|
|
97
|
+
return /[#\s]/.test(value) ? `${key}="${value}"` : `${key}=${value}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse KEY=value lines the way Bun's .env loader does (unquoted values end at "#"). */
|
|
101
|
+
function parseBunEnvFile(text: string): Record<string, string> {
|
|
102
|
+
const out: Record<string, string> = {};
|
|
103
|
+
for (const line of text.split("\n")) {
|
|
104
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
const raw = m[2].trim();
|
|
107
|
+
const quoted = raw.match(/^(["'`])(.*)\1/);
|
|
108
|
+
out[m[1]] = quoted ? quoted[2] : raw.replace(/\s*#.*$/, "").trim();
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Keys Bun itself loaded from the current folder's .env files before rech started. They look
|
|
115
|
+
* like shell exports, but are file values: counting them as inherited would let a folder's
|
|
116
|
+
* .env.local beat its .rechrome/.env.local, and a keyless (cut at "#") URL beat a good one.
|
|
117
|
+
*/
|
|
118
|
+
export function bunAutoloadedKeys(env: Record<string, string | undefined>, files: string[]): Set<string> {
|
|
119
|
+
const keys = new Set<string>();
|
|
120
|
+
for (const text of files)
|
|
121
|
+
for (const [key, value] of Object.entries(parseBunEnvFile(text))) if (env[key] === value) keys.add(key);
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bunLoaded = bunAutoloadedKeys(process.env,
|
|
126
|
+
[".env", `.env.${process.env.NODE_ENV || "development"}`, ".env.local", `.env.${process.env.NODE_ENV || "development"}.local`]
|
|
127
|
+
.map(name => { try { return readFileSync(join(process.cwd(), name), "utf8"); } catch { return ""; } }));
|
|
128
|
+
const inheritedEnvKeys = new Set(Object.keys(process.env).filter(key => !bunLoaded.has(key)));
|
|
92
129
|
|
|
93
130
|
// Walk CWD→root loading env files nearest-first; inherited environment wins over files.
|
|
94
131
|
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
@@ -437,7 +474,7 @@ export async function getOrCreateUrl(persist = true): Promise<string> {
|
|
|
437
474
|
const key = randomBytes(12).toString("base64url"); // 16 chars
|
|
438
475
|
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
439
476
|
if (persist) {
|
|
440
|
-
const newLine =
|
|
477
|
+
const newLine = envAssignment(ENV_KEY, url);
|
|
441
478
|
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
442
479
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
443
480
|
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
@@ -905,16 +942,135 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
|
|
|
905
942
|
return rows;
|
|
906
943
|
}
|
|
907
944
|
|
|
908
|
-
/**
|
|
909
|
-
|
|
910
|
-
|
|
945
|
+
/**
|
|
946
|
+
* Split a Windows command line into arguments the way the C runtime does (CommandLineToArgvW
|
|
947
|
+
* rules): whitespace separates outside quotes; quotes toggle and may sit mid-argument
|
|
948
|
+
* (`--x="C:\a"b` is `--x=C:\ab`); 2n backslashes before a quote are n backslashes and the quote
|
|
949
|
+
* toggles, 2n+1 are n and a literal quote; `""` inside quotes is a literal quote.
|
|
950
|
+
*/
|
|
951
|
+
export function splitWindowsCommandLine(commandLine: string): string[] {
|
|
952
|
+
const args: string[] = [];
|
|
953
|
+
let current = "", quoted = false, started = false;
|
|
954
|
+
for (let i = 0; i < commandLine.length; i++) {
|
|
955
|
+
const c = commandLine[i];
|
|
956
|
+
if (c === "\\") {
|
|
957
|
+
let n = 0;
|
|
958
|
+
while (commandLine[i] === "\\") { n++; i++; }
|
|
959
|
+
if (commandLine[i] === '"') {
|
|
960
|
+
current += "\\".repeat(n >> 1);
|
|
961
|
+
if (n % 2) current += '"'; else quoted = !quoted;
|
|
962
|
+
} else { current += "\\".repeat(n); i--; }
|
|
963
|
+
started = true;
|
|
964
|
+
} else if (c === '"') {
|
|
965
|
+
if (quoted && commandLine[i + 1] === '"') { current += '"'; i++; } else quoted = !quoted;
|
|
966
|
+
started = true;
|
|
967
|
+
} else if (!quoted && (c === " " || c === "\t")) {
|
|
968
|
+
if (started) { args.push(current); current = ""; started = false; }
|
|
969
|
+
} else { current += c; started = true; }
|
|
970
|
+
}
|
|
971
|
+
if (started) args.push(current);
|
|
972
|
+
return args;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** The `--user-data-dir` value among exact arguments, or null. */
|
|
976
|
+
export function userDataDirArg(args: string[]): string | null {
|
|
977
|
+
const arg = args.find(a => a.startsWith("--user-data-dir="));
|
|
978
|
+
return arg === undefined ? null : arg.slice("--user-data-dir=".length);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Same folder: exact path, ignoring trailing separators (and case on Windows). */
|
|
982
|
+
export function sameDataDir(a: string, b: string, windows = process.platform === "win32"): boolean {
|
|
983
|
+
const norm = (p: string) => { const t = p.replace(/[\\/]+$/, ""); return windows ? t.replaceAll("/", "\\").toLowerCase() : t; };
|
|
984
|
+
return norm(a) === norm(b);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Match a flattened POSIX `ps` command line (no quotes, argument boundaries lost) against a
|
|
989
|
+
* user-data dir: "exact" only when the path is the last thing on the line; "ambiguous" when
|
|
990
|
+
* more text follows (another flag, or a sibling folder like `qa -backup`); else "none".
|
|
991
|
+
*/
|
|
992
|
+
export function flatUserDataDirMatch(command: string, userDataDir: string): "exact" | "ambiguous" | "none" {
|
|
993
|
+
const flag = `--user-data-dir=${userDataDir.replace(/\/+$/, "")}`;
|
|
994
|
+
const at = command.indexOf(flag);
|
|
995
|
+
if (at < 0 || (at > 0 && !/\s/.test(command[at - 1]))) return "none";
|
|
996
|
+
const rest = command.slice(at + flag.length);
|
|
997
|
+
if (/^\/*\s*$/.test(rest)) return "exact";
|
|
998
|
+
return /^\/*\s/.test(rest) ? "ambiguous" : "none"; // `qa2` / `qa-x` are other folders
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Browser processes (not helpers) on this Chrome user-data dir. `exact`: certainly this folder
|
|
1003
|
+
* (Windows command lines are split with the C runtime's rules; Linux reads argv from /proc).
|
|
1004
|
+
* `ambiguous`: flattened `ps` output that may name a sibling folder — never killed.
|
|
1005
|
+
*/
|
|
1006
|
+
function browsersUsing(userDataDir: string): { exact: number[]; ambiguous: number[] } {
|
|
1007
|
+
const exact: number[] = [], ambiguous: number[] = [];
|
|
1008
|
+
const isHelper = (args: string[]) => args.some(a => a.startsWith("--type="));
|
|
1009
|
+
if (process.platform === "win32") {
|
|
1010
|
+
const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
1011
|
+
"Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }"], { windowsHide: true }).stdout.toString();
|
|
1012
|
+
for (const line of out.split(/\r?\n/)) {
|
|
1013
|
+
const tab = line.indexOf("\t"), pid = Number(line.slice(0, tab));
|
|
1014
|
+
if (!pid || pid === process.pid) continue;
|
|
1015
|
+
const args = splitWindowsCommandLine(line.slice(tab + 1));
|
|
1016
|
+
const dir = userDataDirArg(args);
|
|
1017
|
+
if (dir !== null && !isHelper(args) && sameDataDir(dir, userDataDir)) exact.push(pid);
|
|
1018
|
+
}
|
|
1019
|
+
return { exact, ambiguous };
|
|
1020
|
+
}
|
|
911
1021
|
const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
|
|
912
|
-
|
|
913
|
-
|
|
1022
|
+
for (const line of ps.split("\n")) {
|
|
1023
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
1024
|
+
if (!m || Number(m[1]) === process.pid || !m[2].includes("--user-data-dir=")) continue;
|
|
1025
|
+
const pid = Number(m[1]);
|
|
1026
|
+
let argv: string[] | null = null;
|
|
1027
|
+
try { argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean); } catch { /* not Linux, or gone */ }
|
|
1028
|
+
if (argv) {
|
|
1029
|
+
const dir = userDataDirArg(argv);
|
|
1030
|
+
if (dir !== null && !isHelper(argv) && sameDataDir(dir, userDataDir, false)) exact.push(pid);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (/(?:^|\s)--type=/.test(m[2])) continue;
|
|
1034
|
+
const match = flatUserDataDirMatch(m[2], userDataDir);
|
|
1035
|
+
if (match === "exact") exact.push(pid);
|
|
1036
|
+
else if (match === "ambiguous") ambiguous.push(pid);
|
|
1037
|
+
}
|
|
1038
|
+
return { exact, ambiguous };
|
|
914
1039
|
}
|
|
915
1040
|
|
|
916
1041
|
/** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
|
|
917
1042
|
function moveToTrash(dir: string): string | null {
|
|
1043
|
+
// An explicit trash folder (tests use it to keep the real Trash / Recycle Bin clean).
|
|
1044
|
+
const override = process.env.RECH_TRASH_DIR;
|
|
1045
|
+
if (override) {
|
|
1046
|
+
mkdirSync(override, { recursive: true });
|
|
1047
|
+
let dest = join(override, basename(dir));
|
|
1048
|
+
if (existsSync(dest)) dest = `${dest} ${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
1049
|
+
renameSync(dir, dest);
|
|
1050
|
+
return dest;
|
|
1051
|
+
}
|
|
1052
|
+
if (process.platform === "win32") {
|
|
1053
|
+
// SHFileOperation with ALLOWUNDO sends it to the Recycle Bin, with no prompts or progress UI.
|
|
1054
|
+
// WANTNUKEWARNING makes Windows ask rather than silently delete for good when the item
|
|
1055
|
+
// cannot be recycled. (.NET's FileSystem.DeleteDirectory(..., SendToRecycleBin) deleted
|
|
1056
|
+
// permanently here.) The path goes through the environment so quoting can't break it.
|
|
1057
|
+
const source = [
|
|
1058
|
+
"using System; using System.Runtime.InteropServices;",
|
|
1059
|
+
"public static class RechRecycle {",
|
|
1060
|
+
" [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]",
|
|
1061
|
+
" struct Op { public IntPtr hwnd; public uint wFunc; public string pFrom; public string pTo; public ushort fFlags; public bool aborted; public IntPtr mappings; public string title; }",
|
|
1062
|
+
" [DllImport(\"shell32.dll\", CharSet = CharSet.Unicode)] static extern int SHFileOperation(ref Op op);",
|
|
1063
|
+
" public static int Recycle(string path) {",
|
|
1064
|
+
" var op = new Op { wFunc = 3, pFrom = path + \"\\0\\0\", fFlags = 0x0040 | 0x0010 | 0x0004 | 0x0400 | 0x4000 };",
|
|
1065
|
+
" int r = SHFileOperation(ref op); return op.aborted ? -1 : r;",
|
|
1066
|
+
" }",
|
|
1067
|
+
"}",
|
|
1068
|
+
].join("\n");
|
|
1069
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
1070
|
+
"Add-Type -TypeDefinition $env:RECH_RECYCLE_SOURCE; exit [RechRecycle]::Recycle($env:RECH_RECYCLE_PATH)"],
|
|
1071
|
+
{ env: { ...process.env, RECH_RECYCLE_SOURCE: source, RECH_RECYCLE_PATH: dir.replaceAll("/", "\\").replace(/\\+$/, "") }, windowsHide: true }); // SHFileOperation rejects `/` and a trailing `\`
|
|
1072
|
+
return r.exitCode === 0 && !existsSync(dir) ? "the Recycle Bin" : null;
|
|
1073
|
+
}
|
|
918
1074
|
const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
|
|
919
1075
|
if (!trash) return null;
|
|
920
1076
|
mkdirSync(trash, { recursive: true });
|
|
@@ -935,13 +1091,17 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
935
1091
|
const key = (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
936
1092
|
const config = await readListeners();
|
|
937
1093
|
const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
|
|
938
|
-
const
|
|
1094
|
+
const using = plan.dataDir ? browsersUsing(plan.dataDir) : { exact: [], ambiguous: [] };
|
|
1095
|
+
const running = using.exact;
|
|
939
1096
|
console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
|
|
940
1097
|
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}"`);
|
|
941
1098
|
console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
|
|
942
1099
|
if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
|
|
943
1100
|
else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
|
|
944
1101
|
if (running.length) console.log(` - close its browser window, which is running now (pid ${running.join(", ")})`);
|
|
1102
|
+
// A process that may be on this folder or on a sibling one: never killed, and nothing changes.
|
|
1103
|
+
if (using.ambiguous.length)
|
|
1104
|
+
throw new Error(`A process that may be using ${plan.dataDir} is running (pid ${using.ambiguous.join(", ")}), and rech can't tell it from one on a similarly named folder. Close it yourself, then run this again. Nothing changed.`);
|
|
945
1105
|
const interactive = isInteractive();
|
|
946
1106
|
if (!opts.yes) {
|
|
947
1107
|
if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
|
|
@@ -952,8 +1112,8 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
952
1112
|
// In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
|
|
953
1113
|
if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
|
|
954
1114
|
for (const pid of running) process.kill(pid, "SIGTERM");
|
|
955
|
-
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
|
|
956
|
-
if (browsersUsing(plan.dataDir!).length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
|
|
1115
|
+
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).exact.length; i++) await Bun.sleep(250);
|
|
1116
|
+
if (browsersUsing(plan.dataDir!).exact.length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
|
|
957
1117
|
}
|
|
958
1118
|
if (config && plan.listeners.length) {
|
|
959
1119
|
for (const edit of plan.listeners) {
|
|
@@ -974,7 +1134,7 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
974
1134
|
}
|
|
975
1135
|
let moved: string | null = null;
|
|
976
1136
|
if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
|
|
977
|
-
console.log(`Removed "${key}".${moved ? ` Its data folder
|
|
1137
|
+
console.log(`Removed "${key}".${moved ? ` Its data folder was moved to ${moved}.` : plan.dataDir ? ` Its data folder was left at ${plan.dataDir}; delete it yourself if you like.` : ""}`);
|
|
978
1138
|
}
|
|
979
1139
|
|
|
980
1140
|
/** On a client of a remote host, `rech profile` lists what that host's link shares. */
|
|
@@ -2048,7 +2208,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
2048
2208
|
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
2049
2209
|
const lines = envRaw.trimEnd().split("\n");
|
|
2050
2210
|
const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
2051
|
-
await Bun.write(globalEnvFile, [...filtered,
|
|
2211
|
+
await Bun.write(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"));
|
|
2052
2212
|
|
|
2053
2213
|
const home = HOME;
|
|
2054
2214
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
@@ -2434,14 +2594,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
2434
2594
|
rechUrl.searchParams.set("load_extension", dist);
|
|
2435
2595
|
|
|
2436
2596
|
|
|
2437
|
-
const
|
|
2597
|
+
const profileUrl = registeredProfileUrl(rechUrl.toString());
|
|
2598
|
+
const newLine = envAssignment(ENV_KEY, profileUrl);
|
|
2438
2599
|
|
|
2439
2600
|
// [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
|
|
2440
2601
|
await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
|
|
2441
2602
|
console.log(`\n[3/3] Registered "${name}" in ${TOKENS_FILE}`);
|
|
2442
2603
|
|
|
2443
2604
|
console.log(`\nDone! RECHROME_URL for "${name}":\n\n ${newLine}\n`);
|
|
2444
|
-
console.log(`Use it per-call:\n ${
|
|
2605
|
+
console.log(`Use it per-call:\n ${ENV_KEY}='${profileUrl}' rech open https://example.com\n`);
|
|
2445
2606
|
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
2446
2607
|
}
|
|
2447
2608
|
|
|
@@ -2598,7 +2759,7 @@ async function saveProjectUrl(url: string): Promise<string> {
|
|
|
2598
2759
|
if (!existsSync(gitignore)) await Bun.write(gitignore, "*\n");
|
|
2599
2760
|
const envPath = join(dataDir, ".env.local");
|
|
2600
2761
|
const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
|
|
2601
|
-
await writeFile(envPath, [...lines,
|
|
2762
|
+
await writeFile(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), { mode: 0o600 });
|
|
2602
2763
|
await chmod(envPath, 0o600);
|
|
2603
2764
|
return envPath;
|
|
2604
2765
|
}
|
|
@@ -2948,7 +3109,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
2948
3109
|
console.log(` rech setup --profile ${JSON.stringify(profileEmail)} --listen local --prefix=rechrome`);
|
|
2949
3110
|
console.log(` then expose it with tailscale serve (setup prints the command and the remote URL).`);
|
|
2950
3111
|
}
|
|
2951
|
-
const newLine =
|
|
3112
|
+
const newLine = envAssignment(ENV_KEY, registeredProfileUrl(rechUrl.toString()));
|
|
2952
3113
|
|
|
2953
3114
|
console.log("\n○ [5/5] Connection verified. Save connection configuration:");
|
|
2954
3115
|
console.log(`\n${newLine}\n`);
|
|
@@ -11,12 +11,12 @@ Use this workflow when the user requests rechrome setup for an existing Chrome p
|
|
|
11
11
|
|
|
12
12
|
- Preserve the user's browser choice. Installed Google Chrome profiles belong to the user; do not rename, create, delete, or replace them as part of setup.
|
|
13
13
|
- If the user wants an isolated Playwright browser, do not send them through real-Chrome extension installation. Playwright can control its own browser directly. Check the current CLI help for available managed-browser support: the legacy `provision-profile --experimental` flow still uses a bridge and may require the vendored relay patch described in the repo's `AGENTS.md`. Do not present a proposed direct-browser mode as implemented.
|
|
14
|
-
- Run `rech profile ls` and resolve the exact email, display name, or folder. For no-email profiles, use the folder (for example `Profile 7`) to avoid ambiguity. A requested display name such as `
|
|
14
|
+
- Run `rech profile ls` and resolve the exact email, display name, or folder. For no-email profiles, use the folder (for example `Profile 7`) to avoid ambiguity. A requested display name such as `Work 2` is also accepted with `--profile`.
|
|
15
15
|
- Use `rech` or `rechrome` if installed; from a checkout use `bun rechrome.ts`. The package is `rechrome`, not `rech`: use `bunx rechrome`, never `bunx rech`.
|
|
16
16
|
|
|
17
17
|
## Start and retain the setup process
|
|
18
18
|
|
|
19
|
-
Run `rech setup --profile "<selector>"` in a retained terminal session, preferably with a TTY. A bare positional selector such as `rech setup
|
|
19
|
+
Run `rech setup --profile "<selector>"` in a retained terminal session, preferably with a TTY. A bare positional selector such as `rech setup work-2` is not supported by the current CLI.
|
|
20
20
|
|
|
21
21
|
- The listen choice applies to the selected profile. Local is the default and removes that profile from remote allowlists; choose LAN or Tailscale when requested. Other profiles keep their exposure. The menu shows Tailscale only when detected. Use `rech listener ls` and `rech profile ls` to review configured bindings. Listener changes reload in one daemon without restarting Chrome; first migration may restart only the daemon.
|
|
22
22
|
- Keep the terminal session handle and poll it while working in Chrome. Non-TTY mode also works, but input is read until EOF before setup starts: provide all prompt answers and close stdin. EOF does not stop the guide server.
|