@@ 18,13 18,39 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT
: 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
+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 {
pid: number;
port: number;
@@ 215,7 241,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
await sendCommand(state, command, commandArgs);
}
-main().catch((err) => {
- console.error(`[browse] ${err.message}`);
- process.exit(1);
-});
+if (import.meta.main) {
+ main().catch((err) => {
+ console.error(`[browse] ${err.message}`);
+ process.exit(1);
+ });
+}
@@ 8,6 8,7 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
+import { resolveServerScript } from '../src/cli';
import { handleReadCommand } from '../src/read-commands';
import { handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
@@ 420,33 421,70 @@ describe('Status', () => {
});
});
-// ─── CLI retry guard ────────────────────────────────────────────
+// ─── CLI server script resolution ───────────────────────────────
-describe('CLI retry guard', () => {
- test('sendCommand aborts after repeated connection failures', async () => {
- // Write a fake state file pointing to a port that refuses connections
- const stateFile = '/tmp/browse-server.json';
- const origState = fs.existsSync(stateFile) ? fs.readFileSync(stateFile, 'utf-8') : null;
+describe('CLI server script resolution', () => {
+ test('prefers adjacent browse/src/server.ts for compiled project installs', () => {
+ const root = fs.mkdtempSync('/tmp/gstack-cli-');
+ const execPath = path.join(root, '.claude/skills/gstack/browse/dist/browse');
+ 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 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'], {
timeout: 15000,
- env: { ...process.env },
+ env: {
+ ...process.env,
+ BROWSE_STATE_FILE: stateFile,
+ BROWSE_PORT_START: '9520',
+ },
});
+ let stdout = '';
let stderr = '';
+ proc.stdout.on('data', (d) => stdout += 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
- if (origState) fs.writeFileSync(stateFile, origState);
- else if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
+ let restartedPid: number | null = null;
+ if (fs.existsSync(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).not.toBe(0);
+ expect(result.code).toBe(0);
+ expect(result.stdout).toContain('Status: healthy');
+ expect(result.stderr).toContain('Starting server');
}, 20000);
});
@@ 4,11 4,32 @@ set -e
GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILLS_DIR="$(dirname "$GSTACK_DIR")"
+BROWSE_BIN="$GSTACK_DIR/browse/dist/browse"
# 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..."
- 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
# 2. Only create skill symlinks if we're inside a .claude/skills directory
@@ 30,12 51,12 @@ if [ "$SKILLS_BASENAME" = "skills" ]; then
done
echo "gstack ready."
- echo " browse: $GSTACK_DIR/browse/dist/browse"
+ echo " browse: $BROWSE_BIN"
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
fi
else
echo "gstack ready."
- echo " browse: $GSTACK_DIR/browse/dist/browse"
+ echo " browse: $BROWSE_BIN"
echo " (skipped skill symlinks — not inside .claude/skills/)"
fi