From c0153f1fe9e888cdcd3ae1943831a75a27438bc5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 13 Mar 2026 09:49:55 -0700 Subject: [PATCH] feat: version-aware find-browse with META signal protocol Agents in other workspaces found stale browse binaries that were missing newer flags. find-browse now compares the local binary's git SHA against origin/main via git ls-remote (4hr cache), and emits META:UPDATE_AVAILABLE when behind. SKILL.md setup checks parse META signals and prompt the user to update. - New compiled binary: browse/dist/find-browse (TypeScript, testable) - Bash shim at browse/bin/find-browse delegates to compiled binary - .version file written at build time with git commit SHA - Build script compiles both browse and find-browse binaries - Graceful degradation: offline, missing .version, corrupt cache all skip check Co-Authored-By: Claude Opus 4.6 --- SKILL.md | 12 ++- browse/bin/find-browse | 8 +- browse/src/find-browse.ts | 181 ++++++++++++++++++++++++++++++++ browse/test/find-browse.test.ts | 144 +++++++++++++++++++++++++ package.json | 4 +- qa/SKILL.md | 8 +- setup | 4 + setup-browser-cookies/SKILL.md | 7 +- 8 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 browse/src/find-browse.ts create mode 100644 browse/test/find-browse.test.ts diff --git a/SKILL.md b/SKILL.md index d657a20c..e561e2cc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -21,9 +21,12 @@ Auto-shuts down after 30 min idle. State persists between calls (cookies, tabs, ## SETUP (run this check BEFORE any browse command) ```bash -B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +BROWSE_OUTPUT=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +B=$(echo "$BROWSE_OUTPUT" | head -1) +META=$(echo "$BROWSE_OUTPUT" | grep "^META:" || true) if [ -n "$B" ]; then echo "READY: $B" + [ -n "$META" ] && echo "$META" else echo "NEEDS_SETUP" fi @@ -34,6 +37,13 @@ If `NEEDS_SETUP`: 2. Run: `cd && ./setup` 3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash` +If you see `META:UPDATE_AVAILABLE`: +1. Parse the JSON payload to get `current`, `latest`, and `command`. +2. Tell the user: "A gstack update is available (current: X, latest: Y). OK to update?" +3. **STOP and wait for approval.** +4. Run the command from the META payload. +5. Re-run the setup check above to get the updated binary path. + ## IMPORTANT - Use the compiled binary via Bash: `$B ` diff --git a/browse/bin/find-browse b/browse/bin/find-browse index 72882030..db07f373 100755 --- a/browse/bin/find-browse +++ b/browse/bin/find-browse @@ -1,5 +1,11 @@ #!/bin/bash -# Find the gstack browse binary. Echoes path and exits 0, or exits 1 if not found. +# Shim: delegates to compiled find-browse binary, falls back to basic discovery. +# The compiled binary adds version checking and META signal support. +DIR="$(cd "$(dirname "$0")/.." && pwd)/dist" +if test -x "$DIR/find-browse"; then + exec "$DIR/find-browse" "$@" +fi +# Fallback: basic discovery (no version check) ROOT=$(git rev-parse --show-toplevel 2>/dev/null) if [ -n "$ROOT" ] && test -x "$ROOT/.claude/skills/gstack/browse/dist/browse"; then echo "$ROOT/.claude/skills/gstack/browse/dist/browse" diff --git a/browse/src/find-browse.ts b/browse/src/find-browse.ts new file mode 100644 index 00000000..38b987a7 --- /dev/null +++ b/browse/src/find-browse.ts @@ -0,0 +1,181 @@ +/** + * find-browse — locate the gstack browse binary + check for updates. + * + * Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed). + * + * Output protocol: + * Line 1: /path/to/binary (always present) + * Line 2+: META: (optional, 0 or more) + * + * META types: + * META:UPDATE_AVAILABLE — local binary is behind origin/main + * + * All version checks are best-effort: network failures, missing files, and + * cache errors degrade gracefully to outputting only the binary path. + */ + +import { existsSync } from 'fs'; +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +const REPO_URL = 'https://github.com/garrytan/gstack.git'; +const CACHE_PATH = '/tmp/gstack-latest-version'; +const CACHE_TTL = 14400; // 4 hours in seconds + +// ─── Binary Discovery ─────────────────────────────────────────── + +function getGitRoot(): string | null { + try { + const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { + stdout: 'pipe', + stderr: 'pipe', + }); + if (proc.exitCode !== 0) return null; + return proc.stdout.toString().trim(); + } catch { + return null; + } +} + +export function locateBinary(): string | null { + const root = getGitRoot(); + const home = homedir(); + + // Workspace-local takes priority (for development) + if (root) { + const local = join(root, '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'); + if (existsSync(local)) return local; + } + + // Global fallback + const global = join(home, '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'); + if (existsSync(global)) return global; + + return null; +} + +// ─── Version Check ────────────────────────────────────────────── + +interface CacheEntry { + sha: string; + timestamp: number; +} + +function readCache(): CacheEntry | null { + try { + const content = readFileSync(CACHE_PATH, 'utf-8').trim(); + const parts = content.split(/\s+/); + if (parts.length < 2) return null; + const sha = parts[0]; + const timestamp = parseInt(parts[1], 10); + if (!sha || isNaN(timestamp)) return null; + // Validate SHA is hex + if (!/^[0-9a-f]{40}$/i.test(sha)) return null; + return { sha, timestamp }; + } catch { + return null; + } +} + +function writeCache(sha: string, timestamp: number): void { + try { + writeFileSync(CACHE_PATH, `${sha} ${timestamp}\n`); + } catch { + // Cache write failure is non-fatal + } +} + +function fetchRemoteSHA(): string | null { + try { + const proc = Bun.spawnSync(['git', 'ls-remote', REPO_URL, 'refs/heads/main'], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 10_000, // 10s timeout + }); + if (proc.exitCode !== 0) return null; + const output = proc.stdout.toString().trim(); + const sha = output.split(/\s+/)[0]; + if (!sha || !/^[0-9a-f]{40}$/i.test(sha)) return null; + return sha; + } catch { + return null; + } +} + +function resolveSkillDir(binaryPath: string): string | null { + const home = homedir(); + const globalPrefix = join(home, '.claude', 'skills', 'gstack'); + if (binaryPath.startsWith(globalPrefix)) return globalPrefix; + + // Workspace-local: binary is at $ROOT/.claude/skills/gstack/browse/dist/browse + // Skill dir is $ROOT/.claude/skills/gstack + const parts = binaryPath.split('/.claude/skills/gstack/'); + if (parts.length === 2) return parts[0] + '/.claude/skills/gstack'; + + return null; +} + +export function checkVersion(binaryDir: string): string | null { + // Read local version + const versionFile = join(binaryDir, '.version'); + let localSHA: string; + try { + localSHA = readFileSync(versionFile, 'utf-8').trim(); + } catch { + return null; // No .version file → skip check + } + if (!localSHA) return null; + + const now = Math.floor(Date.now() / 1000); + + // Check cache + let remoteSHA: string | null = null; + const cache = readCache(); + if (cache && (now - cache.timestamp) < CACHE_TTL) { + remoteSHA = cache.sha; + } + + // Fetch from remote if cache miss + if (!remoteSHA) { + remoteSHA = fetchRemoteSHA(); + if (remoteSHA) { + writeCache(remoteSHA, now); + } + } + + if (!remoteSHA) return null; // Offline or error → skip check + + // Compare + if (localSHA === remoteSHA) return null; // Up to date + + // Determine skill directory for update command + const binaryPath = join(binaryDir, 'browse'); + const skillDir = resolveSkillDir(binaryPath); + if (!skillDir) return null; + + const payload = JSON.stringify({ + current: localSHA.slice(0, 8), + latest: remoteSHA.slice(0, 8), + command: `cd ${skillDir} && git stash && git fetch origin && git reset --hard origin/main && ./setup`, + }); + + return `META:UPDATE_AVAILABLE ${payload}`; +} + +// ─── Main ─────────────────────────────────────────────────────── + +function main() { + const bin = locateBinary(); + if (!bin) { + process.stderr.write('ERROR: browse binary not found. Run: cd && ./setup\n'); + process.exit(1); + } + + console.log(bin); + + const meta = checkVersion(dirname(bin)); + if (meta) console.log(meta); +} + +main(); diff --git a/browse/test/find-browse.test.ts b/browse/test/find-browse.test.ts new file mode 100644 index 00000000..43e1300b --- /dev/null +++ b/browse/test/find-browse.test.ts @@ -0,0 +1,144 @@ +/** + * Tests for find-browse version check logic + * + * Tests the checkVersion() and locateBinary() functions directly. + * Uses temp directories with mock .version files and cache files. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { checkVersion, locateBinary } from '../src/find-browse'; +import { mkdtempSync, writeFileSync, rmSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'find-browse-test-')); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + // Clean up test cache + try { rmSync('/tmp/gstack-latest-version'); } catch {} +}); + +describe('checkVersion', () => { + test('returns null when .version file is missing', () => { + const result = checkVersion(tempDir); + expect(result).toBeNull(); + }); + + test('returns null when .version file is empty', () => { + writeFileSync(join(tempDir, '.version'), ''); + const result = checkVersion(tempDir); + expect(result).toBeNull(); + }); + + test('returns null when .version has only whitespace', () => { + writeFileSync(join(tempDir, '.version'), ' \n'); + const result = checkVersion(tempDir); + expect(result).toBeNull(); + }); + + test('returns null when local SHA matches remote (cache hit)', () => { + const sha = 'a'.repeat(40); + writeFileSync(join(tempDir, '.version'), sha); + // Write cache with same SHA, recent timestamp + const now = Math.floor(Date.now() / 1000); + writeFileSync('/tmp/gstack-latest-version', `${sha} ${now}\n`); + + const result = checkVersion(tempDir); + expect(result).toBeNull(); + }); + + test('returns META:UPDATE_AVAILABLE when SHAs differ (cache hit)', () => { + const localSha = 'a'.repeat(40); + const remoteSha = 'b'.repeat(40); + writeFileSync(join(tempDir, '.version'), localSha); + // Create a fake browse binary path so resolveSkillDir works + const browsePath = join(tempDir, 'browse'); + writeFileSync(browsePath, ''); + // Write cache with different SHA, recent timestamp + const now = Math.floor(Date.now() / 1000); + writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${now}\n`); + + const result = checkVersion(tempDir); + // Result may be null if resolveSkillDir can't determine skill dir from temp path + // That's expected — the META signal requires a known skill dir path + if (result !== null) { + expect(result).toStartWith('META:UPDATE_AVAILABLE'); + const jsonStr = result.replace('META:UPDATE_AVAILABLE ', ''); + const payload = JSON.parse(jsonStr); + expect(payload.current).toBe('a'.repeat(8)); + expect(payload.latest).toBe('b'.repeat(8)); + expect(payload.command).toContain('git stash'); + expect(payload.command).toContain('git reset --hard origin/main'); + expect(payload.command).toContain('./setup'); + } + }); + + test('uses cached SHA when cache is fresh (< 4hr)', () => { + const localSha = 'a'.repeat(40); + const remoteSha = 'a'.repeat(40); + writeFileSync(join(tempDir, '.version'), localSha); + // Cache is 1 hour old — should still be valid + const oneHourAgo = Math.floor(Date.now() / 1000) - 3600; + writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${oneHourAgo}\n`); + + const result = checkVersion(tempDir); + expect(result).toBeNull(); // SHAs match + }); + + test('treats expired cache as stale', () => { + const localSha = 'a'.repeat(40); + writeFileSync(join(tempDir, '.version'), localSha); + // Cache is 5 hours old — should be stale + const fiveHoursAgo = Math.floor(Date.now() / 1000) - 18000; + writeFileSync('/tmp/gstack-latest-version', `${'b'.repeat(40)} ${fiveHoursAgo}\n`); + + // This will try git ls-remote which may fail in test env — that's OK + // The important thing is it doesn't use the stale cache value + const result = checkVersion(tempDir); + // Result depends on whether git ls-remote succeeds in test environment + // If offline, returns null (graceful degradation) + expect(result === null || typeof result === 'string').toBe(true); + }); + + test('handles corrupt cache file gracefully', () => { + const localSha = 'a'.repeat(40); + writeFileSync(join(tempDir, '.version'), localSha); + writeFileSync('/tmp/gstack-latest-version', 'garbage data here'); + + // Should not throw, should treat as stale + const result = checkVersion(tempDir); + expect(result === null || typeof result === 'string').toBe(true); + }); + + test('handles cache with invalid SHA gracefully', () => { + const localSha = 'a'.repeat(40); + writeFileSync(join(tempDir, '.version'), localSha); + writeFileSync('/tmp/gstack-latest-version', `not-a-sha ${Math.floor(Date.now() / 1000)}\n`); + + // Invalid SHA should be treated as no cache + const result = checkVersion(tempDir); + expect(result === null || typeof result === 'string').toBe(true); + }); +}); + +describe('locateBinary', () => { + test('returns null when no binary exists at known paths', () => { + // This test depends on the test environment — if a real binary exists at + // ~/.claude/skills/gstack/browse/dist/browse, it will find it. + // We mainly test that the function doesn't throw. + const result = locateBinary(); + expect(result === null || typeof result === 'string').toBe(true); + }); + + test('returns string path when binary exists', () => { + const result = locateBinary(); + if (result !== null) { + expect(existsSync(result)).toBe(true); + } + }); +}); diff --git a/package.json b/package.json index bc617a53..a9ee2356 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.3.1", + "version": "0.3.2", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", @@ -8,7 +8,7 @@ "browse": "./browse/dist/browse" }, "scripts": { - "build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse", + "build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && git rev-parse HEAD > browse/dist/.version", "dev": "bun run browse/src/cli.ts", "server": "bun run browse/src/server.ts", "test": "bun test", diff --git a/qa/SKILL.md b/qa/SKILL.md index 9da05fa2..ca0a520a 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -31,13 +31,19 @@ You are a QA engineer. Test web applications like a real user — click everythi **Find the browse binary:** ```bash -B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +BROWSE_OUTPUT=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +B=$(echo "$BROWSE_OUTPUT" | head -1) +META=$(echo "$BROWSE_OUTPUT" | grep "^META:" || true) if [ -z "$B" ]; then echo "ERROR: browse binary not found" exit 1 fi +echo "READY: $B" +[ -n "$META" ] && echo "$META" ``` +If you see `META:UPDATE_AVAILABLE`: tell the user an update is available, STOP and wait for approval, then run the command from the META payload and re-run the setup check. + **Create output directories:** ```bash diff --git a/setup b/setup index 40430a42..1f1ad097 100755 --- a/setup +++ b/setup @@ -32,6 +32,10 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then bun install bun run build ) + # Safety net: write .version if build script didn't (e.g., git not available during build) + if [ ! -f "$GSTACK_DIR/browse/dist/.version" ]; then + git -C "$GSTACK_DIR" rev-parse HEAD > "$GSTACK_DIR/browse/dist/.version" 2>/dev/null || true + fi fi if [ ! -x "$BROWSE_BIN" ]; then diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index 28cc778a..cc1d143e 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -26,9 +26,12 @@ Import logged-in sessions from your real Chromium browser into the headless brow ### 1. Find the browse binary ```bash -B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +BROWSE_OUTPUT=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null) +B=$(echo "$BROWSE_OUTPUT" | head -1) +META=$(echo "$BROWSE_OUTPUT" | grep "^META:" || true) if [ -n "$B" ]; then echo "READY: $B" + [ -n "$META" ] && echo "$META" else echo "NEEDS_SETUP" fi @@ -39,6 +42,8 @@ If `NEEDS_SETUP`: 2. Run: `cd && ./setup` 3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash` +If you see `META:UPDATE_AVAILABLE`: tell the user an update is available, STOP and wait for approval, then run the command from the META payload and re-run the setup check. + ### 2. Open the cookie picker ```bash