rechrome 1.46.0 → 1.47.0

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
@@ -56,6 +56,9 @@ rech tab-list
56
56
  rech eval "() => document.title"
57
57
  rech --profile work@example.com open https://example.com # another registered profile
58
58
  rech --isolate open https://accounts.example.com # throwaway session, e.g. for a login flow
59
+ rech --isolate=login open https://accounts.example.com # named isolated session: reused by later
60
+ rech --isolate=login fill-secret e12 --from-env PASSWORD # --isolate=login commands (same tabs)
61
+ rech session close login # until you close it (rech session ls lists it)
59
62
  ```
60
63
 
61
64
  Any [playwright-cli](https://github.com/snomiao/playwright-cli) command works after `rech`. When a
@@ -545,7 +548,11 @@ Each client gets its own browser session, keyed by the **git worktree root path*
545
548
  roll up to their parent), or the current directory outside git. So two worktrees of one repo
546
549
  get separate sessions, and `git checkout` keeps you in the same one. `RECH_IDENTITY=branch`
547
550
  restores the older `<remote>/tree/<branch>` keying and `RECH_IDENTITY=cwd` keys on the exact
548
- directory. Pass `-s=<name>` for a named sub-session, or `--isolate` for a throwaway one.
551
+ directory. Pass `-s=<name>` for a named sub-session, `--isolate` for a throwaway one (a new session per
552
+ command), or `--isolate=<name>` for a named isolated session (`-s=iso_<name>`) that later
553
+ `--isolate=<name>` commands from the same worktree reuse until `rech session close <name>`.
554
+ An isolated session is its own tab group in the same Chrome profile: it shares the profile's
555
+ cookies, so profile-scoped listeners still refuse `eval`, `run-code` and other arbitrary code in it.
549
556
  A share device's sessions are additionally keyed by its share and device, so they never meet
550
557
  another device's or a listener link's; `rech session ls` lists the ones this link drove.
551
558
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.46.0",
3
+ "version": "1.47.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -2059,7 +2059,7 @@ export function withDefaultScheme(target: string): string {
2059
2059
  // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
2060
2060
  // missing value; accepts multiple occurrences (last one wins).
2061
2061
  /**
2062
- * `rech [--profile X] [--isolate] pw <args>` forwards <args> to playwright-cli verbatim, so
2062
+ * `rech [--profile X] [--isolate[=<name>]] pw <args>` forwards <args> to playwright-cli verbatim, so
2063
2063
  * `rech pw --version` or `rech pw status` reach playwright instead of rech. `--` works the
2064
2064
  * same after a rech flag (`rech --profile X -- status`); a bare leading `--` cannot, because
2065
2065
  * Bun consumes the `--` right after the script path. The separator counts only when
@@ -2071,12 +2071,32 @@ export function rechSeparatorIndex(args: string[]): number {
2071
2071
  const a = args[i];
2072
2072
  if (a === "--" || a === "pw" || a === "playwright") return i;
2073
2073
  if (a === "--profile") { i++; continue; }
2074
- if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated") continue;
2074
+ if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a)) continue;
2075
2075
  return -1;
2076
2076
  }
2077
2077
  return -1;
2078
2078
  }
2079
2079
 
2080
+ /** Prefix of a named isolated session (`--isolate=<name>` -> `-s=iso_<name>`); `_` keeps it out of the iso-<hex> reaper. */
2081
+ export const NAMED_ISO_PREFIX = "iso_";
2082
+
2083
+ /**
2084
+ * Turn rech's `--isolate` / `--isolate=<name>` into a session flag. Bare `--isolate` is a
2085
+ * throwaway `-s=iso-<random>` (reaped when idle); `--isolate=<name>` is the persistent
2086
+ * `-s=iso_<name>`, reused by later commands with the same name until `rech session close <name>`.
2087
+ */
2088
+ export function applyIsolateArg(args: string[]): string[] {
2089
+ const idx = args.findIndex(a => a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a));
2090
+ if (idx === -1) return args;
2091
+ const flag = args[idx];
2092
+ const rest = args.filter((_, i) => i !== idx);
2093
+ if (!flag.includes("=")) return [...rest, `-s=iso-${randomBytes(8).toString("hex")}`];
2094
+ const name = flag.slice(flag.indexOf("=") + 1);
2095
+ if (!/^[a-zA-Z0-9_-]{1,60}$/.test(name)) throw new Error(`--isolate=<name> takes 1-60 letters, digits, '-' or '_' (got "${name}")`);
2096
+ if (rest.some(a => a.startsWith("-s="))) throw new Error("Pass --isolate=<name> or -s=<session>, not both.");
2097
+ return [...rest, `-s=${NAMED_ISO_PREFIX}${name}`];
2098
+ }
2099
+
2080
2100
  /** rechrome's own version, from the package.json shipped next to this file. */
2081
2101
  export function rechromeVersion(): string {
2082
2102
  try { return JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")).version ?? "unknown"; }
@@ -4585,22 +4605,40 @@ async function bridgeCommand(): Promise<void> {
4585
4605
 
4586
4606
  // ---- sessions (client view) -----------------------------------------------------------------
4587
4607
 
4608
+ type SessionRow = { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean };
4609
+
4610
+ async function fetchSessions(url: string): Promise<{ res: Response; body: { sessions?: SessionRow[]; stderr?: string } | null }> {
4611
+ const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
4612
+ return { res, body: await res.json().catch(() => null) as { sessions?: SessionRow[]; stderr?: string } | null };
4613
+ }
4614
+
4615
+ /** `name` as given, unless only the named isolated session `iso_<name>` exists. */
4616
+ export function pickSessionName(name: string, known: string[]): string {
4617
+ if (name.startsWith(NAMED_ISO_PREFIX) || known.includes(name)) return name;
4618
+ return known.includes(NAMED_ISO_PREFIX + name) ? NAMED_ISO_PREFIX + name : name;
4619
+ }
4620
+
4621
+ async function resolveSessionName(url: string, name: string): Promise<string> {
4622
+ const known = await fetchSessions(url).then(({ body }) => (body?.sessions ?? []).map(s => s.session)).catch(() => [] as string[]);
4623
+ return pickSessionName(name, known);
4624
+ }
4625
+
4588
4626
  async function sessionCommand(action: string, name?: string): Promise<void> {
4589
4627
  const raw = process.env[ENV_KEY];
4590
4628
  if (!raw) throw new Error(notConnectedMessage());
4591
4629
  const url = await transportUrl(raw);
4592
4630
  if (action === "close") {
4593
4631
  if (!name) throw new Error("Which session? rech session close <name> (see rech session ls)");
4594
- return run(url, ["close", `-s=${name}`]);
4632
+ // A named isolated session closes by its --isolate name too (`login` for iso_login).
4633
+ return run(url, ["close", `-s=${await resolveSessionName(url, name)}`]);
4595
4634
  }
4596
- const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
4597
- const body = await res.json().catch(() => null) as { sessions?: { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean }[]; stderr?: string } | null;
4635
+ const { res, body } = await fetchSessions(url);
4598
4636
  if (!res.ok || !body?.sessions) throw new Error(body?.stderr?.trim() || `The host does not list sessions (HTTP ${res.status}); it may run an older rech.`);
4599
4637
  if (!body.sessions.length) { console.log("No sessions yet (since the host's daemon started)."); return; }
4600
4638
  const rows = [["SESSION", "PROFILE", "CLIENT", "LAST USED", "STATE"]];
4601
4639
  for (const s of body.sessions) rows.push([s.session, s.profile ?? "-", s.client ?? "-", fmtDate(s.lastUsed), s.live ? "open" : "closed"]);
4602
4640
  printTable(rows);
4603
- console.log(`\nUse one: rech -s=<session> open https://example.com · close it: rech session close <session>`);
4641
+ console.log(`\nUse one: rech -s=<session> open https://example.com (an ${NAMED_ISO_PREFIX}<name> session: rech --isolate=<name> …) · close it: rech session close <session>`);
4604
4642
  }
4605
4643
 
4606
4644
  async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
@@ -4731,10 +4769,12 @@ const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from s
4731
4769
  Usage: rech <command> [options] · rech <browser-command> [args]`;
4732
4770
 
4733
4771
  const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4734
- rech [--profile <p>] [--isolate] <browser-command> [args]
4772
+ rech [--profile <p>] [--isolate[=<name>]] <browser-command> [args]
4735
4773
  open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
4736
4774
  --profile <p> as another registered profile (email, name or folder); put it first
4737
- --isolate in a throwaway session, e.g. for a login flow
4775
+ --isolate in a throwaway session (a new one per command)
4776
+ --isolate=<n> in the named isolated session <n>: made on first use, reused by later
4777
+ commands (same tabs, same page) until \`rech session close <n>\`
4738
4778
  rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
4739
4779
  type a password / TOTP code without it reaching argv, logs or output
4740
4780
  rech read-secret <ref> --allow-domain <host> | <vault command>
@@ -4894,7 +4934,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4894
4934
  .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
4895
4935
  a => handlers.removeListener(a.name))
4896
4936
  .demandCommand(1).strict())
4897
- .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name>", y => y
4937
+ .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name> (also an --isolate=<name> session)", y => y
4898
4938
  .positional("action", { type: "string", choices: ["ls", "list", "close"], default: "ls" })
4899
4939
  .positional("name", { type: "string", describe: "With close: the session (as `rech session ls` shows it)" }),
4900
4940
  a => handlers.session(a.action === "list" ? "ls" : a.action!, a.name))
@@ -5041,10 +5081,13 @@ if (import.meta.main) {
5041
5081
  // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
5042
5082
  // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.js), so an
5043
5083
  // OAuth drive can't leak an orphaned browser context.
5044
- const isolateIdx = args.findIndex((a) => a === "--isolate" || a === "--isolated");
5045
- if (isolateIdx !== -1) {
5046
- args.splice(isolateIdx, 1);
5047
- args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
5084
+ // --isolate=<name>: a named isolated session (-s=iso_<name>), kept across commands until
5085
+ // `rech session close <name>`, for multi-step flows (email, password, TOTP).
5086
+ try { args = applyIsolateArg(args); }
5087
+ catch (err) {
5088
+ console.error(err instanceof Error ? err.message : String(err));
5089
+ envWatcher?.close();
5090
+ process.exit(1);
5048
5091
  }
5049
5092
  args = [...forwarded, ...args];
5050
5093
  await run(url, args, overrideEnv, { verbatim: separator !== -1 });
package/rechrome.ts CHANGED
@@ -2059,7 +2059,7 @@ export function withDefaultScheme(target: string): string {
2059
2059
  // `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
2060
2060
  // missing value; accepts multiple occurrences (last one wins).
2061
2061
  /**
2062
- * `rech [--profile X] [--isolate] pw <args>` forwards <args> to playwright-cli verbatim, so
2062
+ * `rech [--profile X] [--isolate[=<name>]] pw <args>` forwards <args> to playwright-cli verbatim, so
2063
2063
  * `rech pw --version` or `rech pw status` reach playwright instead of rech. `--` works the
2064
2064
  * same after a rech flag (`rech --profile X -- status`); a bare leading `--` cannot, because
2065
2065
  * Bun consumes the `--` right after the script path. The separator counts only when
@@ -2071,12 +2071,32 @@ export function rechSeparatorIndex(args: string[]): number {
2071
2071
  const a = args[i];
2072
2072
  if (a === "--" || a === "pw" || a === "playwright") return i;
2073
2073
  if (a === "--profile") { i++; continue; }
2074
- if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated") continue;
2074
+ if (a.startsWith("--profile=") || a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a)) continue;
2075
2075
  return -1;
2076
2076
  }
2077
2077
  return -1;
2078
2078
  }
2079
2079
 
2080
+ /** Prefix of a named isolated session (`--isolate=<name>` -> `-s=iso_<name>`); `_` keeps it out of the iso-<hex> reaper. */
2081
+ export const NAMED_ISO_PREFIX = "iso_";
2082
+
2083
+ /**
2084
+ * Turn rech's `--isolate` / `--isolate=<name>` into a session flag. Bare `--isolate` is a
2085
+ * throwaway `-s=iso-<random>` (reaped when idle); `--isolate=<name>` is the persistent
2086
+ * `-s=iso_<name>`, reused by later commands with the same name until `rech session close <name>`.
2087
+ */
2088
+ export function applyIsolateArg(args: string[]): string[] {
2089
+ const idx = args.findIndex(a => a === "--isolate" || a === "--isolated" || /^--isolated?=/.test(a));
2090
+ if (idx === -1) return args;
2091
+ const flag = args[idx];
2092
+ const rest = args.filter((_, i) => i !== idx);
2093
+ if (!flag.includes("=")) return [...rest, `-s=iso-${randomBytes(8).toString("hex")}`];
2094
+ const name = flag.slice(flag.indexOf("=") + 1);
2095
+ if (!/^[a-zA-Z0-9_-]{1,60}$/.test(name)) throw new Error(`--isolate=<name> takes 1-60 letters, digits, '-' or '_' (got "${name}")`);
2096
+ if (rest.some(a => a.startsWith("-s="))) throw new Error("Pass --isolate=<name> or -s=<session>, not both.");
2097
+ return [...rest, `-s=${NAMED_ISO_PREFIX}${name}`];
2098
+ }
2099
+
2080
2100
  /** rechrome's own version, from the package.json shipped next to this file. */
2081
2101
  export function rechromeVersion(): string {
2082
2102
  try { return JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")).version ?? "unknown"; }
@@ -4585,22 +4605,40 @@ async function bridgeCommand(): Promise<void> {
4585
4605
 
4586
4606
  // ---- sessions (client view) -----------------------------------------------------------------
4587
4607
 
4608
+ type SessionRow = { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean };
4609
+
4610
+ async function fetchSessions(url: string): Promise<{ res: Response; body: { sessions?: SessionRow[]; stderr?: string } | null }> {
4611
+ const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
4612
+ return { res, body: await res.json().catch(() => null) as { sessions?: SessionRow[]; stderr?: string } | null };
4613
+ }
4614
+
4615
+ /** `name` as given, unless only the named isolated session `iso_<name>` exists. */
4616
+ export function pickSessionName(name: string, known: string[]): string {
4617
+ if (name.startsWith(NAMED_ISO_PREFIX) || known.includes(name)) return name;
4618
+ return known.includes(NAMED_ISO_PREFIX + name) ? NAMED_ISO_PREFIX + name : name;
4619
+ }
4620
+
4621
+ async function resolveSessionName(url: string, name: string): Promise<string> {
4622
+ const known = await fetchSessions(url).then(({ body }) => (body?.sessions ?? []).map(s => s.session)).catch(() => [] as string[]);
4623
+ return pickSessionName(name, known);
4624
+ }
4625
+
4588
4626
  async function sessionCommand(action: string, name?: string): Promise<void> {
4589
4627
  const raw = process.env[ENV_KEY];
4590
4628
  if (!raw) throw new Error(notConnectedMessage());
4591
4629
  const url = await transportUrl(raw);
4592
4630
  if (action === "close") {
4593
4631
  if (!name) throw new Error("Which session? rech session close <name> (see rech session ls)");
4594
- return run(url, ["close", `-s=${name}`]);
4632
+ // A named isolated session closes by its --isolate name too (`login` for iso_login).
4633
+ return run(url, ["close", `-s=${await resolveSessionName(url, name)}`]);
4595
4634
  }
4596
- const res = await fetch(serviceUrl(url, "sessions"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(10_000) });
4597
- const body = await res.json().catch(() => null) as { sessions?: { session: string; profile?: string; client?: string; owner?: string; lastUsed: number; live: boolean }[]; stderr?: string } | null;
4635
+ const { res, body } = await fetchSessions(url);
4598
4636
  if (!res.ok || !body?.sessions) throw new Error(body?.stderr?.trim() || `The host does not list sessions (HTTP ${res.status}); it may run an older rech.`);
4599
4637
  if (!body.sessions.length) { console.log("No sessions yet (since the host's daemon started)."); return; }
4600
4638
  const rows = [["SESSION", "PROFILE", "CLIENT", "LAST USED", "STATE"]];
4601
4639
  for (const s of body.sessions) rows.push([s.session, s.profile ?? "-", s.client ?? "-", fmtDate(s.lastUsed), s.live ? "open" : "closed"]);
4602
4640
  printTable(rows);
4603
- console.log(`\nUse one: rech -s=<session> open https://example.com · close it: rech session close <session>`);
4641
+ console.log(`\nUse one: rech -s=<session> open https://example.com (an ${NAMED_ISO_PREFIX}<name> session: rech --isolate=<name> …) · close it: rech session close <session>`);
4604
4642
  }
4605
4643
 
4606
4644
  async function signalServe(opts: { host?: string; port?: number }): Promise<void> {
@@ -4731,10 +4769,12 @@ const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from s
4731
4769
  Usage: rech <command> [options] · rech <browser-command> [args]`;
4732
4770
 
4733
4771
  const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4734
- rech [--profile <p>] [--isolate] <browser-command> [args]
4772
+ rech [--profile <p>] [--isolate[=<name>]] <browser-command> [args]
4735
4773
  open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
4736
4774
  --profile <p> as another registered profile (email, name or folder); put it first
4737
- --isolate in a throwaway session, e.g. for a login flow
4775
+ --isolate in a throwaway session (a new one per command)
4776
+ --isolate=<n> in the named isolated session <n>: made on first use, reused by later
4777
+ commands (same tabs, same page) until \`rech session close <n>\`
4738
4778
  rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
4739
4779
  type a password / TOTP code without it reaching argv, logs or output
4740
4780
  rech read-secret <ref> --allow-domain <host> | <vault command>
@@ -4894,7 +4934,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4894
4934
  .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
4895
4935
  a => handlers.removeListener(a.name))
4896
4936
  .demandCommand(1).strict())
4897
- .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name>", y => y
4937
+ .command(["session [action] [name]", "sessions [action] [name]"], "This link's browser sessions on the host: ls (default) | close <name> (also an --isolate=<name> session)", y => y
4898
4938
  .positional("action", { type: "string", choices: ["ls", "list", "close"], default: "ls" })
4899
4939
  .positional("name", { type: "string", describe: "With close: the session (as `rech session ls` shows it)" }),
4900
4940
  a => handlers.session(a.action === "list" ? "ls" : a.action!, a.name))
@@ -5041,10 +5081,13 @@ if (import.meta.main) {
5041
5081
  // flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
5042
5082
  // marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
5043
5083
  // OAuth drive can't leak an orphaned browser context.
5044
- const isolateIdx = args.findIndex((a) => a === "--isolate" || a === "--isolated");
5045
- if (isolateIdx !== -1) {
5046
- args.splice(isolateIdx, 1);
5047
- args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
5084
+ // --isolate=<name>: a named isolated session (-s=iso_<name>), kept across commands until
5085
+ // `rech session close <name>`, for multi-step flows (email, password, TOTP).
5086
+ try { args = applyIsolateArg(args); }
5087
+ catch (err) {
5088
+ console.error(err instanceof Error ? err.message : String(err));
5089
+ envWatcher?.close();
5090
+ process.exit(1);
5048
5091
  }
5049
5092
  args = [...forwarded, ...args];
5050
5093
  await run(url, args, overrideEnv, { verbatim: separator !== -1 });