mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-01 15:51:41 +02:00
fix: harden browse install and lifecycle checks
This commit is contained in:
+37
-9
@@ -18,13 +18,39 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT
|
|||||||
: parseInt(process.env.BROWSE_PORT || '0', 10);
|
: parseInt(process.env.BROWSE_PORT || '0', 10);
|
||||||
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
||||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
|
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
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback for user-level installs
|
||||||
|
return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_SCRIPT = resolveServerScript();
|
||||||
|
|
||||||
interface ServerState {
|
interface ServerState {
|
||||||
pid: number;
|
pid: number;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -215,7 +241,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
await sendCommand(state, command, commandArgs);
|
await sendCommand(state, command, commandArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
if (import.meta.main) {
|
||||||
console.error(`[browse] ${err.message}`);
|
main().catch((err) => {
|
||||||
process.exit(1);
|
console.error(`[browse] ${err.message}`);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
import { startTestServer } from './test-server';
|
import { startTestServer } from './test-server';
|
||||||
import { BrowserManager } from '../src/browser-manager';
|
import { BrowserManager } from '../src/browser-manager';
|
||||||
|
import { resolveServerScript } from '../src/cli';
|
||||||
import { handleReadCommand } from '../src/read-commands';
|
import { handleReadCommand } from '../src/read-commands';
|
||||||
import { handleWriteCommand } from '../src/write-commands';
|
import { handleWriteCommand } from '../src/write-commands';
|
||||||
import { handleMetaCommand } from '../src/meta-commands';
|
import { handleMetaCommand } from '../src/meta-commands';
|
||||||
@@ -420,33 +421,70 @@ describe('Status', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── CLI retry guard ────────────────────────────────────────────
|
// ─── CLI server script resolution ───────────────────────────────
|
||||||
|
|
||||||
describe('CLI retry guard', () => {
|
describe('CLI server script resolution', () => {
|
||||||
test('sendCommand aborts after repeated connection failures', async () => {
|
test('prefers adjacent browse/src/server.ts for compiled project installs', () => {
|
||||||
// Write a fake state file pointing to a port that refuses connections
|
const root = fs.mkdtempSync('/tmp/gstack-cli-');
|
||||||
const stateFile = '/tmp/browse-server.json';
|
const execPath = path.join(root, '.claude/skills/gstack/browse/dist/browse');
|
||||||
const origState = fs.existsSync(stateFile) ? fs.readFileSync(stateFile, 'utf-8') : null;
|
const serverPath = path.join(root, '.claude/skills/gstack/browse/src/server.ts');
|
||||||
|
|
||||||
fs.writeFileSync(stateFile, JSON.stringify({ port: 1, token: 'fake', pid: 999999 }));
|
fs.mkdirSync(path.dirname(execPath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(serverPath), { recursive: true });
|
||||||
|
fs.writeFileSync(serverPath, '// test server\n');
|
||||||
|
|
||||||
|
const resolved = resolveServerScript(
|
||||||
|
{ HOME: path.join(root, 'empty-home') },
|
||||||
|
'$bunfs/root',
|
||||||
|
execPath
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toBe(serverPath);
|
||||||
|
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CLI lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CLI lifecycle', () => {
|
||||||
|
test('dead state file triggers a clean restart', async () => {
|
||||||
|
const stateFile = `/tmp/browse-test-state-${Date.now()}.json`;
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify({
|
||||||
|
port: 1,
|
||||||
|
token: 'fake',
|
||||||
|
pid: 999999,
|
||||||
|
}));
|
||||||
|
|
||||||
const cliPath = path.resolve(__dirname, '../src/cli.ts');
|
const cliPath = path.resolve(__dirname, '../src/cli.ts');
|
||||||
const result = await new Promise<{ code: number; stderr: string }>((resolve) => {
|
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
|
||||||
const proc = spawn('bun', ['run', cliPath, 'status'], {
|
const proc = spawn('bun', ['run', cliPath, 'status'], {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
env: { ...process.env },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BROWSE_STATE_FILE: stateFile,
|
||||||
|
BROWSE_PORT_START: '9520',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
proc.stdout.on('data', (d) => stdout += d.toString());
|
||||||
proc.stderr.on('data', (d) => stderr += d.toString());
|
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||||
proc.on('close', (code) => resolve({ code: code ?? 1, stderr }));
|
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore original state file
|
let restartedPid: number | null = null;
|
||||||
if (origState) fs.writeFileSync(stateFile, origState);
|
if (fs.existsSync(stateFile)) {
|
||||||
else if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
|
restartedPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
|
||||||
|
fs.unlinkSync(stateFile);
|
||||||
|
}
|
||||||
|
if (restartedPid) {
|
||||||
|
try { process.kill(restartedPid, 'SIGTERM'); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Should fail, not loop forever
|
expect(result.code).toBe(0);
|
||||||
expect(result.code).not.toBe(0);
|
expect(result.stdout).toContain('Status: healthy');
|
||||||
|
expect(result.stderr).toContain('Starting server');
|
||||||
}, 20000);
|
}, 20000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,32 @@ set -e
|
|||||||
|
|
||||||
GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
SKILLS_DIR="$(dirname "$GSTACK_DIR")"
|
SKILLS_DIR="$(dirname "$GSTACK_DIR")"
|
||||||
|
BROWSE_BIN="$GSTACK_DIR/browse/dist/browse"
|
||||||
|
|
||||||
# 1. Build browse binary if needed
|
# 1. Build browse binary if needed
|
||||||
if [ ! -x "$GSTACK_DIR/browse/dist/browse" ]; then
|
NEEDS_BUILD=0
|
||||||
|
if [ ! -x "$BROWSE_BIN" ]; then
|
||||||
|
NEEDS_BUILD=1
|
||||||
|
elif [ -n "$(find "$GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then
|
||||||
|
NEEDS_BUILD=1
|
||||||
|
elif [ "$GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then
|
||||||
|
NEEDS_BUILD=1
|
||||||
|
elif [ -f "$GSTACK_DIR/bun.lock" ] && [ "$GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]; then
|
||||||
|
NEEDS_BUILD=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEEDS_BUILD" -eq 1 ]; then
|
||||||
echo "Building browse binary..."
|
echo "Building browse binary..."
|
||||||
cd "$GSTACK_DIR" && bun install && bun run build
|
(
|
||||||
|
cd "$GSTACK_DIR"
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$BROWSE_BIN" ]; then
|
||||||
|
echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Only create skill symlinks if we're inside a .claude/skills directory
|
# 2. Only create skill symlinks if we're inside a .claude/skills directory
|
||||||
@@ -30,12 +51,12 @@ if [ "$SKILLS_BASENAME" = "skills" ]; then
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "gstack ready."
|
echo "gstack ready."
|
||||||
echo " browse: $GSTACK_DIR/browse/dist/browse"
|
echo " browse: $BROWSE_BIN"
|
||||||
if [ ${#linked[@]} -gt 0 ]; then
|
if [ ${#linked[@]} -gt 0 ]; then
|
||||||
echo " linked skills: ${linked[*]}"
|
echo " linked skills: ${linked[*]}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "gstack ready."
|
echo "gstack ready."
|
||||||
echo " browse: $GSTACK_DIR/browse/dist/browse"
|
echo " browse: $BROWSE_BIN"
|
||||||
echo " (skipped skill symlinks — not inside .claude/skills/)"
|
echo " (skipped skill symlinks — not inside .claude/skills/)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user