@ulpi/browse 0.10.0 → 1.0.1

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/src/cli.ts DELETED
@@ -1,783 +0,0 @@
1
- /**
2
- * browse CLI — thin wrapper that talks to the persistent server
3
- *
4
- * Flow:
5
- * 1. Read /tmp/browse-server.json for port + token
6
- * 2. If missing or stale PID → start server in background
7
- * 3. Health check
8
- * 4. Send command via HTTP POST
9
- * 5. Print response to stdout (or stderr for errors)
10
- */
11
-
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import { DEFAULTS } from './constants';
15
- import { loadConfig } from './config';
16
-
17
- // Global CLI flags — set in main(), used by sendCommand()
18
- const cliFlags = {
19
- json: false,
20
- contentBoundaries: false,
21
- allowedDomains: '' as string,
22
- headed: false,
23
- stateFile: '' as string,
24
- maxOutput: 0,
25
- };
26
-
27
- // Track whether --state has been applied (only sent on first command)
28
- let stateFileApplied = false;
29
-
30
- const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
31
- // One server per project directory by default. Sessions handle agent isolation.
32
- // For multiple servers on the same project: set BROWSE_INSTANCE or BROWSE_PORT.
33
- const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || '';
34
- const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
35
-
36
- /**
37
- * Resolve the project-local .browse/ directory for state files, logs, screenshots.
38
- * Walks up from cwd looking for .git/ or .claude/ (project root markers).
39
- * Creates <root>/.browse/ with a self-contained .gitignore.
40
- * Falls back to /tmp/ if not found (e.g. running outside a project).
41
- */
42
- function resolveLocalDir(): string {
43
- if (process.env.BROWSE_LOCAL_DIR) {
44
- try { fs.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true }); } catch {}
45
- return process.env.BROWSE_LOCAL_DIR;
46
- }
47
-
48
- let dir = process.cwd();
49
- for (let i = 0; i < 20; i++) {
50
- if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.claude'))) {
51
- const browseDir = path.join(dir, '.browse');
52
- try {
53
- fs.mkdirSync(browseDir, { recursive: true });
54
- // Self-contained .gitignore — users don't need to add .browse/ to their .gitignore
55
- const gi = path.join(browseDir, '.gitignore');
56
- if (!fs.existsSync(gi)) {
57
- fs.writeFileSync(gi, '*\n');
58
- }
59
- } catch {}
60
- return browseDir;
61
- }
62
- const parent = path.dirname(dir);
63
- if (parent === dir) break;
64
- dir = parent;
65
- }
66
- return '/tmp';
67
- }
68
-
69
- const LOCAL_DIR = resolveLocalDir();
70
- const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(LOCAL_DIR, `browse-server${INSTANCE_SUFFIX}.json`);
71
- const MAX_START_WAIT = 8000; // 8 seconds to start
72
- const LOCK_FILE = STATE_FILE + '.lock';
73
- const LOCK_STALE_MS = DEFAULTS.LOCK_STALE_THRESHOLD_MS;
74
-
75
- export function resolveServerScript(
76
- env: Record<string, string | undefined> = process.env,
77
- metaDir: string = import.meta.dir,
78
- ): string {
79
- // 1. Explicit env var override
80
- if (env.BROWSE_SERVER_SCRIPT) {
81
- return env.BROWSE_SERVER_SCRIPT;
82
- }
83
-
84
- // 2. server.ts adjacent to cli.ts (dev mode or installed)
85
- if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
86
- const direct = path.resolve(metaDir, 'server.ts');
87
- if (fs.existsSync(direct)) {
88
- return direct;
89
- }
90
- }
91
-
92
- // Compiled binary ($bunfs): server is bundled, no external file needed
93
- if (metaDir.includes('$bunfs')) {
94
- return '__compiled__';
95
- }
96
-
97
- throw new Error(
98
- '[browse] Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env var to the path of server.ts.'
99
- );
100
- }
101
-
102
- const SERVER_SCRIPT = resolveServerScript();
103
-
104
- interface ServerState {
105
- pid: number;
106
- port: number;
107
- token: string;
108
- startedAt: string;
109
- serverPath: string;
110
- }
111
-
112
- // ─── State File ────────────────────────────────────────────────
113
- function readState(): ServerState | null {
114
- try {
115
- const data = fs.readFileSync(STATE_FILE, 'utf-8');
116
- return JSON.parse(data);
117
- } catch {
118
- return null;
119
- }
120
- }
121
-
122
- function isProcessAlive(pid: number): boolean {
123
- try {
124
- process.kill(pid, 0);
125
- return true;
126
- } catch {
127
- return false;
128
- }
129
- }
130
-
131
- async function listInstances(): Promise<void> {
132
- try {
133
- const files = fs.readdirSync(LOCAL_DIR).filter(
134
- f => f.startsWith('browse-server') && f.endsWith('.json') && !f.endsWith('.lock')
135
- );
136
- if (files.length === 0) { console.log('(no running instances)'); return; }
137
-
138
- let found = false;
139
- for (const file of files) {
140
- try {
141
- const data = JSON.parse(fs.readFileSync(path.join(LOCAL_DIR, file), 'utf-8'));
142
- if (!data.pid || !data.port) continue;
143
-
144
- const alive = isProcessAlive(data.pid);
145
- let status = 'dead';
146
- let sessions = 0;
147
- if (alive) {
148
- try {
149
- const resp = await fetch(`http://127.0.0.1:${data.port}/health`, { signal: AbortSignal.timeout(1000) });
150
- if (resp.ok) {
151
- const health = await resp.json() as any;
152
- status = health.status === 'healthy' ? 'healthy' : 'unhealthy';
153
- sessions = health.sessions || 0;
154
- }
155
- } catch { status = 'unreachable'; }
156
- }
157
-
158
- // Derive instance name from filename
159
- const match = file.match(/^browse-server-?(.*)\.json$/);
160
- const instance = match?.[1] || 'default';
161
-
162
- console.log(` ${instance.padEnd(15)} PID ${String(data.pid).padEnd(8)} port ${data.port} ${status}${sessions ? ` ${sessions} session(s)` : ''}`);
163
- found = true;
164
-
165
- // Clean up dead entries
166
- if (!alive) {
167
- try { fs.unlinkSync(path.join(LOCAL_DIR, file)); } catch {}
168
- }
169
- } catch {}
170
- }
171
- if (!found) console.log('(no running instances)');
172
- } catch { console.log('(no running instances)'); }
173
- }
174
-
175
- function isBrowseProcess(pid: number): boolean {
176
- try {
177
- const { execSync } = require('child_process');
178
- const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8' }).trim();
179
- return cmd.includes('browse') || cmd.includes('__BROWSE_SERVER_MODE');
180
- } catch {
181
- return false;
182
- }
183
- }
184
-
185
- // ─── Server Lifecycle ──────────────────────────────────────────
186
-
187
- /**
188
- * Acquire a lock file to prevent concurrent server spawns.
189
- * Uses O_EXCL (wx flag) for atomic creation.
190
- * Returns true if lock acquired, false if another process holds it.
191
- */
192
- function acquireLock(): boolean {
193
- try {
194
- fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' });
195
- return true;
196
- } catch (err: any) {
197
- if (err.code === 'EEXIST') {
198
- // Check if lock is stale
199
- try {
200
- const stat = fs.statSync(LOCK_FILE);
201
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
202
- // Lock is stale — remove and retry
203
- try { fs.unlinkSync(LOCK_FILE); } catch {}
204
- return acquireLock();
205
- }
206
- } catch {}
207
- return false;
208
- }
209
- throw err;
210
- }
211
- }
212
-
213
- function releaseLock() {
214
- try { fs.unlinkSync(LOCK_FILE); } catch {}
215
- }
216
-
217
- async function startServer(): Promise<ServerState> {
218
- // Acquire lock to prevent concurrent spawns
219
- if (!acquireLock()) {
220
- // Another process is starting the server — wait for state file or lock release
221
- const start = Date.now();
222
- while (Date.now() - start < MAX_START_WAIT) {
223
- const state = readState();
224
- if (state && isProcessAlive(state.pid)) {
225
- return state;
226
- }
227
- // If the lock was released (first starter failed), retry acquiring it
228
- // instead of waiting forever for a state file that will never appear.
229
- if (acquireLock()) {
230
- // We now hold the lock — fall through to the spawn logic below
231
- break;
232
- }
233
- await Bun.sleep(100);
234
- }
235
- // If we still don't hold the lock and no state file appeared, give up
236
- if (!fs.existsSync(LOCK_FILE) || fs.readFileSync(LOCK_FILE, 'utf-8').trim() !== String(process.pid)) {
237
- const state = readState();
238
- if (state && isProcessAlive(state.pid)) return state;
239
- throw new Error('Server failed to start (another process is starting it)');
240
- }
241
- }
242
-
243
- try {
244
- // Only remove state file if it belongs to a dead process
245
- try {
246
- const oldState = readState();
247
- if (oldState && !isProcessAlive(oldState.pid)) {
248
- fs.unlinkSync(STATE_FILE);
249
- }
250
- } catch {}
251
-
252
- // Start server as detached background process.
253
- // Compiled binary: self-spawn with __BROWSE_SERVER_MODE=1
254
- // Dev mode: spawn bun with server.ts
255
- const spawnCmd = SERVER_SCRIPT === '__compiled__'
256
- ? [process.execPath]
257
- : ['bun', 'run', SERVER_SCRIPT];
258
- const proc = Bun.spawn(spawnCmd, {
259
- stdio: ['ignore', 'pipe', 'pipe'],
260
- env: { ...process.env, __BROWSE_SERVER_MODE: '1', BROWSE_LOCAL_DIR: LOCAL_DIR, BROWSE_INSTANCE, ...(cliFlags.headed ? { BROWSE_HEADED: '1' } : {}) },
261
- });
262
-
263
- // Don't hold the CLI open
264
- proc.unref();
265
-
266
- // Wait for state file to appear
267
- const start = Date.now();
268
- while (Date.now() - start < MAX_START_WAIT) {
269
- const state = readState();
270
- if (state && isProcessAlive(state.pid)) {
271
- return state;
272
- }
273
- await Bun.sleep(100);
274
- }
275
-
276
- // If we get here, server didn't start in time
277
- // Try to read stderr for error message
278
- const stderr = proc.stderr;
279
- if (stderr) {
280
- const reader = stderr.getReader();
281
- const { value } = await reader.read();
282
- if (value) {
283
- const errText = new TextDecoder().decode(value);
284
- throw new Error(`Server failed to start:\n${errText}`);
285
- }
286
- }
287
- throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
288
- } finally {
289
- releaseLock();
290
- }
291
- }
292
-
293
- async function ensureServer(): Promise<ServerState> {
294
- const state = readState();
295
-
296
- if (state && isProcessAlive(state.pid)) {
297
- // Server appears alive — do a health check
298
- try {
299
- const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
300
- signal: AbortSignal.timeout(DEFAULTS.HEALTH_CHECK_TIMEOUT_MS),
301
- });
302
- if (resp.ok) {
303
- const health = await resp.json() as any;
304
- if (health.status === 'healthy') {
305
- return state;
306
- }
307
- }
308
- } catch {
309
- // Health check failed — server is dead or unhealthy
310
- }
311
-
312
- // Server is alive but unhealthy (shutting down, browser crashed).
313
- // Kill it so we can start fresh — but only if it's actually a browse process.
314
- if (isBrowseProcess(state.pid)) {
315
- try { process.kill(state.pid, 'SIGTERM'); } catch {}
316
- // Brief wait for graceful exit
317
- const deadline = Date.now() + 3000;
318
- while (Date.now() < deadline && isProcessAlive(state.pid)) {
319
- await Bun.sleep(100);
320
- }
321
- if (isProcessAlive(state.pid)) {
322
- try { process.kill(state.pid, 'SIGKILL'); } catch {}
323
- await Bun.sleep(200);
324
- }
325
- }
326
- }
327
-
328
- // Clean up stale state file
329
- if (state) {
330
- try { fs.unlinkSync(STATE_FILE); } catch {}
331
- }
332
-
333
- // Clean up orphaned state files from other instances (e.g., old PPID-suffixed files)
334
- cleanOrphanedServers();
335
-
336
- // Need to (re)start
337
- console.error('[browse] Starting server...');
338
- return startServer();
339
- }
340
-
341
- /**
342
- * Clean up orphaned browse server state files.
343
- * Removes any browse-server*.json whose PID is dead.
344
- * Kills live orphans (legacy PPID-suffixed files from pre-v0.2.4) if they're browse processes.
345
- * Preserves intentional BROWSE_PORT instances (suffix matches port inside the file).
346
- */
347
- function cleanOrphanedServers(): void {
348
- try {
349
- const files = fs.readdirSync(LOCAL_DIR);
350
- for (const file of files) {
351
- if (!file.startsWith('browse-server') || !file.endsWith('.json') || file.endsWith('.lock')) continue;
352
- const filePath = path.join(LOCAL_DIR, file);
353
- if (filePath === STATE_FILE) continue;
354
- try {
355
- const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
356
- if (!data.pid) { fs.unlinkSync(filePath); continue; }
357
- // Preserve intentional BROWSE_PORT instances (suffix = port number)
358
- const suffixMatch = file.match(/browse-server-(\d+)\.json$/);
359
- if (suffixMatch && data.port === parseInt(suffixMatch[1], 10) && isProcessAlive(data.pid)) continue;
360
- // Dead process → remove state file
361
- if (!isProcessAlive(data.pid)) { fs.unlinkSync(filePath); continue; }
362
- // Live orphan (legacy PPID file) → kill if it's a browse process
363
- if (isBrowseProcess(data.pid)) {
364
- try { process.kill(data.pid, 'SIGTERM'); } catch {}
365
- }
366
- } catch { try { fs.unlinkSync(filePath); } catch {} }
367
- }
368
- } catch {}
369
- }
370
-
371
- // ─── Command Dispatch ──────────────────────────────────────────
372
-
373
- // Commands that are safe to retry after a transport failure.
374
- // Write commands (click, fill, goto, etc.) may have already executed
375
- // before the connection dropped — retrying them could duplicate side effects.
376
- // NOTE: 'js' and 'eval' excluded — page.evaluate() can run arbitrary side effects
377
- // NOTE: 'storage' excluded — 'storage set' mutates localStorage
378
- export const SAFE_TO_RETRY = new Set([
379
- // Read commands — no side effects
380
- 'text', 'html', 'links', 'forms', 'accessibility',
381
- 'css', 'attrs', 'element-state', 'dialog',
382
- 'console', 'network', 'cookies', 'perf', 'value', 'count',
383
- // Meta commands that are read-only or idempotent
384
- 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find', 'record', 'cookie-import',
385
- 'box', 'errors', 'doctor', 'upgrade',
386
- ]);
387
-
388
- // Commands that return static data independent of page state.
389
- // Safe to retry even after a server restart (no "blank page" issue).
390
- const PAGE_INDEPENDENT = new Set(['devices', 'status']);
391
-
392
- async function sendCommand(state: ServerState, command: string, args: string[], retries = 0, sessionId?: string): Promise<void> {
393
- const body = JSON.stringify({ command, args });
394
-
395
- const headers: Record<string, string> = {
396
- 'Content-Type': 'application/json',
397
- 'Authorization': `Bearer ${state.token}`,
398
- };
399
- if (sessionId) {
400
- headers['X-Browse-Session'] = sessionId;
401
- }
402
- if (cliFlags.json) {
403
- headers['X-Browse-Json'] = '1';
404
- }
405
- if (cliFlags.contentBoundaries) {
406
- headers['X-Browse-Boundaries'] = '1';
407
- }
408
- if (cliFlags.allowedDomains) {
409
- headers['X-Browse-Allowed-Domains'] = cliFlags.allowedDomains;
410
- }
411
- if (cliFlags.stateFile && !stateFileApplied) {
412
- headers['X-Browse-State'] = cliFlags.stateFile;
413
- stateFileApplied = true;
414
- }
415
- if (cliFlags.maxOutput > 0) {
416
- headers['X-Browse-Max-Output'] = String(cliFlags.maxOutput);
417
- }
418
-
419
- try {
420
- const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
421
- method: 'POST',
422
- headers,
423
- body,
424
- signal: AbortSignal.timeout(30000),
425
- });
426
-
427
- if (resp.status === 401) {
428
- // Token mismatch — server may have restarted
429
- console.error('[browse] Auth failed — server may have restarted. Retrying...');
430
- const newState = readState();
431
- if (newState && newState.token !== state.token) {
432
- return sendCommand(newState, command, args, 0, sessionId);
433
- }
434
- throw new Error('Authentication failed');
435
- }
436
-
437
- const text = await resp.text();
438
-
439
- if (resp.ok) {
440
- process.stdout.write(text);
441
- if (!text.endsWith('\n')) process.stdout.write('\n');
442
-
443
- // After stop/restart, wait for old server to actually die
444
- if (command === 'stop' || command === 'restart') {
445
- const oldPid = state.pid;
446
- const deadline = Date.now() + 5000;
447
- while (Date.now() < deadline && isProcessAlive(oldPid)) {
448
- await Bun.sleep(100);
449
- }
450
- if (isProcessAlive(oldPid)) {
451
- try { process.kill(oldPid, 'SIGKILL'); } catch {}
452
- await Bun.sleep(300);
453
- }
454
- // Clean up state file
455
- try { fs.unlinkSync(STATE_FILE); } catch {}
456
-
457
- if (command === 'restart') {
458
- const newState = await startServer();
459
- console.error(`[browse] Server restarted (PID: ${newState.pid})`);
460
- }
461
- }
462
- } else {
463
- // Try to parse as JSON error
464
- try {
465
- const err = JSON.parse(text);
466
- console.error(err.error || text);
467
- if (err.hint) console.error(err.hint);
468
- } catch {
469
- console.error(text);
470
- }
471
- process.exit(1);
472
- }
473
- } catch (err: any) {
474
- if (err.name === 'AbortError') {
475
- console.error('[browse] Command timed out after 30s');
476
- process.exit(1);
477
- }
478
- // Connection error — server may have crashed
479
- if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
480
- if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
481
-
482
- // ECONNREFUSED = server not listening, command never executed → safe to retry anything.
483
- // ECONNRESET/fetch failed = connection dropped mid-request, command may have executed.
484
- // Only retry read-only commands to avoid duplicating side effects (e.g., form submits).
485
- if (err.code !== 'ECONNREFUSED' && !SAFE_TO_RETRY.has(command)) {
486
- throw new Error(
487
- `[browse] Connection lost during '${command}'. ` +
488
- `The action may have already executed — not retrying to avoid duplicating side effects.`
489
- );
490
- }
491
-
492
- console.error('[browse] Server connection lost. Restarting...');
493
- const newState = await startServer();
494
-
495
- // After a restart the new server has a fresh browser (blank page).
496
- // Read-only commands would silently return data from that blank page,
497
- // which is worse than an error. Only retry navigation commands that
498
- // will establish session state on the new server.
499
- // Exception: page-independent commands (devices, status) return static
500
- // data that doesn't depend on page state — safe to retry on blank page.
501
- if (SAFE_TO_RETRY.has(command) && !PAGE_INDEPENDENT.has(command)) {
502
- throw new Error(
503
- `[browse] Server restarted but '${command}' would return data from a blank page. ` +
504
- `Re-navigate with 'goto' first, then retry.`
505
- );
506
- }
507
-
508
- return sendCommand(newState, command, args, retries + 1, sessionId);
509
- }
510
- throw err;
511
- }
512
- }
513
-
514
- // ─── Main ──────────────────────────────────────────────────────
515
- export async function main() {
516
- const args = process.argv.slice(2);
517
-
518
- // Load project config (browse.json) — values serve as defaults
519
- const config = loadConfig();
520
-
521
- // Find the first non-flag arg (the command) to limit global flag scanning.
522
- // Only extract global flags from args BEFORE the command position.
523
- function findCommandIndex(a: string[]): number {
524
- for (let i = 0; i < a.length; i++) {
525
- if (!a[i].startsWith('-')) return i;
526
- // Skip flag values for known value-flags
527
- if (a[i] === '--session' || a[i] === '--allowed-domains' || a[i] === '--cdp' || a[i] === '--state') i++;
528
- }
529
- return a.length;
530
- }
531
-
532
- // Extract --session flag (only before command)
533
- let sessionId: string | undefined;
534
- const sessionIdx = args.indexOf('--session');
535
- if (sessionIdx !== -1 && sessionIdx < findCommandIndex(args)) {
536
- sessionId = args[sessionIdx + 1];
537
- if (!sessionId || sessionId.startsWith('-')) {
538
- console.error('Usage: browse --session <id> <command> [args...]');
539
- process.exit(1);
540
- }
541
- args.splice(sessionIdx, 2);
542
- }
543
- sessionId = sessionId || process.env.BROWSE_SESSION || config.session || undefined;
544
-
545
- // Extract --json flag (only before command)
546
- let jsonMode = false;
547
- const jsonIdx = args.indexOf('--json');
548
- if (jsonIdx !== -1 && jsonIdx < findCommandIndex(args)) {
549
- jsonMode = true;
550
- args.splice(jsonIdx, 1);
551
- }
552
- jsonMode = jsonMode || process.env.BROWSE_JSON === '1' || config.json === true;
553
-
554
- // Extract --content-boundaries flag (only before command)
555
- let contentBoundaries = false;
556
- const boundIdx = args.indexOf('--content-boundaries');
557
- if (boundIdx !== -1 && boundIdx < findCommandIndex(args)) {
558
- contentBoundaries = true;
559
- args.splice(boundIdx, 1);
560
- }
561
- contentBoundaries = contentBoundaries || process.env.BROWSE_CONTENT_BOUNDARIES === '1' || config.contentBoundaries === true;
562
-
563
- // Extract --allowed-domains flag (only before command)
564
- let allowedDomains: string | undefined;
565
- const domIdx = args.indexOf('--allowed-domains');
566
- if (domIdx !== -1 && domIdx < findCommandIndex(args)) {
567
- allowedDomains = args[domIdx + 1];
568
- if (!allowedDomains || allowedDomains.startsWith('-')) {
569
- console.error('Usage: browse --allowed-domains domain1,domain2 <command> [args...]');
570
- process.exit(1);
571
- }
572
- args.splice(domIdx, 2);
573
- }
574
- allowedDomains = allowedDomains || process.env.BROWSE_ALLOWED_DOMAINS || (config.allowedDomains ? config.allowedDomains.join(',') : undefined);
575
-
576
- // Extract --headed flag (only before command)
577
- let headed = false;
578
- const headedIdx = args.indexOf('--headed');
579
- if (headedIdx !== -1 && headedIdx < findCommandIndex(args)) {
580
- headed = true;
581
- args.splice(headedIdx, 1);
582
- }
583
- headed = headed || process.env.BROWSE_HEADED === '1';
584
-
585
- // Extract --connect flag (only before command)
586
- let connectFlag = false;
587
- const connectIdx = args.indexOf('--connect');
588
- if (connectIdx !== -1 && connectIdx < findCommandIndex(args)) {
589
- connectFlag = true;
590
- args.splice(connectIdx, 1);
591
- }
592
-
593
- // Extract --cdp <port> flag (only before command)
594
- let cdpPort: number | undefined;
595
- const cdpIdx = args.indexOf('--cdp');
596
- if (cdpIdx !== -1 && cdpIdx < findCommandIndex(args)) {
597
- const portStr = args[cdpIdx + 1];
598
- if (!portStr || portStr.startsWith('-')) {
599
- console.error('Usage: browse --cdp <port> <command> [args...]');
600
- process.exit(1);
601
- }
602
- cdpPort = parseInt(portStr, 10);
603
- if (!Number.isFinite(cdpPort) || cdpPort <= 0) {
604
- console.error(`Invalid CDP port: ${portStr}`);
605
- process.exit(1);
606
- }
607
- args.splice(cdpIdx, 2);
608
- }
609
-
610
- // Extract --state <path> flag (only before command)
611
- let stateFile = '';
612
- const stateIdx = args.indexOf('--state');
613
- if (stateIdx !== -1 && stateIdx < findCommandIndex(args)) {
614
- stateFile = args[stateIdx + 1] || '';
615
- if (!stateFile || stateFile.startsWith('-')) {
616
- console.error('Usage: browse --state <path> <command> [args...]');
617
- process.exit(1);
618
- }
619
- args.splice(stateIdx, 2);
620
- }
621
-
622
- // Handle --connect / --cdp: blocked by Bun WebSocket bug (oven-sh/bun#9911)
623
- // Bun's compiled binary sends "Connection: keep-alive" instead of "Connection: Upgrade",
624
- // breaking the WebSocket handshake required by Playwright's connectOverCDP().
625
- // This affects all CDP-based connections (--connect, --cdp, lightpanda).
626
- // The discovery and flag logic is ready — enable when Bun fixes the bug.
627
- if (connectFlag || cdpPort) {
628
- console.error(
629
- '--connect/--cdp are not yet supported in the compiled binary.\n' +
630
- 'Bun\'s WebSocket client breaks the CDP handshake (oven-sh/bun#9911).\n' +
631
- 'Workaround: use cookie-import to borrow auth from your browser instead:\n' +
632
- ' browse cookie-import chrome --domain <your-domain>'
633
- );
634
- process.exit(1);
635
- }
636
-
637
- // Extract --max-output <n> flag (only before command)
638
- let maxOutput = 0;
639
- const maxOutputIdx = args.indexOf('--max-output');
640
- if (maxOutputIdx !== -1 && maxOutputIdx < findCommandIndex(args)) {
641
- const val = args[maxOutputIdx + 1];
642
- if (!val || val.startsWith('-')) {
643
- console.error('Usage: browse --max-output <chars> <command> [args...]');
644
- process.exit(1);
645
- }
646
- maxOutput = parseInt(val, 10);
647
- args.splice(maxOutputIdx, 2);
648
- }
649
- maxOutput = maxOutput || parseInt(process.env.BROWSE_MAX_OUTPUT || '0', 10) || 0;
650
-
651
- // Set global flags for sendCommand()
652
- cliFlags.json = jsonMode;
653
- cliFlags.contentBoundaries = contentBoundaries;
654
- cliFlags.allowedDomains = allowedDomains || '';
655
- cliFlags.headed = headed;
656
- cliFlags.stateFile = stateFile;
657
- cliFlags.maxOutput = maxOutput;
658
-
659
- // ─── Local commands (no server needed) ─────────────────────
660
- if (args[0] === 'version' || args[0] === '--version' || args[0] === '-V') {
661
- const pkg = await import('../package.json');
662
- console.log(pkg.version);
663
- return;
664
- }
665
-
666
- if (args[0] === 'instances') {
667
- await listInstances();
668
- return;
669
- }
670
-
671
- if (args[0] === 'install-skill') {
672
- const { installSkill } = await import('./install-skill');
673
- installSkill(args[1]);
674
- return;
675
- }
676
-
677
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
678
- console.log(`browse — Fast headless browser for AI coding agents
679
-
680
- Usage: browse [options] <command> [args...]
681
-
682
- Navigation: goto <url> | back | forward | reload | url
683
- Content: text | html [sel] | links | forms | accessibility
684
- Interaction: click <sel> | rightclick <sel> | dblclick <sel>
685
- fill <sel> <val> | select <sel> <val>
686
- hover <sel> | focus <sel> | tap <sel>
687
- check <sel> | uncheck <sel> | drag <src> <tgt>
688
- type <text> | press <key> | keydown <key> | keyup <key>
689
- keyboard inserttext <text>
690
- scroll [sel|up|down] | scrollinto <sel>
691
- swipe <up|down|left|right> [px]
692
- wait <sel|ms|--url|--text|--fn|--load|--network-idle>
693
- viewport <WxH> | highlight <sel> | download <sel> [path]
694
- Mouse: mouse move <x> <y> | mouse down [btn] | mouse up [btn]
695
- mouse wheel <dy> [dx]
696
- Settings: set geo <lat> <lng> | set media <dark|light|no-preference>
697
- Device: emulate <device> | emulate reset | devices [filter]
698
- Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
699
- element-state <sel> | box <sel>
700
- console [--clear] | errors [--clear] | network [--clear]
701
- cookies | storage [set <k> <v>] | perf
702
- value <sel> | count <sel> | clipboard [write <text>]
703
- Visual: screenshot [sel|@ref] [path] [--full] [--clip x,y,w,h]
704
- screenshot --annotate | pdf [path] | responsive [prefix]
705
- Snapshot: snapshot [-i] [-f] [-V] [-c] [-C] [-d N] [-s sel]
706
- Find: find role|text|label|placeholder|testid|alt|title <query>
707
- find first|last <sel> | find nth <n> <sel>
708
- Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
709
- Multi-step: chain (reads JSON from stdin)
710
- Cookies: cookie <n>=<v> | cookie set <n> <v> [--domain --secure]
711
- cookie clear | cookie export <file> | cookie import <file>
712
- Network: offline [on|off] | route <pattern> block|fulfill
713
- header <n>:<v> | useragent <str>
714
- Recording: har start | har stop [path]
715
- video start [dir] | video stop | video status
716
- record start | record stop | record status
717
- record export browse|replay [path]
718
- Tabs: tabs | tab <id> | newtab [url] | closetab [id]
719
- Frames: frame <sel> | frame main
720
- Sessions: sessions | session-close <id>
721
- Auth: auth save <name> <url> <user> <pass|--password-stdin>
722
- auth login <name> | auth list | auth delete <name>
723
- cookie-import --list | cookie-import <browser> [--domain <d>] [--profile <p>]
724
- State: state save|load|list|show [name]
725
- Debug: inspect (requires BROWSE_DEBUG_PORT)
726
- Server: status | instances | stop | restart | doctor | upgrade
727
- Setup: install-skill [path]
728
-
729
- Options:
730
- --session <id> Named session (isolates tabs, refs, cookies)
731
- --json Wrap output as {success, data, command}
732
- --content-boundaries Wrap page content in nonce-delimited markers
733
- --allowed-domains <d,d> Block navigation/resources outside allowlist
734
- --headed Run browser in headed (visible) mode
735
- --max-output <n> Truncate output to N characters
736
- --state <path> Load state file (cookies/storage) before first command
737
- --connect Auto-discover and connect to running Chrome
738
- --cdp <port> Connect to Chrome on specific debugging port
739
-
740
- Snapshot flags:
741
- -i Interactive elements only (terse flat list by default)
742
- -f Full — indented tree with props and children (use with -i)
743
- -V Viewport — only elements visible in current viewport
744
- -c Compact — remove empty structural elements
745
- -C Cursor-interactive — detect divs with cursor:pointer,
746
- onclick, tabindex, data-action (missed by ARIA tree)
747
- -d N Limit tree depth to N levels
748
- -s <sel> Scope to CSS selector
749
-
750
- Refs: After 'snapshot', use @e1, @e2... as selectors:
751
- click @e3 | fill @e4 "value" | hover @e1`);
752
- process.exit(0);
753
- }
754
-
755
- const command = args[0];
756
- const commandArgs = args.slice(1);
757
-
758
- // Special case: chain reads from stdin
759
- if (command === 'chain' && commandArgs.length === 0) {
760
- const stdin = await Bun.stdin.text();
761
- commandArgs.push(stdin.trim());
762
- }
763
-
764
- // Special case: auth --password-stdin reads in CLI before sending to server
765
- if (command === 'auth' && commandArgs.includes('--password-stdin')) {
766
- const stdinIdx = commandArgs.indexOf('--password-stdin');
767
- const password = (await Bun.stdin.text()).trim();
768
- commandArgs.splice(stdinIdx, 1, password);
769
- }
770
-
771
- const state = await ensureServer();
772
- await sendCommand(state, command, commandArgs, 0, sessionId);
773
- }
774
-
775
- if (process.env.__BROWSE_SERVER_MODE === '1') {
776
- import('./server');
777
- } else if (import.meta.main) {
778
- // Direct execution: bun run src/cli.ts <command>
779
- main().catch((err) => {
780
- console.error(`[browse] ${err.message}`);
781
- process.exit(1);
782
- });
783
- }