rechrome 1.25.0 → 1.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -5,7 +5,7 @@ import { file } from "bun";
5
5
  import yargs from "yargs";
6
6
  import { readExtensionTokenFromProfile } from "./extension-token.js";
7
7
  import { randomBytes } from "crypto";
8
- import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
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
11
  import { join, basename, dirname } from "path";
@@ -16,14 +16,16 @@ import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./da
16
16
 
17
17
  export const ENV_KEY = "RECHROME_URL";
18
18
  export const DEFAULT_PORT = 13775;
19
- export const RECH_DIR = join(import.meta.dir, ".rech");
20
- export const LOG_DIR = join(RECH_DIR, "logs");
21
-
22
19
  // Home dir: HOME on POSIX, USERPROFILE on Windows (handled by os.homedir()).
23
20
  export const HOME = homedir();
24
21
 
25
- const RECH_HOME_DIR = join(HOME, ".rechrome");
26
- const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.yaml");
22
+ // All rechrome state (registry, listeners, extension, logs, daemon output) lives in
23
+ // ~/.rechrome, never next to the code: a bunx/npx install dir is a disposable cache.
24
+ export const RECH_DIR = join(HOME, ".rechrome");
25
+ export const LOG_DIR = join(RECH_DIR, "logs");
26
+ // Before the rename, logs and daemon output lived in <install dir>/.rech.
27
+ export const LEGACY_RECH_DIR = join(import.meta.dir, ".rech");
28
+ const TOKENS_FILE = join(RECH_DIR, "profiles.yaml");
27
29
 
28
30
  type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
29
31
 
@@ -38,7 +40,7 @@ function validateTokenRegistry(value: unknown): Record<string, TokenEntry> {
38
40
  return value as Record<string, TokenEntry>;
39
41
  }
40
42
 
41
- export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_HOME_DIR): Promise<void> {
43
+ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_DIR): Promise<void> {
42
44
  validateTokenRegistry(registry);
43
45
  await mkdir(directory, { recursive: true });
44
46
  const path = join(directory, "profiles.yaml");
@@ -51,7 +53,7 @@ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, d
51
53
  }
52
54
  }
53
55
 
54
- export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Record<string, TokenEntry>> {
56
+ export async function readTokenRegistry(directory = RECH_DIR): Promise<Record<string, TokenEntry>> {
55
57
  const path = join(directory, "profiles.yaml");
56
58
  let raw: string;
57
59
  try { raw = await readFile(path, "utf8"); }
@@ -74,7 +76,7 @@ export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Reco
74
76
  }
75
77
 
76
78
  async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
77
- mkdirSync(RECH_HOME_DIR, { recursive: true });
79
+ mkdirSync(RECH_DIR, { recursive: true });
78
80
  const registry = await readTokenRegistry();
79
81
  registry[profileEmail] = entry;
80
82
  await writeTokenRegistry(registry);
@@ -241,6 +243,31 @@ export function log(msg: string) {
241
243
  appendFileSync(logFile, line);
242
244
  }
243
245
 
246
+ /**
247
+ * Move logs and daemon output from the legacy <install dir>/.rech into RECH_DIR, entry by
248
+ * entry. An entry already present in RECH_DIR is kept and the legacy copy left in place,
249
+ * so nothing is overwritten. Best effort: a cross-device rename just leaves the old copy.
250
+ */
251
+ export function migrateLegacyDataDir(legacy = LEGACY_RECH_DIR, target = RECH_DIR): string[] {
252
+ const moved: string[] = [];
253
+ if (!existsSync(legacy)) return moved;
254
+ mkdirSync(target, { recursive: true });
255
+ if (realpathSync(legacy) === realpathSync(target)) return moved;
256
+ for (const sub of ["logs", "output"]) {
257
+ const from = join(legacy, sub);
258
+ if (!existsSync(from)) continue;
259
+ mkdirSync(join(target, sub), { recursive: true });
260
+ for (const entry of readdirSync(from)) {
261
+ const to = join(target, sub, entry);
262
+ if (existsSync(to)) continue;
263
+ try { renameSync(join(from, entry), to); moved.push(`${sub}/${entry}`); } catch { /* keep the legacy copy */ }
264
+ }
265
+ }
266
+ // Remove only directories left empty (the unused tls/ included); rmdir refuses non-empty ones.
267
+ for (const dir of ["logs", "output", "tls", ""]) { try { rmdirSync(join(legacy, dir)); } catch { /* not empty */ } }
268
+ return moved;
269
+ }
270
+
244
271
  export function parseUrl(raw: string) {
245
272
  const u = new URL(raw);
246
273
  const fragment = new URLSearchParams(u.hash.slice(1).replace(/^\?/, ""));
@@ -482,7 +509,15 @@ export function deriveIdentity(opts: {
482
509
  return { key, label };
483
510
  }
484
511
 
485
- async function getClientIdentity(): Promise<{ key: string; label: string; profile?: string }> {
512
+ /**
513
+ * A project's own rechrome folder: <root>/.rechrome, where root is the same one the session
514
+ * key uses (the worktree root, submodules rolled up), or cwd in `cwd` mode / outside git.
515
+ */
516
+ export function projectDataDir(opts: { mode: string; cwd: string; root?: string | null }): string {
517
+ return join(opts.mode === "cwd" ? opts.cwd : opts.root || opts.cwd, ".rechrome");
518
+ }
519
+
520
+ async function getClientIdentity(): Promise<{ key: string; label: string; dataDir: string; profile?: string }> {
486
521
  const cwd = realpathSafe(process.cwd());
487
522
  const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
488
523
  let root: string | null = null;
@@ -508,7 +543,7 @@ async function getClientIdentity(): Promise<{ key: string; label: string; profil
508
543
  if (remoteUrl) remote = normalizeRemote(remoteUrl);
509
544
  }
510
545
 
511
- return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
546
+ return { ...deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch }), dataDir: projectDataDir({ mode, cwd, root }) };
512
547
  }
513
548
 
514
549
  // Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
@@ -902,7 +937,8 @@ async function callServe(
902
937
  const res = await fetch(serviceUrl(url, "run"), {
903
938
  method: "POST",
904
939
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
905
- body: JSON.stringify({ args, identity, env }),
940
+ // dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
941
+ body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env }),
906
942
  signal: AbortSignal.timeout(70_000),
907
943
  }).catch(async (e) => {
908
944
  if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
@@ -1018,9 +1054,11 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1018
1054
  if (stdout) process.stdout.write(stdout);
1019
1055
 
1020
1056
  if (files?.length) {
1021
- const dlDir = join(process.cwd(), ".playwright-cli-multi-tab");
1057
+ // Saved files belong to the project: <project>/.rechrome/output. The folder also holds
1058
+ // .env.local (secrets), so it is git-ignored as a whole when first created.
1059
+ const dlDir = join(identity.dataDir, "output");
1022
1060
  mkdirSync(dlDir, { recursive: true });
1023
- const gitignorePath = join(dlDir, ".gitignore");
1061
+ const gitignorePath = join(identity.dataDir, ".gitignore");
1024
1062
  if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
1025
1063
  for (const name of files) {
1026
1064
  const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
@@ -1467,7 +1505,7 @@ async function daemonUninstall(): Promise<void> {
1467
1505
  // locate the binary and launch it detached (singleton via a pidfile).
1468
1506
  // `rech tray hide` / the menu "Hide" item both kill the process;
1469
1507
  // `rech tray show` starts a fresh one.
1470
- const TRAY_PID_FILE = join(RECH_HOME_DIR, "tray.pid");
1508
+ const TRAY_PID_FILE = join(RECH_DIR, "tray.pid");
1471
1509
 
1472
1510
  // A desktop GUI must be present. Linux needs an X11/Wayland display; a headless
1473
1511
  // box (SSH, CI, container) has neither, so the tray is skipped. macOS/Windows
@@ -1729,7 +1767,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1729
1767
  process.exit(1);
1730
1768
  }
1731
1769
  const dist = await ensureExtensionDistInstalled();
1732
- const userDataDir = join(RECH_HOME_DIR, "profiles", name);
1770
+ const userDataDir = join(RECH_DIR, "profiles", name);
1733
1771
  const token = randomBytes(32).toString("base64url");
1734
1772
 
1735
1773
  console.log(`\n[1/3] Provisioning managed profile "${name}"`);
package/rechrome.ts CHANGED
@@ -5,7 +5,7 @@ import { file } from "bun";
5
5
  import yargs from "yargs";
6
6
  import { readExtensionTokenFromProfile } from "./extension-token.ts";
7
7
  import { randomBytes } from "crypto";
8
- import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
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
11
  import { join, basename, dirname } from "path";
@@ -16,14 +16,16 @@ import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./da
16
16
 
17
17
  export const ENV_KEY = "RECHROME_URL";
18
18
  export const DEFAULT_PORT = 13775;
19
- export const RECH_DIR = join(import.meta.dir, ".rech");
20
- export const LOG_DIR = join(RECH_DIR, "logs");
21
-
22
19
  // Home dir: HOME on POSIX, USERPROFILE on Windows (handled by os.homedir()).
23
20
  export const HOME = homedir();
24
21
 
25
- const RECH_HOME_DIR = join(HOME, ".rechrome");
26
- const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.yaml");
22
+ // All rechrome state (registry, listeners, extension, logs, daemon output) lives in
23
+ // ~/.rechrome, never next to the code: a bunx/npx install dir is a disposable cache.
24
+ export const RECH_DIR = join(HOME, ".rechrome");
25
+ export const LOG_DIR = join(RECH_DIR, "logs");
26
+ // Before the rename, logs and daemon output lived in <install dir>/.rech.
27
+ export const LEGACY_RECH_DIR = join(import.meta.dir, ".rech");
28
+ const TOKENS_FILE = join(RECH_DIR, "profiles.yaml");
27
29
 
28
30
  type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
29
31
 
@@ -38,7 +40,7 @@ function validateTokenRegistry(value: unknown): Record<string, TokenEntry> {
38
40
  return value as Record<string, TokenEntry>;
39
41
  }
40
42
 
41
- export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_HOME_DIR): Promise<void> {
43
+ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_DIR): Promise<void> {
42
44
  validateTokenRegistry(registry);
43
45
  await mkdir(directory, { recursive: true });
44
46
  const path = join(directory, "profiles.yaml");
@@ -51,7 +53,7 @@ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, d
51
53
  }
52
54
  }
53
55
 
54
- export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Record<string, TokenEntry>> {
56
+ export async function readTokenRegistry(directory = RECH_DIR): Promise<Record<string, TokenEntry>> {
55
57
  const path = join(directory, "profiles.yaml");
56
58
  let raw: string;
57
59
  try { raw = await readFile(path, "utf8"); }
@@ -74,7 +76,7 @@ export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Reco
74
76
  }
75
77
 
76
78
  async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
77
- mkdirSync(RECH_HOME_DIR, { recursive: true });
79
+ mkdirSync(RECH_DIR, { recursive: true });
78
80
  const registry = await readTokenRegistry();
79
81
  registry[profileEmail] = entry;
80
82
  await writeTokenRegistry(registry);
@@ -241,6 +243,31 @@ export function log(msg: string) {
241
243
  appendFileSync(logFile, line);
242
244
  }
243
245
 
246
+ /**
247
+ * Move logs and daemon output from the legacy <install dir>/.rech into RECH_DIR, entry by
248
+ * entry. An entry already present in RECH_DIR is kept and the legacy copy left in place,
249
+ * so nothing is overwritten. Best effort: a cross-device rename just leaves the old copy.
250
+ */
251
+ export function migrateLegacyDataDir(legacy = LEGACY_RECH_DIR, target = RECH_DIR): string[] {
252
+ const moved: string[] = [];
253
+ if (!existsSync(legacy)) return moved;
254
+ mkdirSync(target, { recursive: true });
255
+ if (realpathSync(legacy) === realpathSync(target)) return moved;
256
+ for (const sub of ["logs", "output"]) {
257
+ const from = join(legacy, sub);
258
+ if (!existsSync(from)) continue;
259
+ mkdirSync(join(target, sub), { recursive: true });
260
+ for (const entry of readdirSync(from)) {
261
+ const to = join(target, sub, entry);
262
+ if (existsSync(to)) continue;
263
+ try { renameSync(join(from, entry), to); moved.push(`${sub}/${entry}`); } catch { /* keep the legacy copy */ }
264
+ }
265
+ }
266
+ // Remove only directories left empty (the unused tls/ included); rmdir refuses non-empty ones.
267
+ for (const dir of ["logs", "output", "tls", ""]) { try { rmdirSync(join(legacy, dir)); } catch { /* not empty */ } }
268
+ return moved;
269
+ }
270
+
244
271
  export function parseUrl(raw: string) {
245
272
  const u = new URL(raw);
246
273
  const fragment = new URLSearchParams(u.hash.slice(1).replace(/^\?/, ""));
@@ -482,7 +509,15 @@ export function deriveIdentity(opts: {
482
509
  return { key, label };
483
510
  }
484
511
 
485
- async function getClientIdentity(): Promise<{ key: string; label: string; profile?: string }> {
512
+ /**
513
+ * A project's own rechrome folder: <root>/.rechrome, where root is the same one the session
514
+ * key uses (the worktree root, submodules rolled up), or cwd in `cwd` mode / outside git.
515
+ */
516
+ export function projectDataDir(opts: { mode: string; cwd: string; root?: string | null }): string {
517
+ return join(opts.mode === "cwd" ? opts.cwd : opts.root || opts.cwd, ".rechrome");
518
+ }
519
+
520
+ async function getClientIdentity(): Promise<{ key: string; label: string; dataDir: string; profile?: string }> {
486
521
  const cwd = realpathSafe(process.cwd());
487
522
  const mode = (process.env.RECH_IDENTITY || "worktree").toLowerCase();
488
523
  let root: string | null = null;
@@ -508,7 +543,7 @@ async function getClientIdentity(): Promise<{ key: string; label: string; profil
508
543
  if (remoteUrl) remote = normalizeRemote(remoteUrl);
509
544
  }
510
545
 
511
- return deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch });
546
+ return { ...deriveIdentity({ mode, cwd, host: hostname(), root, remote, branch }), dataDir: projectDataDir({ mode, cwd, root }) };
512
547
  }
513
548
 
514
549
  // Profile precedence: an explicit `?profile=` in RECHROME_URL is authoritative; the
@@ -902,7 +937,8 @@ async function callServe(
902
937
  const res = await fetch(serviceUrl(url, "run"), {
903
938
  method: "POST",
904
939
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
905
- body: JSON.stringify({ args, identity, env }),
940
+ // dataDir is a client-local path; the daemon (possibly remote) only needs the session identity.
941
+ body: JSON.stringify({ args, identity: { key: identity.key, label: identity.label, profile: identity.profile }, env }),
906
942
  signal: AbortSignal.timeout(70_000),
907
943
  }).catch(async (e) => {
908
944
  if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
@@ -1018,9 +1054,11 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1018
1054
  if (stdout) process.stdout.write(stdout);
1019
1055
 
1020
1056
  if (files?.length) {
1021
- const dlDir = join(process.cwd(), ".playwright-cli-multi-tab");
1057
+ // Saved files belong to the project: <project>/.rechrome/output. The folder also holds
1058
+ // .env.local (secrets), so it is git-ignored as a whole when first created.
1059
+ const dlDir = join(identity.dataDir, "output");
1022
1060
  mkdirSync(dlDir, { recursive: true });
1023
- const gitignorePath = join(dlDir, ".gitignore");
1061
+ const gitignorePath = join(identity.dataDir, ".gitignore");
1024
1062
  if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
1025
1063
  for (const name of files) {
1026
1064
  const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
@@ -1467,7 +1505,7 @@ async function daemonUninstall(): Promise<void> {
1467
1505
  // locate the binary and launch it detached (singleton via a pidfile).
1468
1506
  // `rech tray hide` / the menu "Hide" item both kill the process;
1469
1507
  // `rech tray show` starts a fresh one.
1470
- const TRAY_PID_FILE = join(RECH_HOME_DIR, "tray.pid");
1508
+ const TRAY_PID_FILE = join(RECH_DIR, "tray.pid");
1471
1509
 
1472
1510
  // A desktop GUI must be present. Linux needs an X11/Wayland display; a headless
1473
1511
  // box (SSH, CI, container) has neither, so the tray is skipped. macOS/Windows
@@ -1729,7 +1767,7 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1729
1767
  process.exit(1);
1730
1768
  }
1731
1769
  const dist = await ensureExtensionDistInstalled();
1732
- const userDataDir = join(RECH_HOME_DIR, "profiles", name);
1770
+ const userDataDir = join(RECH_DIR, "profiles", name);
1733
1771
  const token = randomBytes(32).toString("base64url");
1734
1772
 
1735
1773
  console.log(`\n[1/3] Provisioning managed profile "${name}"`);
package/serve.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  getOrCreateUrl,
10
10
  authCheck,
11
11
  RECH_DIR,
12
+ LEGACY_RECH_DIR,
13
+ migrateLegacyDataDir,
12
14
  HOME,
13
15
  PASSTHROUGH_ENV_KEYS,
14
16
  resolvePlaywrightCli,
@@ -263,11 +265,14 @@ export function shouldExitOrphanedServe(opts: {
263
265
  }
264
266
 
265
267
  export async function serve() {
268
+ // The daemon owns logs/ and output/, so it migrates them before writing anything.
269
+ const migrated = migrateLegacyDataDir();
266
270
  const url = await getOrCreateUrl();
267
271
  const { key, port } = parseUrl(url);
268
272
 
269
273
  const workDir = join(RECH_DIR, "output");
270
274
  mkdirSync(workDir, { recursive: true });
275
+ if (migrated.length) log(`Moved ${migrated.length} legacy entries from ${LEGACY_RECH_DIR} to ${RECH_DIR}`);
271
276
 
272
277
  // Foreground/orphan self-exit: a serve whose parent has been re-parented to init
273
278
  // (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
@@ -656,7 +661,7 @@ export async function serve() {
656
661
  outputFiles.push(f);
657
662
  } else {
658
663
  const basename = f.split("/").pop()!;
659
- for (const subdir of [".playwright-cli", ".rech-multi-tab"]) {
664
+ for (const subdir of [".playwright-cli", ".playwright-cli-multi-tab"]) {
660
665
  // Forward-slash for the wire: join() would use "\" on the Windows daemon, which
661
666
  // a POSIX client can't treat as a separator (it builds a literal-backslash path).
662
667
  const subpath = `${subdir}/${basename}`;
package/serve.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  getOrCreateUrl,
10
10
  authCheck,
11
11
  RECH_DIR,
12
+ LEGACY_RECH_DIR,
13
+ migrateLegacyDataDir,
12
14
  HOME,
13
15
  PASSTHROUGH_ENV_KEYS,
14
16
  resolvePlaywrightCli,
@@ -263,11 +265,14 @@ export function shouldExitOrphanedServe(opts: {
263
265
  }
264
266
 
265
267
  export async function serve() {
268
+ // The daemon owns logs/ and output/, so it migrates them before writing anything.
269
+ const migrated = migrateLegacyDataDir();
266
270
  const url = await getOrCreateUrl();
267
271
  const { key, port } = parseUrl(url);
268
272
 
269
273
  const workDir = join(RECH_DIR, "output");
270
274
  mkdirSync(workDir, { recursive: true });
275
+ if (migrated.length) log(`Moved ${migrated.length} legacy entries from ${LEGACY_RECH_DIR} to ${RECH_DIR}`);
271
276
 
272
277
  // Foreground/orphan self-exit: a serve whose parent has been re-parented to init
273
278
  // (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
@@ -656,7 +661,7 @@ export async function serve() {
656
661
  outputFiles.push(f);
657
662
  } else {
658
663
  const basename = f.split("/").pop()!;
659
- for (const subdir of [".playwright-cli", ".rech-multi-tab"]) {
664
+ for (const subdir of [".playwright-cli", ".playwright-cli-multi-tab"]) {
660
665
  // Forward-slash for the wire: join() would use "\" on the Windows daemon, which
661
666
  // a POSIX client can't treat as a separator (it builds a literal-backslash path).
662
667
  const subpath = `${subdir}/${basename}`;