mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 13:15:24 +02:00
e9fbb664f8
Restructure project layout: src/ → browse/src/, test/ → browse/test/. Add snapshot testing. Update docs, package.json, and skills integration. Add setup script and TODO tracking. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
/**
|
|
* gstack CLI — thin wrapper that talks to the persistent server
|
|
*
|
|
* Flow:
|
|
* 1. Read /tmp/browse-server.json for port + token
|
|
* 2. If missing or stale PID → start server in background
|
|
* 3. Health check
|
|
* 4. Send command via HTTP POST
|
|
* 5. Print response to stdout (or stderr for errors)
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const PORT_OFFSET = 45600;
|
|
const BROWSE_PORT = process.env.CONDUCTOR_PORT
|
|
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
|
|
: parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
|
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
|
|
// When compiled, import.meta.dir is virtual. Use env var or well-known path.
|
|
const SERVER_SCRIPT = process.env.BROWSE_SERVER_SCRIPT
|
|
|| (import.meta.dir.startsWith('/') && !import.meta.dir.includes('$bunfs')
|
|
? path.resolve(import.meta.dir, 'server.ts')
|
|
: path.resolve(process.env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts'));
|
|
const MAX_START_WAIT = 8000; // 8 seconds to start
|
|
|
|
interface ServerState {
|
|
pid: number;
|
|
port: number;
|
|
token: string;
|
|
startedAt: string;
|
|
serverPath: string;
|
|
}
|
|
|
|
// ─── State File ────────────────────────────────────────────────
|
|
function readState(): ServerState | null {
|
|
try {
|
|
const data = fs.readFileSync(STATE_FILE, 'utf-8');
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Server Lifecycle ──────────────────────────────────────────
|
|
async function startServer(): Promise<ServerState> {
|
|
// Clean up stale state file
|
|
try { fs.unlinkSync(STATE_FILE); } catch {}
|
|
|
|
// Start server as detached background process
|
|
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env },
|
|
});
|
|
|
|
// Don't hold the CLI open
|
|
proc.unref();
|
|
|
|
// Wait for state file to appear
|
|
const start = Date.now();
|
|
while (Date.now() - start < MAX_START_WAIT) {
|
|
const state = readState();
|
|
if (state && isProcessAlive(state.pid)) {
|
|
return state;
|
|
}
|
|
await Bun.sleep(100);
|
|
}
|
|
|
|
// If we get here, server didn't start in time
|
|
// Try to read stderr for error message
|
|
const stderr = proc.stderr;
|
|
if (stderr) {
|
|
const reader = stderr.getReader();
|
|
const { value } = await reader.read();
|
|
if (value) {
|
|
const errText = new TextDecoder().decode(value);
|
|
throw new Error(`Server failed to start:\n${errText}`);
|
|
}
|
|
}
|
|
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
|
}
|
|
|
|
async function ensureServer(): Promise<ServerState> {
|
|
const state = readState();
|
|
|
|
if (state && isProcessAlive(state.pid)) {
|
|
// Server appears alive — do a health check
|
|
try {
|
|
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (resp.ok) {
|
|
const health = await resp.json() as any;
|
|
if (health.status === 'healthy') {
|
|
return state;
|
|
}
|
|
}
|
|
} catch {
|
|
// Health check failed — server is dead or unhealthy
|
|
}
|
|
}
|
|
|
|
// Need to (re)start
|
|
console.error('[browse] Starting server...');
|
|
return startServer();
|
|
}
|
|
|
|
// ─── Command Dispatch ──────────────────────────────────────────
|
|
async function sendCommand(state: ServerState, command: string, args: string[]): Promise<void> {
|
|
const body = JSON.stringify({ command, args });
|
|
|
|
try {
|
|
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${state.token}`,
|
|
},
|
|
body,
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
|
|
if (resp.status === 401) {
|
|
// Token mismatch — server may have restarted
|
|
console.error('[browse] Auth failed — server may have restarted. Retrying...');
|
|
const newState = readState();
|
|
if (newState && newState.token !== state.token) {
|
|
return sendCommand(newState, command, args);
|
|
}
|
|
throw new Error('Authentication failed');
|
|
}
|
|
|
|
const text = await resp.text();
|
|
|
|
if (resp.ok) {
|
|
process.stdout.write(text);
|
|
if (!text.endsWith('\n')) process.stdout.write('\n');
|
|
} else {
|
|
// Try to parse as JSON error
|
|
try {
|
|
const err = JSON.parse(text);
|
|
console.error(err.error || text);
|
|
if (err.hint) console.error(err.hint);
|
|
} catch {
|
|
console.error(text);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
} catch (err: any) {
|
|
if (err.name === 'AbortError') {
|
|
console.error('[browse] Command timed out after 30s');
|
|
process.exit(1);
|
|
}
|
|
// Connection error — server may have crashed
|
|
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
|
console.error('[browse] Server connection lost. Restarting...');
|
|
const newState = await startServer();
|
|
return sendCommand(newState, command, args);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ─── Main ──────────────────────────────────────────────────────
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
console.log(`gstack browse — Fast headless browser for AI coding agents
|
|
|
|
Usage: browse <command> [args...]
|
|
|
|
Navigation: goto <url> | back | forward | reload | url
|
|
Content: text | html [sel] | links | forms | accessibility
|
|
Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
|
|
hover <sel> | type <text> | press <key>
|
|
scroll [sel] | wait <sel> | viewport <WxH>
|
|
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
console [--clear] | network [--clear]
|
|
cookies | storage [set <k> <v>] | perf
|
|
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
|
Snapshot: snapshot [-i] [-c] [-d N] [-s sel]
|
|
Compare: diff <url1> <url2>
|
|
Multi-step: chain (reads JSON from stdin)
|
|
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
|
Server: status | cookie <n>=<v> | header <n>:<v>
|
|
useragent <str> | stop | restart
|
|
|
|
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|
click @e3 | fill @e4 "value" | hover @e1`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const command = args[0];
|
|
const commandArgs = args.slice(1);
|
|
|
|
// Special case: chain reads from stdin
|
|
if (command === 'chain' && commandArgs.length === 0) {
|
|
const stdin = await Bun.stdin.text();
|
|
commandArgs.push(stdin.trim());
|
|
}
|
|
|
|
const state = await ensureServer();
|
|
await sendCommand(state, command, commandArgs);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`[browse] ${err.message}`);
|
|
process.exit(1);
|
|
});
|