Skill Tracer
Skills › gstack › browse › find-browse.ts

find-browse.ts

118 lines · browse/src/find-browse.ts at 3f79d81

1/**
2 * find-browse — locate the gstack browse binary.
3 *
4 * Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed).
5 * Outputs the absolute path to the browse binary on stdout, or exits 1 if not found.
6 */
7
8import { accessSync, constants, readFileSync } from 'fs';
9import { dirname, join } from 'path';
10import { homedir } from 'os';
12// ─── Binary Discovery ───────────────────────────────────────────
14function getGitRoot(): string | null {
15 try {
16 const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
17 windowsHide: true,
18 stdout: 'pipe',
19 stderr: 'pipe',
20 });
21 if (proc.exitCode !== 0) return null;
22 return proc.stdout.toString().trim();
23 } catch {
24 return null;
25 }
28// Probe a path for executability. accessSync(X_OK) checks the executable
29// bit on Linux/macOS and degrades to an existence check on Windows (no
30// true execute bit). Mirrors lib/aside-render.ts executable() /
31// make-pdf/src/pdftotext.ts.
32function isExecutable(p: string): boolean {
33 try {
34 accessSync(p, constants.X_OK);
35 return true;
36 } catch {
37 return false;
38 }
41// Resolve a bare binary path to the actual file on disk. On Windows, `bun
42// build --compile` appends `.exe` to the output filename, so `browse` on
43// disk is actually `browse.exe`. After a bare-path probe, try the Windows
44// extensions. Linux/macOS behavior is unchanged. Mirrors the helper in
45// lib/aside-render.ts (executable) and make-pdf/src/pdftotext.ts.
46function findExecutable(base: string): string | null {
47 if (isExecutable(base)) return base;
48 if (process.platform === 'win32') {
49 for (const ext of ['.exe', '.cmd', '.bat']) {
50 const withExt = base + ext;
51 if (isExecutable(withExt)) return withExt;
52 }
53 }
54 return null;
57export function locateBinary(): string | null {
58 const root = getGitRoot();
59 const home = homedir();
60 const markers = ['.codex', '.agents', '.claude'];
62 // Workspace-local takes priority (for development)
63 if (root) {
64 for (const m of markers) {
65 const local = join(root, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
66 const found = findExecutable(local);
67 if (found) return found;
68 }
70 // Source-checkout fallback (no installed skill layout — the binary
71 // lives directly at <repo>/browse/dist/browse[.exe]). Hit by:
72 // - gstack repo dev workflow before `./setup` runs
73 // - the windows-setup-e2e.yml CI workflow which builds binaries
74 // in place but never installs them under a marker dir
75 // - make-pdf consumers running from a sibling source checkout
76 const sourceCheckout = join(root, 'browse', 'dist', 'browse');
77 const sourceFound = findExecutable(sourceCheckout);
78 if (sourceFound) return sourceFound;
79 }
81 // Global fallback
82 for (const m of markers) {
83 const global = join(home, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
84 const found = findExecutable(global);
85 if (found) return found;
86 }
88 return null;
91// ─── Main ───────────────────────────────────────────────────────
93function main() {
94 // setup's and gstack-doctor's launch probe (#2595): no git, no discovery.
95 if (process.argv[2] === '--version') {
96 let version = 'unknown';
97 try { version = readFileSync(join(dirname(process.execPath), '.version'), 'utf8').trim() || version; } catch {}
98 console.log(version);
99 process.exit(0);
100 }
101 const bin = locateBinary();
102 if (!bin) {
103 process.stderr.write('ERROR: browse binary not found. Run: cd <skill-dir> && ./setup\n');
104 process.exit(1);
105 }
107 console.log(bin);
110// Only run main() when this module is the entry point. Without this guard,
111// any test that imports `locateBinary` from this file would have main() fire
112// at module-load time, calling process.exit(1) when no compiled binary
113// exists — killing the test process before any test runs. Surfaced on the
114// windows-free-tests CI lane where the runner has no compiled browse
115// binary (intentional — that lane only builds server-node.mjs).
116if (import.meta.main) {
117 main();