mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 05:56:41 +02:00
07b4e15b34
* fix: cookie import picker returns JSON instead of HTML jsonResponse() was defined at module scope but referenced `url` which only existed as a parameter of handleCookiePickerRoute(). Every API call crashed, the catch block also crashed, and Bun returned a default HTML page that the frontend couldn't parse as JSON. Thread port via corsOrigin() helper and options objects. Add route-level tests to prevent this class of bug from shipping again. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add help command to browse server Agents that don't have SKILL.md loaded (or misread flags) had no way to self-discover the CLI. The help command returns a formatted reference of all commands and snapshot flags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: version-aware find-browse with META signal protocol Agents in other workspaces found stale browse binaries that were missing newer flags. find-browse now compares the local binary's git SHA against origin/main via git ls-remote (4hr cache), and emits META:UPDATE_AVAILABLE when behind. SKILL.md setup checks parse META signals and prompt the user to update. - New compiled binary: browse/dist/find-browse (TypeScript, testable) - Bash shim at browse/bin/find-browse delegates to compiled binary - .version file written at build time with git commit SHA - Build script compiles both browse and find-browse binaries - Graceful degradation: offline, missing .version, corrupt cache all skip check Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: clean up .bun-build temp files after compile bun build --compile leaves ~58MB temp files in the working directory. Add rm -f .*.bun-build to the build script to clean up after each build. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make help command reachable by removing it from META_COMMANDS help was in META_COMMANDS, so it dispatched to handleMetaCommand() which threw "Unknown meta command: help". Removing it from the set lets the dedicated else-if handler in handleCommand() execute correctly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.3.2) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add shared Greptile comment triage reference doc Shared reference for fetching, filtering, and classifying Greptile review comments on GitHub PRs. Used by both /review and /ship skills. Includes parallel API fetching, suppressions check, classification logic, reply APIs, and history file writes. * feat: make /review and /ship Greptile-aware /review: Step 2.5 fetches and classifies Greptile comments, Step 5 resolves them with AskUserQuestion for valid issues and false positives. /ship: Step 3.75 triages Greptile comments between pre-landing review and version bump. Adds Greptile Review section to PR body in Step 8. Re-runs tests if any Greptile fixes are applied. * feat: add Greptile batting average to /retro Reads ~/.gstack/greptile-history.md, computes signal ratio (valid catches vs false positives), includes in metrics table, JSON snapshot, and Code Quality Signals narrative. * docs: add Greptile integration section to README Personal endorsement, two-layer review narrative, full UX walkthrough transcript, skills table updates. Add Greptile training feedback loop to TODO.md future ideas. * feat: add local dev mode for testing skills from within the repo bin/dev-setup creates .claude/skills/gstack symlink to the working tree so Claude Code discovers skills locally. bin/dev-teardown cleans up. DEVELOPING_GSTACK.md documents the workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: narrow gitignore to .claude/skills/ instead of all .claude/ Avoids ignoring legitimate Claude Code config like settings.json or CLAUDE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: rename DEVELOPING_GSTACK.md to CONTRIBUTING.md Rewritten as a contributor-friendly guide instead of a dry plan doc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: explain why dev-setup is needed in CONTRIBUTING.md quick start Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add browser interaction guidance to CLAUDE.md Prevents Claude from using mcp__claude-in-chrome__* tools instead of /browse. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add shared config module for project-local browse state Centralizes path resolution (git root detection, state dir, log paths) into config.ts. Both cli.ts and server.ts import from it, eliminating duplicated PORT_OFFSET/BROWSE_PORT/STATE_FILE logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: rewrite port selection to use random ports Replace CONDUCTOR_PORT magic offset and 9400-9409 scan with random port 10000-60000. Atomic state file writes, log paths from config module, binaryVersion field for auto-restart on update. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: move browse state from /tmp to project-local .gstack/ CLI now uses config module for state paths, passes BROWSE_STATE_FILE to spawned server. Adds version mismatch auto-restart, legacy /tmp cleanup with PID verification, and removes stale global install fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update crash log path reference to .gstack/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add config tests and update CLI lifecycle test 14 new tests for config resolution, ensureStateDir, readVersionHash, resolveServerScript, and version mismatch detection. Remove obsolete CONDUCTOR_PORT/BROWSE_PORT filtering from commands.test.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update BROWSER.md and TODO.md for project-local state Replace /tmp paths with .gstack/, remove CONDUCTOR_PORT docs, document random port selection and per-project isolation. Add server bundling TODO. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update README, CHANGELOG, and CONTRIBUTING for v0.3.2 - README: replace Conductor-aware language with project-local isolation, add Greptile setup note - CHANGELOG: comprehensive v0.3.2 entry with all state management changes - CONTRIBUTING: add instructions for testing branches in other repos Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add diff-aware mode to /qa — auto-tests affected pages from branch diff When on a feature branch, /qa now reads git diff main, identifies affected pages/routes from changed files, and tests them automatically. No URL required. The most natural flow: write code, /ship, /qa. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update CHANGELOG for complete v0.3.2 coverage Add missing entries: diff-aware QA mode, Greptile integration, local dev mode, crash log path fix, README/SKILL.md updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
/**
|
|
* gstack CLI — thin wrapper that talks to the persistent server
|
|
*
|
|
* Flow:
|
|
* 1. Read .gstack/browse.json for port + token
|
|
* 2. If missing or stale PID → start server in background
|
|
* 3. Health check + version mismatch detection
|
|
* 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';
|
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
|
|
|
const config = resolveConfig();
|
|
const MAX_START_WAIT = 8000; // 8 seconds to start
|
|
|
|
export function resolveServerScript(
|
|
env: Record<string, string | undefined> = process.env,
|
|
metaDir: string = import.meta.dir,
|
|
execPath: string = process.execPath
|
|
): string {
|
|
if (env.BROWSE_SERVER_SCRIPT) {
|
|
return env.BROWSE_SERVER_SCRIPT;
|
|
}
|
|
|
|
// Dev mode: cli.ts runs directly from browse/src
|
|
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
|
|
const direct = path.resolve(metaDir, 'server.ts');
|
|
if (fs.existsSync(direct)) {
|
|
return direct;
|
|
}
|
|
}
|
|
|
|
// Compiled binary: derive the source tree from browse/dist/browse
|
|
if (execPath) {
|
|
const adjacent = path.resolve(path.dirname(execPath), '..', 'src', 'server.ts');
|
|
if (fs.existsSync(adjacent)) {
|
|
return adjacent;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
'Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env or run from the browse source tree.'
|
|
);
|
|
}
|
|
|
|
const SERVER_SCRIPT = resolveServerScript();
|
|
|
|
interface ServerState {
|
|
pid: number;
|
|
port: number;
|
|
token: string;
|
|
startedAt: string;
|
|
serverPath: string;
|
|
binaryVersion?: string;
|
|
}
|
|
|
|
// ─── State File ────────────────────────────────────────────────
|
|
function readState(): ServerState | null {
|
|
try {
|
|
const data = fs.readFileSync(config.stateFile, 'utf-8');
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Process Management ─────────────────────────────────────────
|
|
async function killServer(pid: number): Promise<void> {
|
|
if (!isProcessAlive(pid)) return;
|
|
|
|
try { process.kill(pid, 'SIGTERM'); } catch { return; }
|
|
|
|
// Wait up to 2s for graceful shutdown
|
|
const deadline = Date.now() + 2000;
|
|
while (Date.now() < deadline && isProcessAlive(pid)) {
|
|
await Bun.sleep(100);
|
|
}
|
|
|
|
// Force kill if still alive
|
|
if (isProcessAlive(pid)) {
|
|
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up legacy /tmp/browse-server*.json files from before project-local state.
|
|
* Verifies PID ownership before sending signals.
|
|
*/
|
|
function cleanupLegacyState(): void {
|
|
try {
|
|
const files = fs.readdirSync('/tmp').filter(f => f.startsWith('browse-server') && f.endsWith('.json'));
|
|
for (const file of files) {
|
|
const fullPath = `/tmp/${file}`;
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
|
|
if (data.pid && isProcessAlive(data.pid)) {
|
|
// Verify this is actually a browse server before killing
|
|
const check = Bun.spawnSync(['ps', '-p', String(data.pid), '-o', 'command='], {
|
|
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
|
|
});
|
|
const cmd = check.stdout.toString().trim();
|
|
if (cmd.includes('bun') || cmd.includes('server.ts')) {
|
|
try { process.kill(data.pid, 'SIGTERM'); } catch {}
|
|
}
|
|
}
|
|
fs.unlinkSync(fullPath);
|
|
} catch {
|
|
// Best effort — skip files we can't parse or clean up
|
|
}
|
|
}
|
|
// Clean up legacy log files too
|
|
const logFiles = fs.readdirSync('/tmp').filter(f =>
|
|
f.startsWith('browse-console') || f.startsWith('browse-network') || f.startsWith('browse-dialog')
|
|
);
|
|
for (const file of logFiles) {
|
|
try { fs.unlinkSync(`/tmp/${file}`); } catch {}
|
|
}
|
|
} catch {
|
|
// /tmp read failed — skip legacy cleanup
|
|
}
|
|
}
|
|
|
|
// ─── Server Lifecycle ──────────────────────────────────────────
|
|
async function startServer(): Promise<ServerState> {
|
|
ensureStateDir(config);
|
|
|
|
// Clean up stale state file
|
|
try { fs.unlinkSync(config.stateFile); } catch {}
|
|
|
|
// Start server as detached background process
|
|
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
|
});
|
|
|
|
// 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)) {
|
|
// Check for binary version mismatch (auto-restart on update)
|
|
const currentVersion = readVersionHash();
|
|
if (currentVersion && state.binaryVersion && currentVersion !== state.binaryVersion) {
|
|
console.error('[browse] Binary updated, restarting server...');
|
|
await killServer(state.pid);
|
|
return startServer();
|
|
}
|
|
|
|
// 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[], retries = 0): 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')) {
|
|
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
|
|
console.error('[browse] Server connection lost. Restarting...');
|
|
const newState = await startServer();
|
|
return sendCommand(newState, command, args, retries + 1);
|
|
}
|
|
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|--networkidle|--load> | viewport <WxH>
|
|
upload <sel> <file1> [file2...]
|
|
cookie-import <json-file>
|
|
cookie-import-browser [browser] [--domain <d>]
|
|
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
console [--clear|--errors] | network [--clear] | dialog [--clear]
|
|
cookies | storage [set <k> <v>] | perf
|
|
is <prop> <sel> (visible|hidden|enabled|disabled|checked|editable|focused)
|
|
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
|
Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]
|
|
-D/--diff: diff against previous snapshot
|
|
-a/--annotate: annotated screenshot with ref labels
|
|
-C/--cursor-interactive: find non-ARIA clickable elements
|
|
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
|
|
Dialogs: dialog-accept [text] | dialog-dismiss
|
|
|
|
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|
click @e3 | fill @e4 "value" | hover @e1
|
|
@c refs from -C: click @c1`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// One-time cleanup of legacy /tmp state files
|
|
cleanupLegacyState();
|
|
|
|
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);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main().catch((err) => {
|
|
console.error(`[browse] ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
}
|