/** * Centralized gbrain CLI invocation. * * Every `gbrain ...` spawn from `bin/gstack-gbrain-sync.ts` and * `bin/gstack-memory-ingest.ts` MUST go through `spawnGbrain` (or * `execGbrainJson`), and the invariant test * `test/gbrain-exec-invariant.test.ts` enforces this with a static-source * grep. The helper layer guarantees three properties: * * 1. **DATABASE_URL is seeded from gbrain's own config**, not from the * caller's `.env.local`. gbrain auto-loads `.env.local` via dotenv on * startup. When `/sync-gbrain` runs inside a Next.js / Prisma / Rails * project with its own `DATABASE_URL`, gbrain reads that one and not * its own `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Auth fails; * code + memory stages crash; only brain-sync's git push survives. * * 2. **Bun-aware env passing.** Mutating `process.env.DATABASE_URL` does * NOT propagate to children of `child_process.spawnSync`/`spawn` in * Bun — the child gets the original startup env. So we cannot just * set process.env; we must thread an explicit `env:` dict to every * spawn. This is the central bug the helper exists to prevent * regressing on. * * 3. **`GBRAIN_HOME` honored consistently.** Other gstack helpers * (`detectEngineTier`) already honor `GBRAIN_HOME`. `buildGbrainEnv` * reads from `${GBRAIN_HOME:-$HOME/.gbrain}/config.json` so all * gstack-side gbrain calls agree on which config file matters. * * **Escape hatch:** `GSTACK_RESPECT_ENV_DATABASE_URL=1` returns the * caller's env unchanged. Use only when the brain intentionally lives in * the project's local DB (rare). */ import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process"; interface GbrainConfig { database_url?: string; } export interface BuildGbrainEnvOptions { /** * Caller env to extend. Defaults to `process.env`. Tests inject a * synthetic env so the helper can be exercised without polluting the * real process env. */ baseEnv?: NodeJS.ProcessEnv; /** * When true, announce on stderr that we overrode the caller's * DATABASE_URL. Suppressed for the `--quiet` sync flow. */ announce?: boolean; } /** * Build an env dict with DATABASE_URL seeded from * `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Returns the base env * unchanged when: * - `GSTACK_RESPECT_ENV_DATABASE_URL=1` (intentional opt-out), * - the config file is missing or unparseable, * - the config has no `database_url`, * - the caller already set DATABASE_URL to the same value. * * Always returns a fresh object — mutating the returned env never * affects the caller's env. Tests assert on effective values, not * object identity. */ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.ProcessEnv { const baseEnv = opts.baseEnv || process.env; const out: NodeJS.ProcessEnv = { ...baseEnv }; if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out; const homeBase = baseEnv.HOME || homedir(); const gbrainHome = baseEnv.GBRAIN_HOME || join(homeBase, ".gbrain"); const configPath = join(gbrainHome, "config.json"); if (!existsSync(configPath)) return out; let cfg: GbrainConfig = {}; try { cfg = JSON.parse(readFileSync(configPath, "utf-8")) as GbrainConfig; } catch { return out; } if (!cfg.database_url) return out; if (baseEnv.DATABASE_URL === cfg.database_url) return out; const hadCaller = baseEnv.DATABASE_URL !== undefined; out.DATABASE_URL = cfg.database_url; if (opts.announce) { const note = hadCaller ? " (overrode value from caller env / .env.local)" : ""; process.stderr.write(`[gbrain-exec] seeded DATABASE_URL from ${configPath}${note}\n`); } return out; } export interface SpawnGbrainOptions { /** Timeout in milliseconds. Defaults to 30s. */ timeout?: number; /** Working directory for the child process. */ cwd?: string; /** Stdio configuration. Defaults to capturing both stdout and stderr. */ stdio?: "inherit" | "pipe" | "ignore" | Array<"inherit" | "pipe" | "ignore">; /** * Base env to extend before seeding DATABASE_URL. Defaults to * `process.env`. Tests inject a synthetic env so the spawn picks up a * gbrain shim on PATH and a fake `~/.gbrain/config.json`. */ baseEnv?: NodeJS.ProcessEnv; /** Whether to announce DATABASE_URL seeding on stderr. */ announce?: boolean; } /** * Spawn `gbrain ` with the seeded env. Returns the raw * `SpawnSyncReturns` so callers can inspect `status`, `stdout`, * `stderr` exactly as they would with `spawnSync` directly. */ export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): SpawnSyncReturns { return spawnSync("gbrain", args, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, cwd: opts.cwd, stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), }); } /** * Run `gbrain ` and parse stdout as JSON. Returns `null` on * non-zero exit, parse failure, or timeout. Useful for `gbrain sources * list --json` and similar. */ export function execGbrainJson(args: string[], opts: SpawnGbrainOptions = {}): T | null { const r = spawnGbrain(args, opts); if (r.status !== 0) return null; try { return JSON.parse(r.stdout || "null") as T; } catch { return null; } } /** * Async streaming variant for callers that need to attach stdout/stderr * listeners (e.g., `gbrain import` in `gstack-memory-ingest.ts`). Always * injects the seeded env. Returns the raw `ChildProcess` so the caller * can wire up its own promise around exit/timeout/signal handling. */ export function spawnGbrainAsync( args: string[], opts: { stdio?: SpawnOptions["stdio"]; cwd?: string; baseEnv?: NodeJS.ProcessEnv } = {}, ): ChildProcess { return spawn("gbrain", args, { stdio: opts.stdio || ["ignore", "pipe", "pipe"], cwd: opts.cwd, env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }), }); } /** * Run `gbrain ` via execFileSync. Throws on non-zero exit. Useful * for callers that want to surface gbrain's stderr as the error message. */ export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): string { return execFileSync("gbrain", args, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, cwd: opts.cwd, stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), }); }