rechrome 1.31.4 → 1.31.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/rechrome.js +179 -13
- package/rechrome.ts +179 -13
- package/serve.js +1 -1
- package/serve.ts +1 -1
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -8,7 +8,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";
|
|
11
|
-
import { join, basename, dirname } from "path";
|
|
11
|
+
import { join, basename, dirname, resolve } from "path";
|
|
12
12
|
import { pathToFileURL } from "url";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { spawn as cpSpawn } from "child_process";
|
|
@@ -942,16 +942,135 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
|
|
|
942
942
|
return rows;
|
|
943
943
|
}
|
|
944
944
|
|
|
945
|
-
/**
|
|
946
|
-
|
|
947
|
-
|
|
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
|
+
}
|
|
948
1021
|
const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
|
|
949
|
-
|
|
950
|
-
|
|
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 };
|
|
951
1039
|
}
|
|
952
1040
|
|
|
953
1041
|
/** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
|
|
954
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
|
+
}
|
|
955
1074
|
const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
|
|
956
1075
|
if (!trash) return null;
|
|
957
1076
|
mkdirSync(trash, { recursive: true });
|
|
@@ -972,13 +1091,17 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
972
1091
|
const key = (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
973
1092
|
const config = await readListeners();
|
|
974
1093
|
const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
|
|
975
|
-
const
|
|
1094
|
+
const using = plan.dataDir ? browsersUsing(plan.dataDir) : { exact: [], ambiguous: [] };
|
|
1095
|
+
const running = using.exact;
|
|
976
1096
|
console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
|
|
977
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}"`);
|
|
978
1098
|
console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
|
|
979
1099
|
if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
|
|
980
1100
|
else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
|
|
981
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.`);
|
|
982
1105
|
const interactive = isInteractive();
|
|
983
1106
|
if (!opts.yes) {
|
|
984
1107
|
if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
|
|
@@ -989,8 +1112,8 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
989
1112
|
// In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
|
|
990
1113
|
if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
|
|
991
1114
|
for (const pid of running) process.kill(pid, "SIGTERM");
|
|
992
|
-
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
|
|
993
|
-
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.");
|
|
994
1117
|
}
|
|
995
1118
|
if (config && plan.listeners.length) {
|
|
996
1119
|
for (const edit of plan.listeners) {
|
|
@@ -1011,7 +1134,7 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
1011
1134
|
}
|
|
1012
1135
|
let moved: string | null = null;
|
|
1013
1136
|
if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
|
|
1014
|
-
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.` : ""}`);
|
|
1015
1138
|
}
|
|
1016
1139
|
|
|
1017
1140
|
/** On a client of a remote host, `rech profile` lists what that host's link shares. */
|
|
@@ -1930,9 +2053,48 @@ function oxmgrVersion(bin: string): string | null {
|
|
|
1930
2053
|
// RECH_DAEMON_MANAGER override — lives in ./daemon-manager.js (pure + tested);
|
|
1931
2054
|
// here we just supply the runtime inputs (what's on PATH, the oxmgr version).
|
|
1932
2055
|
let _daemonMgr: DaemonManager | undefined;
|
|
2056
|
+
/**
|
|
2057
|
+
* The first oxmgr that actually runs. PATH order alone isn't enough: a stale global shim
|
|
2058
|
+
* (e.g. bun's, printing "oxmgr binary is missing") can shadow a working build, and npm's
|
|
2059
|
+
* `oxmgr.cmd` re-parses argv through cmd.exe, so real executables come first. RECH_OXMGR
|
|
2060
|
+
* names one explicitly (a path, or a command looked up on PATH). The successful probe's
|
|
2061
|
+
* output seeds the version cache, so `--version` runs once.
|
|
2062
|
+
*/
|
|
2063
|
+
function findWorkingOxmgr(): string | null {
|
|
2064
|
+
const ext = IS_WINDOWS ? ".exe" : "";
|
|
2065
|
+
// Windows PATH entries may be quoted ("C:\Program Files\x"); quotes break the joined path.
|
|
2066
|
+
const dirs = (process.env.PATH ?? "").split(IS_WINDOWS ? ";" : ":").map(d => d.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
|
|
2067
|
+
const override = process.env.RECH_OXMGR;
|
|
2068
|
+
const candidates = [
|
|
2069
|
+
override && (/[\\/]/.test(override) ? resolve(override) : Bun.which(override)),
|
|
2070
|
+
...dirs.map(d => join(d, `oxmgr${ext}`)),
|
|
2071
|
+
// The vendored binary inside a global npm / bun install of the `oxmgr` package
|
|
2072
|
+
// (npm: <dir>/node_modules on Windows, <prefix>/lib/node_modules beside <prefix>/bin elsewhere).
|
|
2073
|
+
...dirs.map(d => join(d, "node_modules", "oxmgr", "vendor", `oxmgr${ext}`)),
|
|
2074
|
+
...(IS_WINDOWS ? [] : dirs.map(d => join(d, "..", "lib", "node_modules", "oxmgr", "vendor", "oxmgr"))),
|
|
2075
|
+
join(HOME, ".bun", "install", "global", "node_modules", "oxmgr", "vendor", `oxmgr${ext}`),
|
|
2076
|
+
].filter((p): p is string => !!p && existsSync(p));
|
|
2077
|
+
const works = (p: string) => {
|
|
2078
|
+
try {
|
|
2079
|
+
const r = Bun.spawnSync([p, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
2080
|
+
if (r.exitCode !== 0) return false;
|
|
2081
|
+
const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(r.stdout?.toString() ?? "");
|
|
2082
|
+
_oxmgrVersion = m ? m[1]! : null;
|
|
2083
|
+
return true;
|
|
2084
|
+
} catch { return false; }
|
|
2085
|
+
};
|
|
2086
|
+
const exe = [...new Set(candidates)].find(works);
|
|
2087
|
+
if (exe) return exe;
|
|
2088
|
+
// Last resort (Windows): a working shim such as npm's oxmgr.cmd, but never over a working
|
|
2089
|
+
// pm2 — cmd.exe re-parses a shim's argv (see runPm).
|
|
2090
|
+
if (!IS_WINDOWS || Bun.which("pm2")) return null;
|
|
2091
|
+
const shim = Bun.which("oxmgr");
|
|
2092
|
+
return shim && works(shim) ? shim : null;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1933
2095
|
function daemonManager(): DaemonManager {
|
|
1934
2096
|
if (_daemonMgr) return _daemonMgr;
|
|
1935
|
-
const oxmgrBin =
|
|
2097
|
+
const oxmgrBin = findWorkingOxmgr();
|
|
1936
2098
|
const pm2Bin = Bun.which("pm2");
|
|
1937
2099
|
_daemonMgr = pickDaemonManager({
|
|
1938
2100
|
oxmgrBin,
|
|
@@ -1951,6 +2113,10 @@ function daemonManager(): DaemonManager {
|
|
|
1951
2113
|
// the managed process (it has no per-var flag like oxmgr's --env), so install
|
|
1952
2114
|
// passes daemon env this way.
|
|
1953
2115
|
async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
|
|
2116
|
+
// A .cmd/.bat shim runs through cmd.exe, which treats & | < > ^ in an argument as syntax
|
|
2117
|
+
// (e.g. the &s in a RECHROME_URL passed with --env): fail clearly instead of mangling it.
|
|
2118
|
+
if (/\.(cmd|bat)$/i.test(mgr.bin) && args.some(a => /[&|<>^]/.test(a)))
|
|
2119
|
+
throw new Error(`${mgr.bin} is a cmd.exe shim, which would mangle an argument containing & | < > ^. Install the ${mgr.id} executable (or set RECH_OXMGR to it) and try again.`);
|
|
1954
2120
|
const proc = Bun.spawn([mgr.bin, ...args], {
|
|
1955
2121
|
stdout: "inherit",
|
|
1956
2122
|
stderr: "inherit",
|
|
@@ -2053,7 +2219,7 @@ async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<str
|
|
|
2053
2219
|
});
|
|
2054
2220
|
const code = await proc.exited;
|
|
2055
2221
|
if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
|
|
2056
|
-
if (!
|
|
2222
|
+
if (!findWorkingOxmgr()) {
|
|
2057
2223
|
throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
|
|
2058
2224
|
}
|
|
2059
2225
|
_daemonMgr = undefined;
|
|
@@ -2172,7 +2338,7 @@ async function daemonUninstall(): Promise<void> {
|
|
|
2172
2338
|
const mgr = daemonManager();
|
|
2173
2339
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
2174
2340
|
if (mgr.id === "pm2") await runPm(mgr, ["save", "--force"]); // an emptied list must still overwrite the dump
|
|
2175
|
-
|
|
2341
|
+
// oxmgr's boot/login service is left installed: every other oxmgr-managed process depends on it.
|
|
2176
2342
|
console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
|
|
2177
2343
|
}
|
|
2178
2344
|
|
package/rechrome.ts
CHANGED
|
@@ -8,7 +8,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";
|
|
11
|
-
import { join, basename, dirname } from "path";
|
|
11
|
+
import { join, basename, dirname, resolve } from "path";
|
|
12
12
|
import { pathToFileURL } from "url";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { spawn as cpSpawn } from "child_process";
|
|
@@ -942,16 +942,135 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
|
|
|
942
942
|
return rows;
|
|
943
943
|
}
|
|
944
944
|
|
|
945
|
-
/**
|
|
946
|
-
|
|
947
|
-
|
|
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
|
+
}
|
|
948
1021
|
const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
|
|
949
|
-
|
|
950
|
-
|
|
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 };
|
|
951
1039
|
}
|
|
952
1040
|
|
|
953
1041
|
/** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
|
|
954
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
|
+
}
|
|
955
1074
|
const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
|
|
956
1075
|
if (!trash) return null;
|
|
957
1076
|
mkdirSync(trash, { recursive: true });
|
|
@@ -972,13 +1091,17 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
972
1091
|
const key = (await resolveGlobalProfile(registry, cache, selector)).email;
|
|
973
1092
|
const config = await readListeners();
|
|
974
1093
|
const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
|
|
975
|
-
const
|
|
1094
|
+
const using = plan.dataDir ? browsersUsing(plan.dataDir) : { exact: [], ambiguous: [] };
|
|
1095
|
+
const running = using.exact;
|
|
976
1096
|
console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
|
|
977
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}"`);
|
|
978
1098
|
console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
|
|
979
1099
|
if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
|
|
980
1100
|
else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
|
|
981
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.`);
|
|
982
1105
|
const interactive = isInteractive();
|
|
983
1106
|
if (!opts.yes) {
|
|
984
1107
|
if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
|
|
@@ -989,8 +1112,8 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
989
1112
|
// In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
|
|
990
1113
|
if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
|
|
991
1114
|
for (const pid of running) process.kill(pid, "SIGTERM");
|
|
992
|
-
for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
|
|
993
|
-
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.");
|
|
994
1117
|
}
|
|
995
1118
|
if (config && plan.listeners.length) {
|
|
996
1119
|
for (const edit of plan.listeners) {
|
|
@@ -1011,7 +1134,7 @@ async function removeProfile(selector: string, opts: { yes?: boolean; close?: bo
|
|
|
1011
1134
|
}
|
|
1012
1135
|
let moved: string | null = null;
|
|
1013
1136
|
if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
|
|
1014
|
-
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.` : ""}`);
|
|
1015
1138
|
}
|
|
1016
1139
|
|
|
1017
1140
|
/** On a client of a remote host, `rech profile` lists what that host's link shares. */
|
|
@@ -1930,9 +2053,48 @@ function oxmgrVersion(bin: string): string | null {
|
|
|
1930
2053
|
// RECH_DAEMON_MANAGER override — lives in ./daemon-manager.ts (pure + tested);
|
|
1931
2054
|
// here we just supply the runtime inputs (what's on PATH, the oxmgr version).
|
|
1932
2055
|
let _daemonMgr: DaemonManager | undefined;
|
|
2056
|
+
/**
|
|
2057
|
+
* The first oxmgr that actually runs. PATH order alone isn't enough: a stale global shim
|
|
2058
|
+
* (e.g. bun's, printing "oxmgr binary is missing") can shadow a working build, and npm's
|
|
2059
|
+
* `oxmgr.cmd` re-parses argv through cmd.exe, so real executables come first. RECH_OXMGR
|
|
2060
|
+
* names one explicitly (a path, or a command looked up on PATH). The successful probe's
|
|
2061
|
+
* output seeds the version cache, so `--version` runs once.
|
|
2062
|
+
*/
|
|
2063
|
+
function findWorkingOxmgr(): string | null {
|
|
2064
|
+
const ext = IS_WINDOWS ? ".exe" : "";
|
|
2065
|
+
// Windows PATH entries may be quoted ("C:\Program Files\x"); quotes break the joined path.
|
|
2066
|
+
const dirs = (process.env.PATH ?? "").split(IS_WINDOWS ? ";" : ":").map(d => d.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
|
|
2067
|
+
const override = process.env.RECH_OXMGR;
|
|
2068
|
+
const candidates = [
|
|
2069
|
+
override && (/[\\/]/.test(override) ? resolve(override) : Bun.which(override)),
|
|
2070
|
+
...dirs.map(d => join(d, `oxmgr${ext}`)),
|
|
2071
|
+
// The vendored binary inside a global npm / bun install of the `oxmgr` package
|
|
2072
|
+
// (npm: <dir>/node_modules on Windows, <prefix>/lib/node_modules beside <prefix>/bin elsewhere).
|
|
2073
|
+
...dirs.map(d => join(d, "node_modules", "oxmgr", "vendor", `oxmgr${ext}`)),
|
|
2074
|
+
...(IS_WINDOWS ? [] : dirs.map(d => join(d, "..", "lib", "node_modules", "oxmgr", "vendor", "oxmgr"))),
|
|
2075
|
+
join(HOME, ".bun", "install", "global", "node_modules", "oxmgr", "vendor", `oxmgr${ext}`),
|
|
2076
|
+
].filter((p): p is string => !!p && existsSync(p));
|
|
2077
|
+
const works = (p: string) => {
|
|
2078
|
+
try {
|
|
2079
|
+
const r = Bun.spawnSync([p, "--version"], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
2080
|
+
if (r.exitCode !== 0) return false;
|
|
2081
|
+
const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(r.stdout?.toString() ?? "");
|
|
2082
|
+
_oxmgrVersion = m ? m[1]! : null;
|
|
2083
|
+
return true;
|
|
2084
|
+
} catch { return false; }
|
|
2085
|
+
};
|
|
2086
|
+
const exe = [...new Set(candidates)].find(works);
|
|
2087
|
+
if (exe) return exe;
|
|
2088
|
+
// Last resort (Windows): a working shim such as npm's oxmgr.cmd, but never over a working
|
|
2089
|
+
// pm2 — cmd.exe re-parses a shim's argv (see runPm).
|
|
2090
|
+
if (!IS_WINDOWS || Bun.which("pm2")) return null;
|
|
2091
|
+
const shim = Bun.which("oxmgr");
|
|
2092
|
+
return shim && works(shim) ? shim : null;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1933
2095
|
function daemonManager(): DaemonManager {
|
|
1934
2096
|
if (_daemonMgr) return _daemonMgr;
|
|
1935
|
-
const oxmgrBin =
|
|
2097
|
+
const oxmgrBin = findWorkingOxmgr();
|
|
1936
2098
|
const pm2Bin = Bun.which("pm2");
|
|
1937
2099
|
_daemonMgr = pickDaemonManager({
|
|
1938
2100
|
oxmgrBin,
|
|
@@ -1951,6 +2113,10 @@ function daemonManager(): DaemonManager {
|
|
|
1951
2113
|
// the managed process (it has no per-var flag like oxmgr's --env), so install
|
|
1952
2114
|
// passes daemon env this way.
|
|
1953
2115
|
async function runPm(mgr: DaemonManager, args: string[], env?: Record<string, string>): Promise<number> {
|
|
2116
|
+
// A .cmd/.bat shim runs through cmd.exe, which treats & | < > ^ in an argument as syntax
|
|
2117
|
+
// (e.g. the &s in a RECHROME_URL passed with --env): fail clearly instead of mangling it.
|
|
2118
|
+
if (/\.(cmd|bat)$/i.test(mgr.bin) && args.some(a => /[&|<>^]/.test(a)))
|
|
2119
|
+
throw new Error(`${mgr.bin} is a cmd.exe shim, which would mangle an argument containing & | < > ^. Install the ${mgr.id} executable (or set RECH_OXMGR to it) and try again.`);
|
|
1954
2120
|
const proc = Bun.spawn([mgr.bin, ...args], {
|
|
1955
2121
|
stdout: "inherit",
|
|
1956
2122
|
stderr: "inherit",
|
|
@@ -2053,7 +2219,7 @@ async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<str
|
|
|
2053
2219
|
});
|
|
2054
2220
|
const code = await proc.exited;
|
|
2055
2221
|
if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
|
|
2056
|
-
if (!
|
|
2222
|
+
if (!findWorkingOxmgr()) {
|
|
2057
2223
|
throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
|
|
2058
2224
|
}
|
|
2059
2225
|
_daemonMgr = undefined;
|
|
@@ -2172,7 +2338,7 @@ async function daemonUninstall(): Promise<void> {
|
|
|
2172
2338
|
const mgr = daemonManager();
|
|
2173
2339
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
|
|
2174
2340
|
if (mgr.id === "pm2") await runPm(mgr, ["save", "--force"]); // an emptied list must still overwrite the dump
|
|
2175
|
-
|
|
2341
|
+
// oxmgr's boot/login service is left installed: every other oxmgr-managed process depends on it.
|
|
2176
2342
|
console.log(`Removed ${mgr.id} process: ${PM_PROCESS_NAME}`);
|
|
2177
2343
|
}
|
|
2178
2344
|
|
package/serve.js
CHANGED
|
@@ -496,7 +496,7 @@ export async function serve() {
|
|
|
496
496
|
if (certPath && keyPath) {
|
|
497
497
|
const renewed = await renewCertIfNeeded(certPath, keyPath);
|
|
498
498
|
if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
499
|
-
// Check daily; pm2 restarts cleanly after exit(0)
|
|
499
|
+
// Check daily; the process manager (oxmgr --restart always, or pm2) restarts cleanly after exit(0)
|
|
500
500
|
setInterval(async () => {
|
|
501
501
|
if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
502
502
|
}, 86_400_000);
|
package/serve.ts
CHANGED
|
@@ -496,7 +496,7 @@ export async function serve() {
|
|
|
496
496
|
if (certPath && keyPath) {
|
|
497
497
|
const renewed = await renewCertIfNeeded(certPath, keyPath);
|
|
498
498
|
if (renewed) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
499
|
-
// Check daily; pm2 restarts cleanly after exit(0)
|
|
499
|
+
// Check daily; the process manager (oxmgr --restart always, or pm2) restarts cleanly after exit(0)
|
|
500
500
|
setInterval(async () => {
|
|
501
501
|
if (await renewCertIfNeeded(certPath, keyPath)) { log("Restarting to load renewed TLS cert..."); process.exit(0); }
|
|
502
502
|
}, 86_400_000);
|