mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-10 18:07:11 +02:00
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>
This commit is contained in:
+80
-13
@@ -2,22 +2,18 @@
|
|||||||
* gstack CLI — thin wrapper that talks to the persistent server
|
* gstack CLI — thin wrapper that talks to the persistent server
|
||||||
*
|
*
|
||||||
* Flow:
|
* 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
|
* 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
|
* 4. Send command via HTTP POST
|
||||||
* 5. Print response to stdout (or stderr for errors)
|
* 5. Print response to stdout (or stderr for errors)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||||
|
|
||||||
const PORT_OFFSET = 45600;
|
const config = resolveConfig();
|
||||||
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 MAX_START_WAIT = 8000; // 8 seconds to start
|
const MAX_START_WAIT = 8000; // 8 seconds to start
|
||||||
|
|
||||||
export function resolveServerScript(
|
export function resolveServerScript(
|
||||||
@@ -45,8 +41,9 @@ export function resolveServerScript(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback for user-level installs
|
throw new Error(
|
||||||
return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts');
|
'Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env or run from the browse source tree.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVER_SCRIPT = resolveServerScript();
|
const SERVER_SCRIPT = resolveServerScript();
|
||||||
@@ -57,12 +54,13 @@ interface ServerState {
|
|||||||
token: string;
|
token: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
serverPath: string;
|
serverPath: string;
|
||||||
|
binaryVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State File ────────────────────────────────────────────────
|
// ─── State File ────────────────────────────────────────────────
|
||||||
function readState(): ServerState | null {
|
function readState(): ServerState | null {
|
||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(STATE_FILE, 'utf-8');
|
const data = fs.readFileSync(config.stateFile, 'utf-8');
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -78,15 +76,73 @@ function isProcessAlive(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 ──────────────────────────────────────────
|
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||||
async function startServer(): Promise<ServerState> {
|
async function startServer(): Promise<ServerState> {
|
||||||
|
ensureStateDir(config);
|
||||||
|
|
||||||
// Clean up stale state file
|
// Clean up stale state file
|
||||||
try { fs.unlinkSync(STATE_FILE); } catch {}
|
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||||
|
|
||||||
// Start server as detached background process
|
// Start server as detached background process
|
||||||
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: { ...process.env },
|
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't hold the CLI open
|
// Don't hold the CLI open
|
||||||
@@ -120,6 +176,14 @@ async function ensureServer(): Promise<ServerState> {
|
|||||||
const state = readState();
|
const state = readState();
|
||||||
|
|
||||||
if (state && isProcessAlive(state.pid)) {
|
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
|
// Server appears alive — do a health check
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-time cleanup of legacy /tmp state files
|
||||||
|
cleanupLegacyState();
|
||||||
|
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
const commandArgs = args.slice(1);
|
const commandArgs = args.slice(1);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user