From 4f1b341b37de13a15486c15c4c0e291e906a2a6b Mon Sep 17 00:00:00 2001 From: Jason Shultz Date: Thu, 14 May 2026 15:23:07 -0700 Subject: [PATCH] fix(sync-gbrain): seed DATABASE_URL from ~/.gbrain/config.json into gbrain spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gbrain auto-loads .env.local from cwd via dotenv. When /sync-gbrain runs inside a Next.js / Prisma / Rails project whose .env.local defines its own DATABASE_URL (pointing at the app's local DB), gbrain picked that up instead of its own ~/.gbrain/config.json URL — auth failed, code + memory stages crashed; only the brain-sync git push survived. Fix: new buildGbrainEnv() helper reads ~/.gbrain/config.json once, builds a child-env dict with DATABASE_URL set to the gbrain-configured URL, and threads it through every gbrain spawn in runCodeImport, runMemoryIngest, and runBrainSyncPush (plus ensureSourceRegistered and sourcePageCount via the env option lib/gbrain-sources.ts already exposes). Cannot just mutate process.env — Bun's child_process.spawnSync children get the original startup env, not runtime mutations. Must pass env: explicitly. Comment in the helper records this caveat. Escape hatch: GSTACK_RESPECT_ENV_DATABASE_URL=1 returns process.env unchanged for the (rare) case where the user really does want gbrain to use the project's local DB. Repro before patch: cd /sync-gbrain → code: ERR source registration failed: gbrain not configured → memory: ERR password authentication failed for user "postgres" After patch: all three stages OK, code source registered, 575 pages indexed in the test repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-gbrain-sync.ts | 70 ++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 36b265e42d..d6b7014258 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -241,6 +241,52 @@ function gbrainAvailable(): boolean { } } +/** + * gbrain auto-loads `.env.local` from cwd via dotenv. When this orchestrator + * runs inside a Next.js / Prisma / Rails project whose .env.local defines its + * own DATABASE_URL (pointing at the app's DB), gbrain picks that up instead of + * its own `~/.gbrain/config.json` connection URL. Auth fails, code + memory + * stages crash. brain-sync survives because it only does git push. + * + * Fix: read ~/.gbrain/config.json once and build a child-env dict with the + * correct DATABASE_URL. Pass `env: gbrainEnv` to every gbrain spawn. gbrain's + * own dotenv-load respects pre-set env (override=false), so the explicit value + * beats .env.local. + * + * Important Bun caveat: mutating `process.env.DATABASE_URL` at runtime does + * NOT propagate to children of `child_process.spawnSync` — Bun's child gets + * the original startup env. So we cannot just set process.env; we must thread + * an explicit env dict to every spawn. lib/gbrain-sources.ts already accepts + * an `env` option for exactly this reason. + * + * Escape hatch: GSTACK_RESPECT_ENV_DATABASE_URL=1 returns process.env + * unchanged (e.g., when intentionally syncing a brain that lives in the + * project's local DB). + */ +function buildGbrainEnv(quiet: boolean): NodeJS.ProcessEnv { + const base: NodeJS.ProcessEnv = { ...process.env }; + if (process.env.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return base; + const configPath = join(HOME, ".gbrain", "config.json"); + if (!existsSync(configPath)) return base; + let cfg: { database_url?: string } = {}; + try { + cfg = JSON.parse(readFileSync(configPath, "utf-8")); + } catch { + return base; + } + if (!cfg.database_url) return base; + if (base.DATABASE_URL === cfg.database_url) return base; + const had = base.DATABASE_URL !== undefined; + base.DATABASE_URL = cfg.database_url; + if (!quiet) { + console.error( + `[gbrain-sync] seeded DATABASE_URL from ~/.gbrain/config.json` + + (had ? " (overrode value from caller env / .env.local)" : "") + ); + } + return base; +} + // ── Lock file (D1) ───────────────────────────────────────────────────────── interface LockInfo { @@ -290,7 +336,7 @@ function releaseLock(): void { // ── Stage runners ────────────────────────────────────────────────────────── -async function runCodeImport(args: CliArgs): Promise { +async function runCodeImport(args: CliArgs, env: NodeJS.ProcessEnv): Promise { const t0 = Date.now(); const root = repoRoot(); if (!root) { @@ -327,6 +373,7 @@ async function runCodeImport(args: CliArgs): Promise { encoding: "utf-8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], + env, }); // Treat absent-source as success (clean state). gbrain emits "not found" on // missing id; treat any non-zero exit without "not found" as a soft fail. @@ -337,7 +384,7 @@ async function runCodeImport(args: CliArgs): Promise { // no synchronous duplicate here (per /codex review #12). let registered = false; try { - const result = await ensureSourceRegistered(sourceId, root, { federated: true }); + const result = await ensureSourceRegistered(sourceId, root, { federated: true, env }); registered = result.changed; } catch (err) { return { @@ -358,6 +405,7 @@ async function runCodeImport(args: CliArgs): Promise { const syncResult = spawnSync("gbrain", syncArgs, { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: 35 * 60 * 1000, + env, }); if (syncResult.status !== 0) { @@ -385,8 +433,9 @@ async function runCodeImport(args: CliArgs): Promise { timeout: 10_000, cwd: root, stdio: ["ignore", "pipe", "pipe"], + env, }); - const pageCount = sourcePageCount(sourceId); + const pageCount = sourcePageCount(sourceId, env); const legacyNote = legacyRemoved ? `, removed legacy ${legacyId}` : ""; const baseSummary = `${registered ? "registered + " : ""}synced ${sourceId} (page_count=${pageCount ?? "unknown"}${legacyNote})`; @@ -424,7 +473,7 @@ async function runCodeImport(args: CliArgs): Promise { }; } -function runMemoryIngest(args: CliArgs): StageResult { +function runMemoryIngest(args: CliArgs, env: NodeJS.ProcessEnv): StageResult { const t0 = Date.now(); if (args.mode === "dry-run") { @@ -440,6 +489,7 @@ function runMemoryIngest(args: CliArgs): StageResult { const result = spawnSync("bun", ingestArgs, { encoding: "utf-8", timeout: 35 * 60 * 1000, + env, }); // D6: parse [memory-ingest] lines from the child's stderr. ERR-prefixed @@ -469,7 +519,7 @@ function runMemoryIngest(args: CliArgs): StageResult { }; } -function runBrainSyncPush(args: CliArgs): StageResult { +function runBrainSyncPush(args: CliArgs, env: NodeJS.ProcessEnv): StageResult { const t0 = Date.now(); if (args.mode === "dry-run") { @@ -484,10 +534,12 @@ function runBrainSyncPush(args: CliArgs): StageResult { spawnSync(brainSyncPath, ["--discover-new"], { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: 60 * 1000, + env, }); const result = spawnSync(brainSyncPath, ["--once"], { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: 60 * 1000, + env, }); return { @@ -550,6 +602,8 @@ function formatStage(s: StageResult): string { async function main(): Promise { const args = parseArgs(); + const gbrainEnv = buildGbrainEnv(args.quiet); + if (!args.quiet) { const engine = detectEngineTier(); console.error(`[gbrain-sync] mode=${args.mode} engine=${engine.engine}`); @@ -581,13 +635,13 @@ async function main(): Promise { const stages: StageResult[] = []; if (!args.noCode) { - stages.push(await withErrorContext("sync:code", () => runCodeImport(args), "gstack-gbrain-sync")); + stages.push(await withErrorContext("sync:code", () => runCodeImport(args, gbrainEnv), "gstack-gbrain-sync")); } if (!args.noMemory) { - stages.push(await withErrorContext("sync:memory", () => runMemoryIngest(args), "gstack-gbrain-sync")); + stages.push(await withErrorContext("sync:memory", () => runMemoryIngest(args, gbrainEnv), "gstack-gbrain-sync")); } if (!args.noBrainSync) { - stages.push(await withErrorContext("sync:brain-sync", () => runBrainSyncPush(args), "gstack-gbrain-sync")); + stages.push(await withErrorContext("sync:brain-sync", () => runBrainSyncPush(args, gbrainEnv), "gstack-gbrain-sync")); } if (args.mode !== "dry-run") {