diff --git a/browse/src/cli.ts b/browse/src/cli.ts index dae76fb9..f8b79023 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -2,22 +2,18 @@ * gstack CLI — thin wrapper that talks to the persistent server * * Flow: - * 1. Read /tmp/browse-server.json for port + token + * 1. Read .gstack/browse.json for port + token * 2. If missing or stale PID → start server in background - * 3. Health check + * 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 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`; +const config = resolveConfig(); const MAX_START_WAIT = 8000; // 8 seconds to start export function resolveServerScript( @@ -45,8 +41,9 @@ export function resolveServerScript( } } - // Legacy fallback for user-level installs - return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts'); + throw new Error( + 'Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env or run from the browse source tree.' + ); } const SERVER_SCRIPT = resolveServerScript(); @@ -57,12 +54,13 @@ interface ServerState { token: string; startedAt: string; serverPath: string; + binaryVersion?: string; } // ─── State File ──────────────────────────────────────────────── function readState(): ServerState | null { try { - const data = fs.readFileSync(STATE_FILE, 'utf-8'); + const data = fs.readFileSync(config.stateFile, 'utf-8'); return JSON.parse(data); } catch { return null; @@ -78,15 +76,73 @@ function isProcessAlive(pid: number): boolean { } } +// ─── Process Management ───────────────────────────────────────── +async function killServer(pid: number): Promise { + 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 { + ensureStateDir(config); + // Clean up stale state file - try { fs.unlinkSync(STATE_FILE); } catch {} + 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 }, + env: { ...process.env, BROWSE_STATE_FILE: config.stateFile }, }); // Don't hold the CLI open @@ -120,6 +176,14 @@ async function ensureServer(): Promise { 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`, { @@ -237,6 +301,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: process.exit(0); } + // One-time cleanup of legacy /tmp state files + cleanupLegacyState(); + const command = args[0]; const commandArgs = args.slice(1);