diff --git a/browse/src/config.ts b/browse/src/config.ts new file mode 100644 index 00000000..76892918 --- /dev/null +++ b/browse/src/config.ts @@ -0,0 +1,105 @@ +/** + * Shared config for browse CLI + server. + * + * Resolution: + * 1. BROWSE_STATE_FILE env → derive stateDir from parent + * 2. git rev-parse --show-toplevel → projectDir/.gstack/ + * 3. process.cwd() fallback (non-git environments) + * + * The CLI computes the config and passes BROWSE_STATE_FILE to the + * spawned server. The server derives all paths from that env var. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface BrowseConfig { + projectDir: string; + stateDir: string; + stateFile: string; + consoleLog: string; + networkLog: string; + dialogLog: string; +} + +/** + * Detect the git repository root, or null if not in a repo / git unavailable. + */ +export function getGitRoot(): string | null { + try { + const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 2_000, // Don't hang if .git is broken + }); + if (proc.exitCode !== 0) return null; + return proc.stdout.toString().trim() || null; + } catch { + return null; + } +} + +/** + * Resolve all browse config paths. + * + * If BROWSE_STATE_FILE is set (e.g. by CLI when spawning server, or by + * tests for isolation), all paths are derived from it. Otherwise, the + * project root is detected via git or cwd. + */ +export function resolveConfig( + env: Record = process.env, +): BrowseConfig { + let stateFile: string; + let stateDir: string; + let projectDir: string; + + if (env.BROWSE_STATE_FILE) { + stateFile = env.BROWSE_STATE_FILE; + stateDir = path.dirname(stateFile); + projectDir = path.dirname(stateDir); // parent of .gstack/ + } else { + projectDir = getGitRoot() || process.cwd(); + stateDir = path.join(projectDir, '.gstack'); + stateFile = path.join(stateDir, 'browse.json'); + } + + return { + projectDir, + stateDir, + stateFile, + consoleLog: path.join(stateDir, 'browse-console.log'), + networkLog: path.join(stateDir, 'browse-network.log'), + dialogLog: path.join(stateDir, 'browse-dialog.log'), + }; +} + +/** + * Create the .gstack/ state directory if it doesn't exist. + * Throws with a clear message on permission errors. + */ +export function ensureStateDir(config: BrowseConfig): void { + try { + fs.mkdirSync(config.stateDir, { recursive: true }); + } catch (err: any) { + if (err.code === 'EACCES') { + throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`); + } + if (err.code === 'ENOTDIR') { + throw new Error(`Cannot create state directory ${config.stateDir}: a file exists at that path`); + } + throw err; + } +} + +/** + * Read the binary version (git SHA) from browse/dist/.version. + * Returns null if the file doesn't exist or can't be read. + */ +export function readVersionHash(execPath: string = process.execPath): string | null { + try { + const versionFile = path.resolve(path.dirname(execPath), '.version'); + return fs.readFileSync(versionFile, 'utf-8').trim() || null; + } catch { + return null; + } +}