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