~cytrogen/gstack

a29743b0566406ceb536e61aec3de24e3a8b5acc — morluto a month ago 3d90106
fix: harden browse install and lifecycle checks (#4)

Thanks @morluto
3 files changed, 115 insertions(+), 28 deletions(-)

M browse/src/cli.ts
M browse/test/commands.test.ts
M setup
M browse/src/cli.ts => browse/src/cli.ts +37 -9
@@ 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);
  });
}

M browse/test/commands.test.ts => browse/test/commands.test.ts +53 -15
@@ 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);
});


M setup => setup +25 -4
@@ 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