rechrome 1.17.0 → 1.18.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 +32 -0
- package/package.json +1 -1
- package/rech.js +389 -37
- package/rech.ts +389 -37
- package/serve.js +73 -7
- package/serve.ts +73 -7
package/rech.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { file } from "bun";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
|
|
5
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
|
|
6
6
|
import { hostname, homedir } from "os";
|
|
7
7
|
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ export const HOME = homedir();
|
|
|
17
17
|
const RECH_HOME_DIR = join(HOME, ".rechrome");
|
|
18
18
|
const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.json");
|
|
19
19
|
|
|
20
|
-
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string };
|
|
20
|
+
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
|
|
21
21
|
|
|
22
22
|
async function readTokenRegistry(): Promise<Record<string, TokenEntry>> {
|
|
23
23
|
const raw = await file(TOKENS_FILE).text().catch(() => "{}");
|
|
@@ -69,7 +69,7 @@ async function loadEnv() {
|
|
|
69
69
|
}
|
|
70
70
|
// Shell-set passthrough vars survive .env.local loading
|
|
71
71
|
const _shellPassthrough: Record<string, string> = {};
|
|
72
|
-
for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
|
|
72
|
+
for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR","PLAYWRIGHT_MCP_LOAD_EXTENSION"] as const) {
|
|
73
73
|
if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
|
|
74
74
|
}
|
|
75
75
|
await loadEnv();
|
|
@@ -86,6 +86,9 @@ export const PASSTHROUGH_ENV_KEYS = [
|
|
|
86
86
|
"PLAYWRIGHT_MCP_EXTENSION_TOKEN",
|
|
87
87
|
"PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
|
|
88
88
|
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
89
|
+
// Managed (provisioned) profiles aren't persistently installed in Secure Preferences,
|
|
90
|
+
// so the relay must re-load the unpacked extension on every launch via --load-extension.
|
|
91
|
+
"PLAYWRIGHT_MCP_LOAD_EXTENSION",
|
|
89
92
|
"PWMCP_TEST_CONNECTION_TIMEOUT",
|
|
90
93
|
] as const;
|
|
91
94
|
|
|
@@ -94,6 +97,54 @@ function isReadable(p?: string): boolean {
|
|
|
94
97
|
try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
// Open a file/URL in the OS default app/browser. `open` is macOS-only — Windows needs
|
|
101
|
+
// `cmd /c start`, Linux needs `xdg-open`.
|
|
102
|
+
function openInDefaultApp(target: string): void {
|
|
103
|
+
const cmd = process.platform === "darwin" ? ["open", target]
|
|
104
|
+
: process.platform === "win32" ? ["cmd", "/c", "start", "", target]
|
|
105
|
+
: ["xdg-open", target];
|
|
106
|
+
try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Best-effort path to the Chrome executable for the current platform (used to open a
|
|
110
|
+
// specific profile at a chrome-extension:// URL). Returns null if not found.
|
|
111
|
+
function findChromeBinary(): string | null {
|
|
112
|
+
const candidates = process.platform === "darwin"
|
|
113
|
+
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
|
|
114
|
+
: process.platform === "win32"
|
|
115
|
+
? [
|
|
116
|
+
join(process.env.PROGRAMFILES || "C:\\Program Files", "Google/Chrome/Application/chrome.exe"),
|
|
117
|
+
join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google/Chrome/Application/chrome.exe"),
|
|
118
|
+
join(process.env.LOCALAPPDATA || join(HOME, "AppData/Local"), "Google/Chrome/Application/chrome.exe"),
|
|
119
|
+
]
|
|
120
|
+
: ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
|
|
121
|
+
for (const p of candidates) {
|
|
122
|
+
if (p.includes("/") || p.includes("\\")) { if (existsSync(p)) return p; }
|
|
123
|
+
else { const w = Bun.which(p); if (w) return w; }
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Open a target (URL or local file) in a specific Chrome profile. This opens a new tab in
|
|
129
|
+
// the user's running Chrome for that profile (or launches Chrome if it's not running) — it
|
|
130
|
+
// does NOT restart Chrome or touch the live session. Note: `--profile-directory` only opens
|
|
131
|
+
// a tab; flags like `--load-extension` are ignored when Chrome is already running for that
|
|
132
|
+
// user-data-dir. Returns true if Chrome was spawned, false if it fell back to the OS default.
|
|
133
|
+
function openInChromeProfile(profileDir: string, target: string): boolean {
|
|
134
|
+
const chromeBin = findChromeBinary();
|
|
135
|
+
if (!chromeBin) { openInDefaultApp(target); return false; }
|
|
136
|
+
try {
|
|
137
|
+
Bun.spawn(
|
|
138
|
+
[chromeBin, `--profile-directory=${profileDir}`, target],
|
|
139
|
+
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
140
|
+
);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
openInDefaultApp(target);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
97
148
|
export function log(msg: string) {
|
|
98
149
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
99
150
|
const ts = new Date().toISOString();
|
|
@@ -117,6 +168,7 @@ export function parseUrl(raw: string) {
|
|
|
117
168
|
extensionToken: u.searchParams.get("token") ?? undefined,
|
|
118
169
|
profileDirectory: u.searchParams.get("profile") ?? undefined,
|
|
119
170
|
userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
|
|
171
|
+
loadExtension: u.searchParams.get("load_extension") ?? undefined,
|
|
120
172
|
};
|
|
121
173
|
}
|
|
122
174
|
|
|
@@ -185,7 +237,7 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
|
|
|
185
237
|
return { hostname: hostname(), cwd };
|
|
186
238
|
}
|
|
187
239
|
|
|
188
|
-
async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Promise<Record<string, string>> {
|
|
240
|
+
async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string; loadExtension?: string }): Promise<Record<string, string>> {
|
|
189
241
|
const env: Record<string, string> = {};
|
|
190
242
|
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
191
243
|
if (process.env[key]) env[key] = process.env[key];
|
|
@@ -196,6 +248,8 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
|
|
|
196
248
|
env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
|
|
197
249
|
if (urlExtras?.userDataDir)
|
|
198
250
|
env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
|
|
251
|
+
if (urlExtras?.loadExtension)
|
|
252
|
+
env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = urlExtras.loadExtension;
|
|
199
253
|
// Token: shell env wins (explicit override), registry is fallback, URL param is last resort
|
|
200
254
|
const profileKey = urlExtras?.profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
201
255
|
if (profileKey) {
|
|
@@ -204,6 +258,7 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
|
|
|
204
258
|
if (entry) {
|
|
205
259
|
if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
|
|
206
260
|
if (!env["PLAYWRIGHT_MCP_USER_DATA_DIR"] && entry.userDataDir) env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = entry.userDataDir;
|
|
261
|
+
if (!env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] && entry.loadExtension) env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = entry.loadExtension;
|
|
207
262
|
if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
|
|
208
263
|
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
|
|
209
264
|
} else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
|
|
@@ -371,11 +426,11 @@ async function callServe(
|
|
|
371
426
|
args: string[],
|
|
372
427
|
overrideEnv?: Record<string, string>,
|
|
373
428
|
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
374
|
-
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
|
|
429
|
+
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
375
430
|
const identity = await getClientIdentity();
|
|
376
431
|
const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
377
432
|
if (effectiveProfile) (identity as any).profile = effectiveProfile;
|
|
378
|
-
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
|
|
433
|
+
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
379
434
|
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
380
435
|
method: "POST",
|
|
381
436
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
@@ -411,7 +466,7 @@ async function callServe(
|
|
|
411
466
|
}
|
|
412
467
|
|
|
413
468
|
async function run(url: string, args: string[]) {
|
|
414
|
-
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
|
|
469
|
+
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
415
470
|
const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
416
471
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
417
472
|
const identity = await getClientIdentity();
|
|
@@ -420,7 +475,7 @@ async function run(url: string, args: string[]) {
|
|
|
420
475
|
`[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
|
|
421
476
|
);
|
|
422
477
|
|
|
423
|
-
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir });
|
|
478
|
+
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
424
479
|
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
425
480
|
|
|
426
481
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
@@ -461,7 +516,7 @@ async function run(url: string, args: string[]) {
|
|
|
461
516
|
process.exit(status);
|
|
462
517
|
}
|
|
463
518
|
|
|
464
|
-
function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
519
|
+
export function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
465
520
|
return `<!DOCTYPE html>
|
|
466
521
|
<html lang="en">
|
|
467
522
|
<head>
|
|
@@ -563,10 +618,20 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
563
618
|
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
564
619
|
const rechScript = import.meta.filename;
|
|
565
620
|
|
|
566
|
-
// Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab"
|
|
621
|
+
// Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab".
|
|
622
|
+
// The fork is a .js script: POSIX execs it via its shebang (`#!/usr/bin/env node`), but Windows
|
|
623
|
+
// can't exec a .js directly, so it must be invoked through an interpreter. It MUST be node, not
|
|
624
|
+
// bun: the cliDaemon inherits its parent's runtime (spawned via process.execPath), and the
|
|
625
|
+
// extension-bridge relay's WebSocket handshake hangs under Bun (the extension WS connects but
|
|
626
|
+
// `extension.initialized` never completes) — under node it completes, matching the POSIX shebang.
|
|
627
|
+
// serve splits PLAYWRIGHT_CLI on spaces into argv, so we use bare `node` (the node path lives
|
|
628
|
+
// under "Program Files" and contains a space); node must be on the daemon's PATH, same as the
|
|
629
|
+
// shebang's `env node` assumption. The repo path contains no spaces.
|
|
567
630
|
const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
|
|
568
631
|
const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
|
|
569
|
-
|| (existsSync(bundledForkCli)
|
|
632
|
+
|| (existsSync(bundledForkCli)
|
|
633
|
+
? (IS_WINDOWS ? `node ${bundledForkCli}` : bundledForkCli)
|
|
634
|
+
: "playwright-cli-multi-tab");
|
|
570
635
|
|
|
571
636
|
// Environment the managed `serve` process must run with.
|
|
572
637
|
const daemonEnv: Record<string, string> = {
|
|
@@ -583,11 +648,12 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
583
648
|
// Drop any prior registration (current + legacy names) before re-adding.
|
|
584
649
|
for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
|
|
585
650
|
|
|
651
|
+
let startCode: number;
|
|
586
652
|
if (IS_WINDOWS) {
|
|
587
653
|
// pm2 captures the CLI env (passed via runPm's env) for the managed process,
|
|
588
654
|
// autorestarts by default, and runs the bun binary directly with
|
|
589
655
|
// `--interpreter none` (so it isn't fed to node).
|
|
590
|
-
await runPm([
|
|
656
|
+
startCode = await runPm([
|
|
591
657
|
"start", bunBin,
|
|
592
658
|
"--name", PM_PROCESS_NAME,
|
|
593
659
|
"--interpreter", "none",
|
|
@@ -597,7 +663,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
597
663
|
await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
|
|
598
664
|
} else {
|
|
599
665
|
const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
600
|
-
await runPm([
|
|
666
|
+
startCode = await runPm([
|
|
601
667
|
"start",
|
|
602
668
|
"--name", PM_PROCESS_NAME,
|
|
603
669
|
"--restart", "always",
|
|
@@ -607,6 +673,9 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
607
673
|
]);
|
|
608
674
|
await runPm(["service", "install"]);
|
|
609
675
|
}
|
|
676
|
+
// Surface a failed start instead of reporting a daemon that was never registered.
|
|
677
|
+
if (startCode !== 0)
|
|
678
|
+
throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
|
|
610
679
|
}
|
|
611
680
|
|
|
612
681
|
async function daemonUninstall(): Promise<void> {
|
|
@@ -616,7 +685,220 @@ async function daemonUninstall(): Promise<void> {
|
|
|
616
685
|
console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
|
|
617
686
|
}
|
|
618
687
|
|
|
619
|
-
|
|
688
|
+
// Read the extension's auth token straight from a profile's localStorage LevelDB. Read-only
|
|
689
|
+
// (we never take LevelDB's lock), so it's safe while the user's Chrome is running. The token is
|
|
690
|
+
// the value of the `auth-token` key under the extension origin, stored as a 0x01 (Latin-1)
|
|
691
|
+
// encoding byte followed by the 43-char base64url token. LevelDB prefix-compression can split the
|
|
692
|
+
// origin string across block-restart points, so we anchor on the `auth-token` marker + token shape
|
|
693
|
+
// and (when possible) require the extension id to appear in the same file to avoid a collision
|
|
694
|
+
// with another extension's `auth-token`. Returns the newest token found, or null.
|
|
695
|
+
function readExtensionTokenFromProfile(userDataDir: string, profileDir: string): string | null {
|
|
696
|
+
const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
|
|
697
|
+
let files: string[];
|
|
698
|
+
try { files = readdirSync(dir).filter(f => f.endsWith(".ldb") || f.endsWith(".log")).sort(); }
|
|
699
|
+
catch { return null; }
|
|
700
|
+
const extIdChunk = EXTENSION_ID.slice(0, 20); // contiguous prefix survives the LevelDB split
|
|
701
|
+
const scan = (requireExtId: boolean): string | null => {
|
|
702
|
+
let found: string | null = null;
|
|
703
|
+
for (const f of files) {
|
|
704
|
+
let buf: Buffer;
|
|
705
|
+
try { buf = readFileSync(join(dir, f)); } catch { continue; }
|
|
706
|
+
if (requireExtId && !buf.includes(extIdChunk, 0, "latin1")) continue;
|
|
707
|
+
let idx = 0;
|
|
708
|
+
while (true) {
|
|
709
|
+
const j = buf.indexOf("auth-token", idx, "latin1");
|
|
710
|
+
if (j < 0) break;
|
|
711
|
+
idx = j + 1;
|
|
712
|
+
const win = buf.subarray(j, Math.min(buf.length, j + 200)).toString("latin1");
|
|
713
|
+
const m = win.match(/\x01([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/);
|
|
714
|
+
if (m) found = m[1]; // newest file / newest occurrence wins
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return found;
|
|
718
|
+
};
|
|
719
|
+
return scan(true) ?? scan(false);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
|
|
723
|
+
// Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
|
|
724
|
+
// --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
|
|
725
|
+
function findChromiumForTesting(): string | null {
|
|
726
|
+
// Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
|
|
727
|
+
// `playwright install` doesn't always write to the env path, so check both.
|
|
728
|
+
const bases = [
|
|
729
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH,
|
|
730
|
+
process.platform === "win32" ? join(HOME, "AppData/Local/ms-playwright")
|
|
731
|
+
: process.platform === "darwin" ? join(HOME, "Library/Caches/ms-playwright")
|
|
732
|
+
: join(HOME, ".cache/ms-playwright"),
|
|
733
|
+
].filter((b): b is string => !!b);
|
|
734
|
+
for (const base of bases) {
|
|
735
|
+
let revs: string[];
|
|
736
|
+
try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
|
|
737
|
+
catch { continue; }
|
|
738
|
+
for (const rev of revs) {
|
|
739
|
+
const root = join(base, rev);
|
|
740
|
+
const candidates = process.platform === "darwin"
|
|
741
|
+
? readdirSync(root).filter(d => d.startsWith("chrome-mac")).flatMap(d => {
|
|
742
|
+
const appsDir = join(root, d);
|
|
743
|
+
let apps: string[] = [];
|
|
744
|
+
try { apps = readdirSync(appsDir).filter(a => a.endsWith(".app")); } catch {}
|
|
745
|
+
return apps.map(a => join(appsDir, a, "Contents/MacOS", a.replace(/\.app$/, "")));
|
|
746
|
+
})
|
|
747
|
+
: process.platform === "win32"
|
|
748
|
+
? [join(root, "chrome-win", "chrome.exe")]
|
|
749
|
+
: [join(root, "chrome-linux", "chrome")];
|
|
750
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Minimal Chrome DevTools Protocol client over a WebSocket — just enough to create a
|
|
757
|
+
// target, attach to it, and evaluate JS. Used to seed the auth token into a managed
|
|
758
|
+
// profile's extension localStorage without pulling in the full Playwright dependency.
|
|
759
|
+
class CDPClient {
|
|
760
|
+
private ws: WebSocket;
|
|
761
|
+
private nextId = 0;
|
|
762
|
+
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
|
763
|
+
private opened: Promise<void>;
|
|
764
|
+
constructor(url: string) {
|
|
765
|
+
this.ws = new WebSocket(url);
|
|
766
|
+
this.opened = new Promise<void>((resolve, reject) => {
|
|
767
|
+
this.ws.addEventListener("open", () => resolve(), { once: true });
|
|
768
|
+
this.ws.addEventListener("error", () => reject(new Error("CDP WebSocket error")), { once: true });
|
|
769
|
+
});
|
|
770
|
+
this.ws.addEventListener("message", (ev: MessageEvent) => {
|
|
771
|
+
let msg: any;
|
|
772
|
+
try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : ""); } catch { return; }
|
|
773
|
+
const p = msg.id != null ? this.pending.get(msg.id) : undefined;
|
|
774
|
+
if (!p) return;
|
|
775
|
+
this.pending.delete(msg.id);
|
|
776
|
+
if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
777
|
+
else p.resolve(msg.result);
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
async open(): Promise<void> { await this.opened; }
|
|
781
|
+
send(method: string, params: Record<string, any> = {}, sessionId?: string): Promise<any> {
|
|
782
|
+
const id = ++this.nextId;
|
|
783
|
+
const payload: any = { id, method, params };
|
|
784
|
+
if (sessionId) payload.sessionId = sessionId;
|
|
785
|
+
return new Promise((resolve, reject) => {
|
|
786
|
+
this.pending.set(id, { resolve, reject });
|
|
787
|
+
this.ws.send(JSON.stringify(payload));
|
|
788
|
+
setTimeout(() => { if (this.pending.delete(id)) reject(new Error(`CDP ${method} timed out`)); }, 15_000);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
close(): void { try { this.ws.close(); } catch {} }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Launch a throwaway Chrome against a dedicated user-data-dir with the unpacked extension
|
|
795
|
+
// loaded, then seed `token` into the extension's localStorage (the value `connect.html` checks
|
|
796
|
+
// for token-bypass). Headless by default; never touches the user's real Chrome/profiles.
|
|
797
|
+
async function provisionExtensionToken(opts: {
|
|
798
|
+
userDataDir: string; profileDir: string; dist: string; token: string; headed?: boolean;
|
|
799
|
+
}): Promise<void> {
|
|
800
|
+
// Branded Google Chrome 149+ rejects --load-extension ("not allowed in Google Chrome"), so a
|
|
801
|
+
// managed profile must be seeded on Chromium / Chrome for Testing, which still honors the flag.
|
|
802
|
+
const chromeBin = findChromiumForTesting();
|
|
803
|
+
if (!chromeBin) throw new Error("Chromium / Chrome for Testing not found — run `npx playwright install chromium`");
|
|
804
|
+
const { userDataDir, profileDir, dist, token } = opts;
|
|
805
|
+
mkdirSync(userDataDir, { recursive: true });
|
|
806
|
+
const portFile = join(userDataDir, "DevToolsActivePort");
|
|
807
|
+
try { unlinkSync(portFile); } catch {}
|
|
808
|
+
const args = [
|
|
809
|
+
`--user-data-dir=${userDataDir}`,
|
|
810
|
+
`--profile-directory=${profileDir}`,
|
|
811
|
+
`--load-extension=${dist}`,
|
|
812
|
+
`--disable-extensions-except=${dist}`,
|
|
813
|
+
"--remote-debugging-port=0",
|
|
814
|
+
"--no-first-run",
|
|
815
|
+
"--no-default-browser-check",
|
|
816
|
+
"--disable-background-timer-throttling",
|
|
817
|
+
];
|
|
818
|
+
if (!opts.headed) args.push("--headless=new");
|
|
819
|
+
if (process.platform === "linux") args.push("--no-sandbox");
|
|
820
|
+
args.push("about:blank");
|
|
821
|
+
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
|
|
822
|
+
let cdp: CDPClient | null = null;
|
|
823
|
+
try {
|
|
824
|
+
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
825
|
+
let port: number | null = null;
|
|
826
|
+
for (let i = 0; i < 100; i++) {
|
|
827
|
+
await Bun.sleep(100);
|
|
828
|
+
const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
|
|
829
|
+
if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
|
|
830
|
+
if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
|
|
831
|
+
}
|
|
832
|
+
if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
|
|
833
|
+
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
|
834
|
+
cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
|
|
835
|
+
await cdp.open();
|
|
836
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: `chrome-extension://${EXTENSION_ID}/status.html` });
|
|
837
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
838
|
+
// The extension page may still be loading; retry the write until localStorage reflects it.
|
|
839
|
+
let ok = false;
|
|
840
|
+
const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
|
|
841
|
+
for (let i = 0; i < 50; i++) {
|
|
842
|
+
const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId).catch(() => null);
|
|
843
|
+
if (r?.result?.value === token) { ok = true; break; }
|
|
844
|
+
await Bun.sleep(100);
|
|
845
|
+
}
|
|
846
|
+
if (!ok) throw new Error(`Could not seed auth token into chrome-extension://${EXTENSION_ID}/ (is the extension loading?)`);
|
|
847
|
+
// Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
|
|
848
|
+
await cdp.send("Browser.close").catch(() => {});
|
|
849
|
+
} finally {
|
|
850
|
+
cdp?.close();
|
|
851
|
+
try { proc.kill(); } catch {}
|
|
852
|
+
await proc.exited.catch(() => {});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
|
|
857
|
+
// The name doubles as the on-disk profile directory and the registry/URL key, so keep it a
|
|
858
|
+
// simple token and disallow the reserved real-Chrome names to avoid any cross-talk.
|
|
859
|
+
if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || /^(Default|Profile \d+)$/i.test(name)) {
|
|
860
|
+
console.error(`Invalid profile name: "${name ?? ""}". Use letters/digits/._- (not "Default"/"Profile N").`);
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
const dist = await ensureExtensionDistInstalled();
|
|
864
|
+
const userDataDir = join(RECH_HOME_DIR, "profiles", name);
|
|
865
|
+
const token = randomBytes(32).toString("base64url");
|
|
866
|
+
|
|
867
|
+
console.log(`\n[1/3] Provisioning managed profile "${name}"`);
|
|
868
|
+
console.log(` user-data-dir: ${userDataDir}`);
|
|
869
|
+
console.log(` extension: ${dist}`);
|
|
870
|
+
console.log(` Launching ${opts.headed ? "headed" : "headless"} Chrome to seed the auth token...`);
|
|
871
|
+
await provisionExtensionToken({ userDataDir, profileDir: name, dist, token, headed: opts.headed });
|
|
872
|
+
console.log(` Token seeded (${token.slice(0, 6)}…)`);
|
|
873
|
+
|
|
874
|
+
// [2/3] Daemon URL — reuse the running daemon's key; warn (don't fail) if it isn't up yet.
|
|
875
|
+
console.log(`\n[2/3] Building RECHROME_URL`);
|
|
876
|
+
const url = await getOrCreateUrl();
|
|
877
|
+
const { host, port, protocol, key } = parseUrl(url);
|
|
878
|
+
const healthy = await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
879
|
+
headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(2000),
|
|
880
|
+
}).then(r => r.ok).catch(() => false);
|
|
881
|
+
if (!healthy) console.log(` Note: daemon not reachable at ${host}:${port} — run \`rech setup\` once to start it.`);
|
|
882
|
+
|
|
883
|
+
const rechUrl = new URL(`${protocol}://${host}:${port}`);
|
|
884
|
+
rechUrl.username = key || randomBytes(12).toString("base64url");
|
|
885
|
+
rechUrl.searchParams.set("extension_id", EXTENSION_ID);
|
|
886
|
+
rechUrl.searchParams.set("token", token);
|
|
887
|
+
rechUrl.searchParams.set("profile", name);
|
|
888
|
+
rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
889
|
+
rechUrl.searchParams.set("load_extension", dist);
|
|
890
|
+
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
891
|
+
|
|
892
|
+
// [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
|
|
893
|
+
await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
|
|
894
|
+
console.log(`\n[3/3] Registered "${name}" in ${TOKENS_FILE}`);
|
|
895
|
+
|
|
896
|
+
console.log(`\nDone! RECHROME_URL for "${name}":\n\n ${newLine}\n`);
|
|
897
|
+
console.log(`Use it per-call:\n ${newLine.replace("RECHROME_URL=", "RECHROME_URL='")}' rech open https://example.com\n`);
|
|
898
|
+
console.log(`Or save it to a project .env.local to make it the default.`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
|
|
620
902
|
const { createInterface } = await import("readline");
|
|
621
903
|
const isTTY = process.stdin.isTTY ?? false;
|
|
622
904
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
@@ -770,7 +1052,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
770
1052
|
return available[idx];
|
|
771
1053
|
}
|
|
772
1054
|
|
|
773
|
-
async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string): Promise<{ extId: string; token: string } | null> {
|
|
1055
|
+
async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string, providedToken?: string): Promise<{ extId: string; token: string } | null> {
|
|
774
1056
|
// Extension check
|
|
775
1057
|
let extId: string | undefined;
|
|
776
1058
|
// Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
|
|
@@ -780,21 +1062,58 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
780
1062
|
if (found) { extId = found.id; break; }
|
|
781
1063
|
console.log(`\n Extension not found in profile: ${profileDisplay}`);
|
|
782
1064
|
console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
|
|
783
|
-
// Non-TTY (agent/pipe) can't install an extension interactively, and `ask` doesn't block on an exhausted stdin queue —
|
|
784
|
-
// looping here would spawn `open` per iteration until the OS runs out of resources. Fail fast instead.
|
|
785
|
-
if (!isTTY) {
|
|
786
|
-
console.error(` Non-TTY: cannot install extension interactively — aborting`);
|
|
787
|
-
return null;
|
|
788
|
-
}
|
|
789
1065
|
const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
|
|
790
1066
|
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
791
1067
|
await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
|
|
792
|
-
|
|
793
|
-
|
|
1068
|
+
// Open the install guide directly in the *target* profile (resolved from --profile), so
|
|
1069
|
+
// "Load unpacked" lands in the right Chrome. This is a new tab, not a restart.
|
|
1070
|
+
console.log(`\n Opening install guide in Chrome profile: ${profileDisplay}`);
|
|
1071
|
+
openInChromeProfile(profileDir, setupHtmlPath);
|
|
1072
|
+
// Non-TTY (agent/pipe) can't block on a paste prompt, and `ask` returns immediately on an
|
|
1073
|
+
// exhausted stdin queue — looping would respawn Chrome every iteration. Open the guide once,
|
|
1074
|
+
// then stop with clear re-run instructions instead of spinning.
|
|
1075
|
+
if (!isTTY) {
|
|
1076
|
+
console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
|
|
1077
|
+
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
1078
|
+
console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
|
|
1079
|
+
console.error(` Then re-run: rech setup --profile <num|email> [--token <tok>]`);
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
794
1082
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
795
1083
|
}
|
|
796
1084
|
console.log(` Extension found: ${extId}`);
|
|
797
1085
|
|
|
1086
|
+
// Non-interactive token injection (--token / RECH_TOKEN). An explicitly supplied token wins
|
|
1087
|
+
// over both the registry-keep prompt and the paste loop, so a non-TTY agent can register a
|
|
1088
|
+
// profile in one shot. Accepts the bare token or a full `PLAYWRIGHT_MCP_EXTENSION_TOKEN=...`.
|
|
1089
|
+
if (providedToken) {
|
|
1090
|
+
const token = providedToken.replace(/^.*?=/, "").trim();
|
|
1091
|
+
if (token.length < 20) {
|
|
1092
|
+
console.error(` Provided token too short (${token.length} chars) — pass the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value`);
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
console.log(` Using provided token: ${token.slice(0, 6)}…`);
|
|
1096
|
+
return { extId, token };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Default automation: read the auth token straight from the profile's localStorage LevelDB,
|
|
1100
|
+
// so an installed extension needs no manual paste (works the same in TTY and non-TTY). The
|
|
1101
|
+
// token is minted lazily the first time the status/connect page loads, so if it isn't there
|
|
1102
|
+
// yet, open status.html in this profile to mint it (a new tab — never a restart) and re-scan.
|
|
1103
|
+
if (userDataDir) {
|
|
1104
|
+
let auto = readExtensionTokenFromProfile(userDataDir, profileDir);
|
|
1105
|
+
if (!auto) {
|
|
1106
|
+
console.log(` No token in profile yet — minting via chrome-extension://${extId}/status.html …`);
|
|
1107
|
+
openInChromeProfile(profileDir, `chrome-extension://${extId}/status.html`);
|
|
1108
|
+
for (let i = 0; i < 10 && !auto; i++) { await Bun.sleep(500); auto = readExtensionTokenFromProfile(userDataDir, profileDir); }
|
|
1109
|
+
}
|
|
1110
|
+
if (auto) {
|
|
1111
|
+
console.log(` Auto-read token from profile localStorage: ${auto.slice(0, 6)}…`);
|
|
1112
|
+
return { extId, token: auto };
|
|
1113
|
+
}
|
|
1114
|
+
console.log(` Could not auto-read token from localStorage — falling back to manual entry`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
798
1117
|
// Check for existing token in registry
|
|
799
1118
|
const registry = await readTokenRegistry();
|
|
800
1119
|
const existing = registry[profileKey];
|
|
@@ -813,11 +1132,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
813
1132
|
console.log(`\n Get auth token from the extension:`);
|
|
814
1133
|
console.log(` ${statusUrl}`);
|
|
815
1134
|
if (isTTY) {
|
|
816
|
-
|
|
817
|
-
["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
818
|
-
`--profile-directory=${profileDir}`, statusUrl],
|
|
819
|
-
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
820
|
-
);
|
|
1135
|
+
openInChromeProfile(profileDir, statusUrl);
|
|
821
1136
|
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
822
1137
|
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
823
1138
|
} else {
|
|
@@ -857,7 +1172,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
|
857
1172
|
// [3+4/4] Extension + token for primary profile
|
|
858
1173
|
console.log("\n[3/4] Checking extension...");
|
|
859
1174
|
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
860
|
-
const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail);
|
|
1175
|
+
const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
|
|
861
1176
|
if (!primary) { rl?.close(); process.exit(1); }
|
|
862
1177
|
const { extId, token } = primary;
|
|
863
1178
|
|
|
@@ -932,12 +1247,16 @@ async function status(): Promise<void> {
|
|
|
932
1247
|
const { host, port, protocol } = parseUrl(url);
|
|
933
1248
|
const parsed = parseUrl(url);
|
|
934
1249
|
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1250
|
+
// Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
|
|
1251
|
+
// POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
|
|
1252
|
+
const bind = ping
|
|
1253
|
+
? await fetch(`${protocol}://${host}:${port}/ping`, {
|
|
1254
|
+
headers: { Authorization: `Bearer ${parsed.key}` },
|
|
1255
|
+
signal: AbortSignal.timeout(2000),
|
|
1256
|
+
}).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
|
|
1257
|
+
: undefined;
|
|
1258
|
+
const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
|
|
1259
|
+
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
|
|
941
1260
|
const pmOut = await pmList();
|
|
942
1261
|
const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
|
|
943
1262
|
console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
|
|
@@ -962,7 +1281,16 @@ function printHelp(): void {
|
|
|
962
1281
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
963
1282
|
|
|
964
1283
|
Usage:
|
|
965
|
-
rech setup
|
|
1284
|
+
rech setup [--profile <num|email>] [--token <tok>]
|
|
1285
|
+
First-time setup: daemon + Chrome extension + config
|
|
1286
|
+
--profile selects the Chrome profile non-interactively
|
|
1287
|
+
--token (or RECH_TOKEN) supplies the auth token for
|
|
1288
|
+
non-TTY/agent runs, skipping the interactive paste
|
|
1289
|
+
rech provision-profile <name> --experimental [--headed]
|
|
1290
|
+
(experimental) Auto-provision a managed QA profile on
|
|
1291
|
+
Chrome for Testing — branded Chrome 149+ rejects
|
|
1292
|
+
--load-extension, so this is a clean browser, not your
|
|
1293
|
+
real Chrome. For your real Chrome, use \`rech setup\`
|
|
966
1294
|
rech status Show current configuration and serve health
|
|
967
1295
|
rech uninstall Remove the serve daemon and clear config
|
|
968
1296
|
rech serve Start the serve server manually (foreground)
|
|
@@ -971,9 +1299,11 @@ Usage:
|
|
|
971
1299
|
|
|
972
1300
|
Environment:
|
|
973
1301
|
${ENV_KEY} Server URL set by \`rech setup\`
|
|
1302
|
+
RECH_TOKEN Auth token for \`rech setup\` (same as --token)
|
|
974
1303
|
|
|
975
1304
|
Examples:
|
|
976
1305
|
rech setup
|
|
1306
|
+
rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
977
1307
|
rech eval "() => document.title"
|
|
978
1308
|
rech open https://example.com
|
|
979
1309
|
rech screenshot`);
|
|
@@ -997,7 +1327,29 @@ if (import.meta.main) {
|
|
|
997
1327
|
const profile = profileIdx !== -1
|
|
998
1328
|
? args[profileIdx + 1]
|
|
999
1329
|
: args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
|
|
1000
|
-
|
|
1330
|
+
const tokenIdx = args.indexOf("--token");
|
|
1331
|
+
const token = (tokenIdx !== -1
|
|
1332
|
+
? args[tokenIdx + 1]
|
|
1333
|
+
: args.find(a => a.startsWith("--token="))?.slice("--token=".length))
|
|
1334
|
+
?? process.env.RECH_TOKEN;
|
|
1335
|
+
await setup({ profile, token }); // setup closes envWatcher itself before printing Done
|
|
1336
|
+
} else if (cmd === "provision-profile") {
|
|
1337
|
+
const name = args.find((a, i) => i > 0 && !a.startsWith("-"));
|
|
1338
|
+
const headed = args.includes("--headed");
|
|
1339
|
+
const experimental = args.includes("--experimental");
|
|
1340
|
+
if (!name) { console.error("Usage: rech provision-profile <name> --experimental [--headed]"); process.exit(1); }
|
|
1341
|
+
// Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
|
|
1342
|
+
// (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
|
|
1343
|
+
// so it's gated behind --experimental rather than offered as the default setup path.
|
|
1344
|
+
if (!experimental) {
|
|
1345
|
+
console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
|
|
1346
|
+
console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
|
|
1347
|
+
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <N>`);
|
|
1348
|
+
console.error(`To proceed anyway, re-run with --experimental.`);
|
|
1349
|
+
process.exit(1);
|
|
1350
|
+
}
|
|
1351
|
+
await provisionProfile(name, { headed });
|
|
1352
|
+
envWatcher?.close();
|
|
1001
1353
|
} else if (cmd === "uninstall") {
|
|
1002
1354
|
await daemonUninstall();
|
|
1003
1355
|
envWatcher?.close();
|