rechrome 1.5.0 → 1.7.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 +40 -20
- package/package.json +3 -3
- package/rech.js +608 -65
- package/rech.ts +608 -65
- package/serve.js +112 -13
- package/serve.ts +112 -13
package/rech.js
CHANGED
|
@@ -2,47 +2,95 @@
|
|
|
2
2
|
|
|
3
3
|
import { file } from "bun";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
|
-
import { mkdirSync, appendFileSync, existsSync } from "fs";
|
|
5
|
+
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, constants as fsConstants } from "fs";
|
|
6
6
|
import { hostname } from "os";
|
|
7
|
-
import { join, basename } from "path";
|
|
7
|
+
import { join, basename, dirname } from "path";
|
|
8
8
|
|
|
9
|
-
export const ENV_KEY = "
|
|
9
|
+
export const ENV_KEY = "RECHROME_URL";
|
|
10
10
|
export const DEFAULT_PORT = 13775;
|
|
11
11
|
export const RECH_DIR = join(import.meta.dir, ".rech");
|
|
12
12
|
export const LOG_DIR = join(RECH_DIR, "logs");
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const RECH_HOME_DIR = join(process.env.HOME!, ".rech");
|
|
15
|
+
const TOKENS_FILE = join(RECH_HOME_DIR, "tokens.json");
|
|
16
|
+
|
|
17
|
+
type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string };
|
|
18
|
+
|
|
19
|
+
async function readTokenRegistry(): Promise<Record<string, TokenEntry>> {
|
|
20
|
+
const raw = await file(TOKENS_FILE).text().catch(() => "{}");
|
|
21
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
|
|
25
|
+
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
26
|
+
const registry = await readTokenRegistry();
|
|
27
|
+
registry[profileEmail] = entry;
|
|
28
|
+
await Bun.write(TOKENS_FILE, JSON.stringify(registry, null, 2) + "\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
const envFile = join(import.meta.dir, ".env.local");
|
|
32
|
+
const globalEnvFile = join(process.env.HOME || "~", ".env.local");
|
|
16
33
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
34
|
+
// Walk CWD→root loading env files nearest-first; per-key: closest file wins, farther files skip.
|
|
35
|
+
// At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
|
|
36
|
+
export async function loadNearestEnv(extraFallbacks: string[] = []) {
|
|
37
|
+
const seen = new Set<string>();
|
|
38
|
+
const applyFile = async (path: string) => {
|
|
39
|
+
const raw = await file(path).text().catch(() => "");
|
|
40
|
+
for (const line of raw.split("\n")) {
|
|
41
|
+
const m = line.match(/^\s*([^#=\s][^#=]*?)\s*=\s*(.*?)\s*$/);
|
|
42
|
+
if (!m || m[1].startsWith("#")) continue;
|
|
43
|
+
if (seen.has(m[1])) continue;
|
|
44
|
+
seen.add(m[1]);
|
|
45
|
+
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let dir = process.cwd();
|
|
50
|
+
const dirs: string[] = [];
|
|
51
|
+
while (true) {
|
|
52
|
+
dirs.push(dir);
|
|
53
|
+
const parent = join(dir, "..");
|
|
54
|
+
if (parent === dir) break;
|
|
55
|
+
dir = parent;
|
|
25
56
|
}
|
|
57
|
+
for (const d of dirs) {
|
|
58
|
+
await applyFile(join(d, ".rechrome", ".env.local"));
|
|
59
|
+
await applyFile(join(d, ".env.local"));
|
|
60
|
+
}
|
|
61
|
+
for (const f of extraFallbacks) await applyFile(f);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function loadEnv() {
|
|
65
|
+
await loadNearestEnv();
|
|
66
|
+
}
|
|
67
|
+
// Shell-set passthrough vars survive .env.local loading
|
|
68
|
+
const _shellPassthrough: Record<string, string> = {};
|
|
69
|
+
for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
|
|
70
|
+
if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
|
|
26
71
|
}
|
|
27
72
|
await loadEnv();
|
|
73
|
+
Object.assign(process.env, _shellPassthrough);
|
|
28
74
|
|
|
29
|
-
// Watch .env.local for changes and hot-reload
|
|
30
75
|
import { watch } from "node:fs";
|
|
31
|
-
|
|
32
|
-
watch(envFile, async () => {
|
|
33
|
-
|
|
34
|
-
await loadEnv();
|
|
35
|
-
});
|
|
36
|
-
}
|
|
76
|
+
const envWatcher = existsSync(envFile)
|
|
77
|
+
? watch(envFile, async () => { log(".env.local changed, reloading"); await loadEnv(); })
|
|
78
|
+
: null;
|
|
37
79
|
|
|
38
80
|
|
|
39
81
|
export const PASSTHROUGH_ENV_KEYS = [
|
|
40
82
|
"PLAYWRIGHT_MCP_EXTENSION_ID",
|
|
41
83
|
"PLAYWRIGHT_MCP_EXTENSION_TOKEN",
|
|
42
|
-
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
43
84
|
"PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
|
|
85
|
+
"PLAYWRIGHT_MCP_USER_DATA_DIR",
|
|
86
|
+
"PWMCP_TEST_CONNECTION_TIMEOUT",
|
|
44
87
|
] as const;
|
|
45
88
|
|
|
89
|
+
function isReadable(p?: string): boolean {
|
|
90
|
+
if (!p) return false;
|
|
91
|
+
try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
46
94
|
export function log(msg: string) {
|
|
47
95
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
48
96
|
const ts = new Date().toISOString();
|
|
@@ -54,19 +102,31 @@ export function log(msg: string) {
|
|
|
54
102
|
|
|
55
103
|
export function parseUrl(raw: string) {
|
|
56
104
|
const u = new URL(raw);
|
|
57
|
-
|
|
105
|
+
const scheme = u.protocol.replace(":", "");
|
|
106
|
+
const protocol = scheme === "https" ? "https" : "http";
|
|
107
|
+
const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
|
|
108
|
+
return {
|
|
109
|
+
key: u.username,
|
|
110
|
+
host: u.hostname,
|
|
111
|
+
port: parseInt(u.port) || defaultPort,
|
|
112
|
+
protocol,
|
|
113
|
+
extensionId: u.searchParams.get("extension_id") ?? undefined,
|
|
114
|
+
extensionToken: u.searchParams.get("token") ?? undefined,
|
|
115
|
+
profileDirectory: u.searchParams.get("profile") ?? undefined,
|
|
116
|
+
userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
|
|
117
|
+
};
|
|
58
118
|
}
|
|
59
119
|
|
|
60
120
|
export async function getOrCreateUrl(): Promise<string> {
|
|
61
121
|
if (process.env[ENV_KEY]) return process.env[ENV_KEY];
|
|
62
122
|
const key = randomBytes(9).toString("base64url"); // 12 chars
|
|
63
|
-
const url = `
|
|
123
|
+
const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
|
|
64
124
|
const newLine = `${ENV_KEY}=${url}`;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const content =
|
|
69
|
-
Bun.write(
|
|
125
|
+
// Write to ~/.env.local so it's not shadowed by project .env.local
|
|
126
|
+
const envRaw = await file(globalEnvFile).text().catch(() => "");
|
|
127
|
+
const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
|
|
128
|
+
const content = [...lines, newLine, ""].join("\n");
|
|
129
|
+
Bun.write(globalEnvFile, content);
|
|
70
130
|
process.env[ENV_KEY] = url;
|
|
71
131
|
return url;
|
|
72
132
|
}
|
|
@@ -121,49 +181,182 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
|
|
|
121
181
|
return { hostname: hostname(), cwd };
|
|
122
182
|
}
|
|
123
183
|
|
|
124
|
-
function getClientEnv(): Record<string, string
|
|
184
|
+
async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Promise<Record<string, string>> {
|
|
125
185
|
const env: Record<string, string> = {};
|
|
126
186
|
for (const key of PASSTHROUGH_ENV_KEYS) {
|
|
127
187
|
if (process.env[key]) env[key] = process.env[key];
|
|
128
188
|
}
|
|
189
|
+
if (urlExtras?.extensionId)
|
|
190
|
+
env["PLAYWRIGHT_MCP_EXTENSION_ID"] = urlExtras.extensionId;
|
|
191
|
+
if (urlExtras?.profileDirectory)
|
|
192
|
+
env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
|
|
193
|
+
if (urlExtras?.userDataDir)
|
|
194
|
+
env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
|
|
195
|
+
// Token: shell env wins (explicit override), registry is fallback, URL param is last resort
|
|
196
|
+
const profileKey = urlExtras?.profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
197
|
+
if (profileKey) {
|
|
198
|
+
const registry = await readTokenRegistry();
|
|
199
|
+
const entry = registry[profileKey];
|
|
200
|
+
if (entry) {
|
|
201
|
+
if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
|
|
202
|
+
if (!env["PLAYWRIGHT_MCP_USER_DATA_DIR"] && entry.userDataDir) env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = entry.userDataDir;
|
|
203
|
+
if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
|
|
204
|
+
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
|
|
205
|
+
} else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
|
|
206
|
+
console.error(`[rech] warning: shell PLAYWRIGHT_MCP_EXTENSION_TOKEN differs from registry token for "${profileKey}" — using shell value. Run \`unset PLAYWRIGHT_MCP_EXTENSION_TOKEN\` to use the registry.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] && urlExtras?.extensionToken)
|
|
211
|
+
env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = urlExtras.extensionToken;
|
|
129
212
|
return env;
|
|
130
213
|
}
|
|
131
214
|
|
|
132
|
-
|
|
133
|
-
const
|
|
215
|
+
const CHROME_LOCAL_STATE_PATHS = () => {
|
|
216
|
+
const home = process.env.HOME || "~";
|
|
217
|
+
return [
|
|
218
|
+
join(home, "Library/Application Support/Google/Chrome/Local State"),
|
|
219
|
+
join(home, ".config/google-chrome/Local State"),
|
|
220
|
+
join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
221
|
+
];
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
async function readChromeProfileCache(): Promise<Record<string, { user_name?: string; name?: string }> | null> {
|
|
225
|
+
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
226
|
+
const f = file(statePath);
|
|
227
|
+
if (!(await f.exists())) continue;
|
|
228
|
+
try {
|
|
229
|
+
const data = JSON.parse(await f.text());
|
|
230
|
+
return data?.profile?.info_cache ?? null;
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
134
235
|
|
|
236
|
+
async function findChromeUserDataDir(): Promise<string | null> {
|
|
237
|
+
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
238
|
+
if (!(await file(statePath).exists())) continue;
|
|
239
|
+
return dirname(statePath);
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const EXTENSION_DIST_DIR = join(
|
|
245
|
+
import.meta.dir,
|
|
246
|
+
"lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Walk all Chrome profiles' Secure Preferences and find an extension
|
|
250
|
+
// whose loaded `path` matches our dist directory. The extension ID Chrome
|
|
251
|
+
// generates for an unpacked extension is path-dependent, so we cannot rely
|
|
252
|
+
// on a hardcoded ID across machines.
|
|
253
|
+
async function findInstalledExtension(
|
|
254
|
+
profileDir?: string,
|
|
255
|
+
): Promise<{ id: string; profile: string } | null> {
|
|
256
|
+
const userDataDir = await findChromeUserDataDir();
|
|
257
|
+
if (!userDataDir) return null;
|
|
258
|
+
const cache = await readChromeProfileCache();
|
|
259
|
+
const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
|
|
260
|
+
for (const prof of profiles) {
|
|
261
|
+
const prefsPath = join(userDataDir, prof, "Secure Preferences");
|
|
262
|
+
const f = file(prefsPath);
|
|
263
|
+
if (!(await f.exists())) continue;
|
|
264
|
+
try {
|
|
265
|
+
const data = JSON.parse(await f.text());
|
|
266
|
+
const settings = data?.extensions?.settings ?? {};
|
|
267
|
+
for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
|
|
268
|
+
if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
|
|
269
|
+
let storedPath = info.path as string;
|
|
270
|
+
try { storedPath = realpathSync(storedPath); } catch {}
|
|
271
|
+
let distPath = EXTENSION_DIST_DIR;
|
|
272
|
+
try { distPath = realpathSync(distPath); } catch {}
|
|
273
|
+
if (storedPath === distPath) return { id: extId, profile: prof };
|
|
274
|
+
}
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function printInstallInstructions(profileDisplay: string): void {
|
|
281
|
+
console.error("");
|
|
282
|
+
console.error("Multi-tab extension is not installed in this Chrome profile.");
|
|
283
|
+
console.error("");
|
|
284
|
+
console.error("To install:");
|
|
285
|
+
console.error(" 1. Open chrome://extensions/ in the selected profile");
|
|
286
|
+
console.error(` (profile: ${profileDisplay})`);
|
|
287
|
+
console.error(" 2. Enable \"Developer mode\" (top-right toggle)");
|
|
288
|
+
console.error(" 3. Click \"Load unpacked\"");
|
|
289
|
+
console.error(" 4. Select this directory:");
|
|
290
|
+
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
291
|
+
console.error(" 5. Re-run `rech setup`");
|
|
292
|
+
console.error("");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function resolveProfileEmail(dir: string): Promise<string> {
|
|
296
|
+
const cache = await readChromeProfileCache();
|
|
297
|
+
if (cache?.[dir]?.user_name) return cache[dir].user_name;
|
|
298
|
+
return dir;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function listProfiles(): Promise<void> {
|
|
302
|
+
const cache = await readChromeProfileCache();
|
|
303
|
+
if (!cache) { console.error("Chrome Local State not found"); process.exit(1); }
|
|
304
|
+
|
|
305
|
+
const current = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
306
|
+
// Resolve email/name → dir for current marker
|
|
307
|
+
let currentDir = current;
|
|
308
|
+
if (current && !/^(Default|Profile \d+)$/i.test(current)) {
|
|
309
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
310
|
+
if (info.user_name === current || info.name === current) { currentDir = dir; break; }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const rows = Object.entries(cache).map(([dir, info]) => [
|
|
315
|
+
dir,
|
|
316
|
+
info.user_name || "",
|
|
317
|
+
info.name || "",
|
|
318
|
+
dir === currentDir ? "← current" : "",
|
|
319
|
+
]);
|
|
320
|
+
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function callServe(
|
|
327
|
+
url: string,
|
|
328
|
+
args: string[],
|
|
329
|
+
overrideEnv?: Record<string, string>,
|
|
330
|
+
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
331
|
+
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
|
|
135
332
|
const identity = await getClientIdentity();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
const res = await fetch(
|
|
333
|
+
const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
334
|
+
if (effectiveProfile) (identity as any).profile = effectiveProfile;
|
|
335
|
+
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
|
|
336
|
+
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
140
337
|
method: "POST",
|
|
141
338
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
142
|
-
body: JSON.stringify({ args, identity, env
|
|
339
|
+
body: JSON.stringify({ args, identity, env }),
|
|
143
340
|
signal: AbortSignal.timeout(70_000),
|
|
144
|
-
}).catch((e) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (res.status === 401) {
|
|
150
|
-
console.error("Unauthorized: bad key");
|
|
151
|
-
process.exit(1);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const { status, stdout, stderr, files, existingSession } = (await res.json()) as {
|
|
155
|
-
status: number;
|
|
156
|
-
stdout: string;
|
|
157
|
-
stderr: string;
|
|
158
|
-
files?: string[];
|
|
159
|
-
existingSession?: boolean;
|
|
160
|
-
};
|
|
341
|
+
}).catch((e) => { console.error(`[rech] ${e.message}`); process.exit(1); });
|
|
342
|
+
if (res.status === 401) { console.error("Unauthorized: bad key"); process.exit(1); }
|
|
343
|
+
return res.json();
|
|
344
|
+
}
|
|
161
345
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
346
|
+
async function run(url: string, args: string[]) {
|
|
347
|
+
const { host, port, protocol } = parseUrl(url);
|
|
348
|
+
const effectiveProfile = parseUrl(url).profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
|
|
349
|
+
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
350
|
+
const identity = await getClientIdentity();
|
|
351
|
+
const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
|
|
352
|
+
console.error(
|
|
353
|
+
`[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
|
|
357
|
+
|
|
358
|
+
if (existingSession)
|
|
359
|
+
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
167
360
|
if (stderr) process.stderr.write(stderr);
|
|
168
361
|
if (stdout) process.stdout.write(stdout);
|
|
169
362
|
|
|
@@ -173,8 +366,8 @@ async function run(url: string, args: string[]) {
|
|
|
173
366
|
const gitignorePath = join(dlDir, ".gitignore");
|
|
174
367
|
if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
|
|
175
368
|
for (const name of files) {
|
|
176
|
-
const fileRes = await fetch(
|
|
177
|
-
headers: { Authorization: `Bearer ${key}` },
|
|
369
|
+
const fileRes = await fetch(`${protocol}://${host}:${port}/files/${name}`, {
|
|
370
|
+
headers: { Authorization: `Bearer ${parseUrl(url).key}` },
|
|
178
371
|
});
|
|
179
372
|
if (!fileRes.ok) continue;
|
|
180
373
|
const dest = join(dlDir, basename(name));
|
|
@@ -186,20 +379,370 @@ async function run(url: string, args: string[]) {
|
|
|
186
379
|
process.exit(status);
|
|
187
380
|
}
|
|
188
381
|
|
|
382
|
+
function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
|
|
383
|
+
return `<!DOCTYPE html>
|
|
384
|
+
<html lang="en">
|
|
385
|
+
<head>
|
|
386
|
+
<meta charset="UTF-8">
|
|
387
|
+
<title>rechrome — Extension Setup</title>
|
|
388
|
+
<style>
|
|
389
|
+
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; }
|
|
390
|
+
h1 { color: #1a73e8; }
|
|
391
|
+
.step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding: 12px 16px; margin: 16px 0; border-radius: 0 8px 8px 0; }
|
|
392
|
+
.step h3 { margin: 0 0 8px; }
|
|
393
|
+
code { background: #e8eaed; padding: 2px 6px; border-radius: 4px; font-size: 0.95em; word-break: break-all; }
|
|
394
|
+
.path { display: flex; align-items: center; gap: 8px; }
|
|
395
|
+
button { background: #1a73e8; color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.9em; }
|
|
396
|
+
button:active { background: #1558b0; }
|
|
397
|
+
.note { color: #666; font-size: 0.9em; }
|
|
398
|
+
</style>
|
|
399
|
+
</head>
|
|
400
|
+
<body>
|
|
401
|
+
<h1>rechrome — Extension Setup</h1>
|
|
402
|
+
<p>Install the multi-tab extension in Chrome profile: <strong>${profileDisplay}</strong></p>
|
|
403
|
+
|
|
404
|
+
<div class="step">
|
|
405
|
+
<h3>Step 1 — Open Chrome Extensions</h3>
|
|
406
|
+
<p>In the Chrome profile <strong>${profileDisplay}</strong>, navigate to:</p>
|
|
407
|
+
<code>chrome://extensions/</code>
|
|
408
|
+
<p class="note">Make sure you are in the correct profile (check the avatar in the top-right corner).</p>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div class="step">
|
|
412
|
+
<h3>Step 2 — Enable Developer Mode</h3>
|
|
413
|
+
<p>Toggle <strong>Developer mode</strong> on (top-right of the extensions page).</p>
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
<div class="step">
|
|
417
|
+
<h3>Step 3 — Load the extension</h3>
|
|
418
|
+
<p>Click <strong>Load unpacked</strong> and select this directory:</p>
|
|
419
|
+
<div class="path">
|
|
420
|
+
<code id="extPath">${extDistDir}</code>
|
|
421
|
+
<button onclick="navigator.clipboard.writeText(document.getElementById('extPath').textContent).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy path',1500)})">Copy path</button>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<div class="step">
|
|
426
|
+
<h3>Step 4 — Return to terminal</h3>
|
|
427
|
+
<p>Press <strong>Enter</strong> in the terminal to continue setup.</p>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
<div class="step">
|
|
431
|
+
<h3>Step 5 — Copy auth token</h3>
|
|
432
|
+
<p>Click the extension icon in the Chrome toolbar (or open the URL below):</p>
|
|
433
|
+
<code id="statusUrl">chrome-extension://(detected after install)/status.html</code>
|
|
434
|
+
<p>The page shows <strong>PLAYWRIGHT_MCP_EXTENSION_TOKEN=...</strong> — paste that into the terminal when prompted.</p>
|
|
435
|
+
</div>
|
|
436
|
+
</body>
|
|
437
|
+
</html>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const OXMGR_PROCESS_NAME = "rechrome-serve";
|
|
441
|
+
|
|
442
|
+
async function runOxmgr(args: string[]): Promise<number> {
|
|
443
|
+
const proc = Bun.spawn(["bunx", "oxmgr", ...args], { stdout: "inherit", stderr: "inherit" });
|
|
444
|
+
await proc.exited;
|
|
445
|
+
return proc.exitCode ?? 1;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function daemonInstall(serveUrl: string): Promise<void> {
|
|
449
|
+
const home = process.env.HOME!;
|
|
450
|
+
const bunBin = Bun.which("bun") ?? process.execPath;
|
|
451
|
+
const rechScript = import.meta.filename;
|
|
452
|
+
|
|
453
|
+
const envArgs: string[] = [
|
|
454
|
+
"--env", `HOME=${home}`,
|
|
455
|
+
"--env", `PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}`,
|
|
456
|
+
"--env", `${ENV_KEY}=${serveUrl}`,
|
|
457
|
+
"--env", `PWMCP_TEST_CONNECTION_TIMEOUT=${process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000"}`,
|
|
458
|
+
];
|
|
459
|
+
if (process.env.PLAYWRIGHT_CLI) envArgs.push("--env", `PLAYWRIGHT_CLI=${process.env.PLAYWRIGHT_CLI}`);
|
|
460
|
+
if (process.env.RECH_HOST) envArgs.push("--env", `RECH_HOST=${process.env.RECH_HOST}`);
|
|
461
|
+
if (isReadable(process.env.RECH_TLS_CERT)) envArgs.push("--env", `RECH_TLS_CERT=${process.env.RECH_TLS_CERT}`);
|
|
462
|
+
if (isReadable(process.env.RECH_TLS_KEY)) envArgs.push("--env", `RECH_TLS_KEY=${process.env.RECH_TLS_KEY}`);
|
|
463
|
+
|
|
464
|
+
await runOxmgr(["delete", OXMGR_PROCESS_NAME]).catch(() => {});
|
|
465
|
+
await runOxmgr([
|
|
466
|
+
"start",
|
|
467
|
+
"--name", OXMGR_PROCESS_NAME,
|
|
468
|
+
"--restart", "always",
|
|
469
|
+
"--cwd", home,
|
|
470
|
+
...envArgs,
|
|
471
|
+
`${bunBin} ${rechScript} serve`,
|
|
472
|
+
]);
|
|
473
|
+
await runOxmgr(["service", "install"]);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function daemonUninstall(): Promise<void> {
|
|
477
|
+
await runOxmgr(["delete", OXMGR_PROCESS_NAME]);
|
|
478
|
+
await runOxmgr(["service", "uninstall"]);
|
|
479
|
+
console.log(`Removed oxmgr process: ${OXMGR_PROCESS_NAME}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function setup(opts: { profile?: string } = {}): Promise<void> {
|
|
483
|
+
const { createInterface } = await import("readline");
|
|
484
|
+
const isTTY = process.stdin.isTTY ?? false;
|
|
485
|
+
const rl = isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
486
|
+
const ask = (q: string, def = "") => {
|
|
487
|
+
if (!rl) { process.stdout.write(`${q}${def}\n`); return Promise.resolve(def); }
|
|
488
|
+
return new Promise<string>(r => rl.question(q, r));
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// [1/4] Daemon
|
|
492
|
+
console.log("\n[1/4] Setting up serve daemon...");
|
|
493
|
+
// Clear stale hostname-based URL so we always use 127.0.0.1 locally
|
|
494
|
+
if (process.env[ENV_KEY]) {
|
|
495
|
+
try {
|
|
496
|
+
const u = new URL(process.env[ENV_KEY]);
|
|
497
|
+
if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
|
|
498
|
+
} catch {}
|
|
499
|
+
}
|
|
500
|
+
const url = await getOrCreateUrl();
|
|
501
|
+
const { host, port, protocol } = parseUrl(url);
|
|
502
|
+
|
|
503
|
+
let ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
504
|
+
if (ping) {
|
|
505
|
+
console.log(` Already running at ${protocol}://${host}:${port}`);
|
|
506
|
+
await daemonInstall(url);
|
|
507
|
+
console.log(` Updated daemon: ${OXMGR_PROCESS_NAME}`);
|
|
508
|
+
} else {
|
|
509
|
+
await daemonInstall(url);
|
|
510
|
+
console.log(` Registered daemon: ${OXMGR_PROCESS_NAME}`);
|
|
511
|
+
process.stdout.write(" Starting");
|
|
512
|
+
for (let i = 0; i < 15; i++) {
|
|
513
|
+
await Bun.sleep(1000);
|
|
514
|
+
ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
515
|
+
if (ping) break;
|
|
516
|
+
process.stdout.write(".");
|
|
517
|
+
}
|
|
518
|
+
process.stdout.write("\n");
|
|
519
|
+
if (!ping) {
|
|
520
|
+
console.error(` Failed to start serve at ${host}:${port}`);
|
|
521
|
+
rl?.close();
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
console.log(` Serve running at ${protocol}://${host}:${port}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const cache = await readChromeProfileCache();
|
|
528
|
+
if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
|
|
529
|
+
const userDataDir = await findChromeUserDataDir();
|
|
530
|
+
|
|
531
|
+
async function pickProfile(exclude: Set<string>): Promise<[string, { user_name?: string; name?: string }] | null> {
|
|
532
|
+
const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
|
|
533
|
+
if (!available.length) return null;
|
|
534
|
+
available.forEach(([dir, info], i) =>
|
|
535
|
+
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
536
|
+
);
|
|
537
|
+
if (opts.profile !== undefined) {
|
|
538
|
+
const num = parseInt(opts.profile);
|
|
539
|
+
if (!isNaN(num)) return available[num - 1] ?? null;
|
|
540
|
+
return available.find(([, info]) =>
|
|
541
|
+
(info.user_name ?? "").toLowerCase().includes(opts.profile!.toLowerCase())
|
|
542
|
+
) ?? null;
|
|
543
|
+
}
|
|
544
|
+
if (!isTTY) {
|
|
545
|
+
console.log(" Non-TTY: auto-selecting first profile");
|
|
546
|
+
return available[0] ?? null;
|
|
547
|
+
}
|
|
548
|
+
const answer = await ask("\n Profile number: ");
|
|
549
|
+
const idx = parseInt(answer.trim()) - 1;
|
|
550
|
+
if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
|
|
551
|
+
return available[idx];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function getExtAndToken(profileDir: string, profileDisplay: string): Promise<{ extId: string; token: string } | null> {
|
|
555
|
+
// Extension check
|
|
556
|
+
let extId: string | undefined;
|
|
557
|
+
while (true) {
|
|
558
|
+
const found = await findInstalledExtension(profileDir);
|
|
559
|
+
if (found) { extId = found.id; break; }
|
|
560
|
+
const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
|
|
561
|
+
mkdirSync(RECH_HOME_DIR, { recursive: true });
|
|
562
|
+
await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
|
|
563
|
+
console.log(`\n Extension not found in profile: ${profileDisplay}`);
|
|
564
|
+
console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
|
|
565
|
+
console.log(`\n Opening install guide in your browser...`);
|
|
566
|
+
Bun.spawn(["open", setupHtmlPath], { stdout: "ignore", stderr: "ignore" });
|
|
567
|
+
await ask("\n Press Enter after loading the extension to retry...");
|
|
568
|
+
}
|
|
569
|
+
console.log(` Extension found: ${extId}`);
|
|
570
|
+
|
|
571
|
+
// Token
|
|
572
|
+
const statusUrl = `chrome-extension://${extId}/status.html`;
|
|
573
|
+
console.log(`\n Get auth token from the extension:`);
|
|
574
|
+
console.log(` ${statusUrl}`);
|
|
575
|
+
Bun.spawn(
|
|
576
|
+
["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
577
|
+
`--profile-directory=${profileDir}`, statusUrl],
|
|
578
|
+
{ stdout: "ignore", stderr: "ignore", detached: true },
|
|
579
|
+
);
|
|
580
|
+
console.log(`\n Or click the extension icon in the Chrome toolbar.`);
|
|
581
|
+
console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
|
|
582
|
+
const tokenInput = (await ask(" Paste token: ")).trim();
|
|
583
|
+
const token = tokenInput.replace(/^.*?=/, "").trim();
|
|
584
|
+
if (!token || token.length < 20) { console.error(" Invalid token (too short)"); return null; }
|
|
585
|
+
console.log(" Token accepted");
|
|
586
|
+
return { extId, token };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// [2/4] Primary profile
|
|
590
|
+
console.log("\n[2/4] Select Chrome profile:");
|
|
591
|
+
const picked = await pickProfile(new Set());
|
|
592
|
+
if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
|
|
593
|
+
const [profileDir, profileInfoSel] = picked;
|
|
594
|
+
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
595
|
+
|
|
596
|
+
// [3+4/4] Extension + token for primary profile
|
|
597
|
+
console.log("\n[3/4] Checking extension...");
|
|
598
|
+
const primary = await getExtAndToken(profileDir, profileDisplay);
|
|
599
|
+
if (!primary) { rl?.close(); process.exit(1); }
|
|
600
|
+
const { extId, token } = primary;
|
|
601
|
+
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
602
|
+
|
|
603
|
+
// Save RECHROME_URL
|
|
604
|
+
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
605
|
+
const pwdRechPath = join(process.cwd(), ".rechrome", ".env.local");
|
|
606
|
+
const homeEnvPath = join(process.env.HOME!, ".env.local");
|
|
607
|
+
const saveChoice = (await ask(
|
|
608
|
+
`\n[4/4] Save RECHROME_URL to:\n 1. ${pwdEnvPath} (current dir) [default]\n 2. ${pwdRechPath} (current dir, rechrome-only)\n 3. ${homeEnvPath} (user home)\n\n Choice [1]: `
|
|
609
|
+
)).trim();
|
|
610
|
+
const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
|
|
611
|
+
if (saveChoice === "2") mkdirSync(join(process.cwd(), ".rechrome"), { recursive: true });
|
|
612
|
+
const existing = await file(globalEnvPath).text().catch(() => "");
|
|
613
|
+
const rechUrl = new URL(url);
|
|
614
|
+
rechUrl.searchParams.set("extension_id", extId);
|
|
615
|
+
rechUrl.searchParams.set("token", token);
|
|
616
|
+
rechUrl.searchParams.set("profile", profileEmail);
|
|
617
|
+
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
618
|
+
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
619
|
+
const keysToRemove = ["PLAYWRIGHT_MCP_USER_DATA_DIR", "PLAYWRIGHT_MCP_EXTENSION_ID", "PLAYWRIGHT_MCP_EXTENSION_TOKEN", "PLAYWRIGHT_MCP_PROFILE_DIRECTORY"];
|
|
620
|
+
let lines = existing.trimEnd().split("\n").filter(l => !keysToRemove.some(k => l.startsWith(`${k}=`)));
|
|
621
|
+
const rechIdx = lines.findIndex(l => l.startsWith("RECHROME_URL="));
|
|
622
|
+
if (rechIdx >= 0) lines[rechIdx] = newLine;
|
|
623
|
+
else lines.push(newLine);
|
|
624
|
+
await Bun.write(globalEnvPath, lines.join("\n").trim() + "\n");
|
|
625
|
+
console.log(`\nSaved to ${globalEnvPath}`);
|
|
626
|
+
console.log(`\n ${newLine}`);
|
|
627
|
+
|
|
628
|
+
// Save primary to token registry
|
|
629
|
+
await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
|
|
630
|
+
|
|
631
|
+
// Additional profiles
|
|
632
|
+
const configured = new Set([profileDir]);
|
|
633
|
+
while (true) {
|
|
634
|
+
const more = (await ask("\nAdd another profile? [y/N]: ")).trim().toLowerCase();
|
|
635
|
+
if (more !== "y" && more !== "yes") break;
|
|
636
|
+
const remaining = Object.entries(cache!).filter(([dir]) => !configured.has(dir));
|
|
637
|
+
if (!remaining.length) { console.log(" No more profiles available."); break; }
|
|
638
|
+
console.log("\n Select additional profile:");
|
|
639
|
+
const extra = await pickProfile(configured);
|
|
640
|
+
if (!extra) { console.log(" Skipped."); continue; }
|
|
641
|
+
const [extraDir, extraInfo] = extra;
|
|
642
|
+
const extraDisplay = extraInfo.user_name || extraInfo.name || extraDir;
|
|
643
|
+
console.log(`\n Setting up: ${extraDisplay}`);
|
|
644
|
+
const result = await getExtAndToken(extraDir, extraDisplay);
|
|
645
|
+
if (!result) { console.log(" Skipped."); continue; }
|
|
646
|
+
const extraEmail = extraInfo.user_name || extraDir;
|
|
647
|
+
await saveTokenEntry(extraEmail, { extensionId: result.extId, token: result.token, profileDir: extraDir, userDataDir: userDataDir ?? undefined });
|
|
648
|
+
configured.add(extraDir);
|
|
649
|
+
console.log(` Saved token for ${extraDisplay}`);
|
|
650
|
+
}
|
|
651
|
+
rl?.close();
|
|
652
|
+
envWatcher?.close();
|
|
653
|
+
console.log(`\nDone! Test with:\n rech eval "() => document.title"`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function status(): Promise<void> {
|
|
657
|
+
const url = process.env[ENV_KEY];
|
|
658
|
+
if (!url) {
|
|
659
|
+
console.log(`serve: not configured (run \`rech setup\`)`);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const { host, port, protocol } = parseUrl(url);
|
|
663
|
+
const parsed = parseUrl(url);
|
|
664
|
+
const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
665
|
+
// Check actual socket binding via lsof (shows * for 0.0.0.0, or exact IP for loopback-only)
|
|
666
|
+
const lsofProc = Bun.spawn(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], { stdout: "pipe", stderr: "ignore" });
|
|
667
|
+
const lsofOut = await new Response(lsofProc.stdout).text();
|
|
668
|
+
const listenLine = lsofOut.split("\n").find(l => l.includes(`:${port}`));
|
|
669
|
+
const listenAddr = listenLine?.match(/TCP\s+(\S+:\d+)/)?.[1] ?? (ping ? `${host}:${port}` : null);
|
|
670
|
+
console.log(`serve: ${ping ? `running ${protocol}://${listenAddr ?? `${host}:${port}`}` : "not running"}`);
|
|
671
|
+
const oxmgrProc = Bun.spawn(["bunx", "oxmgr", "list"], { stdout: "pipe", stderr: "ignore" });
|
|
672
|
+
const oxmgrOut = await new Response(oxmgrProc.stdout).text();
|
|
673
|
+
const daemonRegistered = oxmgrOut.includes(OXMGR_PROCESS_NAME);
|
|
674
|
+
console.log(`daemon: ${daemonRegistered ? `oxmgr (${OXMGR_PROCESS_NAME})` : "not installed"}`);
|
|
675
|
+
const registry = await readTokenRegistry();
|
|
676
|
+
const entries = Object.entries(registry);
|
|
677
|
+
if (entries.length) {
|
|
678
|
+
console.log(`\nprofiles:`);
|
|
679
|
+
const primaryProfile = parsed.profileDirectory;
|
|
680
|
+
for (const [email, entry] of entries) {
|
|
681
|
+
const isPrimary = email === primaryProfile || entry.profileDir === primaryProfile;
|
|
682
|
+
const marker = isPrimary ? " (primary)" : "";
|
|
683
|
+
console.log(` ${email.padEnd(36)} [${entry.profileDir}] ext: ${entry.extensionId.slice(0, 8)}… token: ${entry.token.slice(0, 8)}…${marker}`);
|
|
684
|
+
}
|
|
685
|
+
} else if (parsed.profileDirectory) {
|
|
686
|
+
// Legacy: no registry yet, show from RECHROME_URL
|
|
687
|
+
const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
|
|
688
|
+
console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function printHelp(): void {
|
|
693
|
+
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
694
|
+
|
|
695
|
+
Usage:
|
|
696
|
+
rech setup First-time setup: daemon + Chrome extension + config
|
|
697
|
+
rech status Show current configuration and serve health
|
|
698
|
+
rech uninstall Remove the serve daemon and clear config
|
|
699
|
+
rech serve Start the serve server manually (foreground)
|
|
700
|
+
rech profiles List Chrome profiles
|
|
701
|
+
rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
|
|
702
|
+
|
|
703
|
+
Environment:
|
|
704
|
+
${ENV_KEY} Server URL set by \`rech setup\`
|
|
705
|
+
|
|
706
|
+
Examples:
|
|
707
|
+
rech setup
|
|
708
|
+
rech eval "() => document.title"
|
|
709
|
+
rech open https://example.com
|
|
710
|
+
rech screenshot`);
|
|
711
|
+
}
|
|
712
|
+
|
|
189
713
|
if (import.meta.main) {
|
|
190
714
|
const args = process.argv.slice(2);
|
|
715
|
+
const cmd = args[0]?.toLowerCase();
|
|
191
716
|
|
|
192
|
-
if (
|
|
717
|
+
if (cmd === "serve") {
|
|
193
718
|
const { serve } = await import("./serve.js");
|
|
194
|
-
serve();
|
|
719
|
+
serve(); // long-lived; watcher intentionally kept alive
|
|
720
|
+
} else if (cmd === "status") {
|
|
721
|
+
await status();
|
|
722
|
+
envWatcher?.close();
|
|
723
|
+
} else if (cmd === "profiles") {
|
|
724
|
+
await listProfiles();
|
|
725
|
+
envWatcher?.close();
|
|
726
|
+
} else if (cmd === "setup") {
|
|
727
|
+
const profileIdx = args.indexOf("--profile");
|
|
728
|
+
const profile = profileIdx !== -1
|
|
729
|
+
? args[profileIdx + 1]
|
|
730
|
+
: args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
|
|
731
|
+
await setup({ profile }); // setup closes envWatcher itself before printing Done
|
|
732
|
+
} else if (cmd === "uninstall") {
|
|
733
|
+
await daemonUninstall();
|
|
734
|
+
envWatcher?.close();
|
|
735
|
+
} else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
|
|
736
|
+
printHelp();
|
|
737
|
+
envWatcher?.close();
|
|
195
738
|
} else {
|
|
196
739
|
const url = process.env[ENV_KEY];
|
|
197
740
|
if (!url) {
|
|
198
|
-
console.error(
|
|
199
|
-
|
|
200
|
-
);
|
|
741
|
+
console.error(`${ENV_KEY} is not set. Run \`rech setup\` to configure.\n`);
|
|
742
|
+
printHelp();
|
|
201
743
|
process.exit(1);
|
|
202
744
|
}
|
|
203
|
-
run(url, args);
|
|
745
|
+
await run(url, args);
|
|
746
|
+
envWatcher?.close();
|
|
204
747
|
}
|
|
205
748
|
}
|