From 3f287dca799c0b9770e36b6ac605544a8639ee02 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 18 Apr 2026 13:14:07 +0800 Subject: [PATCH] feat(init): add --migrate-only flag (schema-only, no saveConfig) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: v0.11.0 migration orchestrators need a safe way to re-apply the schema against an existing brain without risking a config flip. Today running bare `gbrain init` with no flags defaults to PGLite and calls saveConfig, which would silently overwrite an existing Postgres database_url — caught by Codex in the v0.11.1 plan review as a show-stopper data-loss bug. The new --migrate-only path: - loadConfig() reads the existing config (does NOT call saveConfig) - errors out with a clear "run gbrain init first" if no config exists - connects via the already-configured engine, calls engine.initSchema(), disconnects - --json emits structured success/error payloads Everything downstream in the v0.11.1 migration chain (apply-migrations, the stopgap bash script, the package.json postinstall hook) will invoke this flag rather than bare gbrain init. test/init-migrate-only.test.ts: 4 tests covering the no-config error path, --json error payload shape, happy-path with a PGLite fixture (verifies config.json content is byte-identical after the call — the real invariant), and idempotent rerun. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/init.ts | 45 ++++++++++++- test/init-migrate-only.test.ts | 120 +++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 test/init-migrate-only.test.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index 6380912f8..3014461a7 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,13 +6,14 @@ import { homedir } from 'os'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -import { saveConfig, type GBrainConfig } from '../core/config.ts'; +import { saveConfig, loadConfig, toEngineConfig, type GBrainConfig } from '../core/config.ts'; import { createEngine } from '../core/engine-factory.ts'; export async function runInit(args: string[]) { const isSupabase = args.includes('--supabase'); const isPGLite = args.includes('--pglite'); const isNonInteractive = args.includes('--non-interactive'); + const isMigrateOnly = args.includes('--migrate-only'); const jsonOutput = args.includes('--json'); const urlIndex = args.indexOf('--url'); const manualUrl = urlIndex !== -1 ? args[urlIndex + 1] : null; @@ -21,6 +22,15 @@ export async function runInit(args: string[]) { const pathIndex = args.indexOf('--path'); const customPath = pathIndex !== -1 ? args[pathIndex + 1] : null; + // Schema-only path: apply initSchema against the already-configured engine + // without ever calling saveConfig. Used by apply-migrations, the stopgap + // script, and the postinstall hook. Bare `gbrain init` defaults to PGLite + // and overwrites any existing Postgres config — we must never take that + // branch from a migration orchestrator. + if (isMigrateOnly) { + return initMigrateOnly({ jsonOutput }); + } + // Explicit PGLite mode if (isPGLite || (!isSupabase && !manualUrl && !isNonInteractive)) { // Smart detection: scan for .md files unless --pglite flag forces it @@ -59,6 +69,39 @@ export async function runInit(args: string[]) { return initPostgres({ databaseUrl, jsonOutput, apiKey }); } +/** + * Apply the schema against the already-configured engine. No saveConfig. + * No PGLite fallback when no config exists. Used by migration orchestrators + * to bump an existing brain's schema to the latest version without + * clobbering the user's chosen engine. + */ +async function initMigrateOnly(opts: { jsonOutput: boolean }) { + const config = loadConfig(); + if (!config) { + const msg = 'No brain configured. Run `gbrain init` (interactive) or `gbrain init --pglite` / `gbrain init --supabase` first.'; + if (opts.jsonOutput) { + console.log(JSON.stringify({ status: 'error', reason: 'no_config', message: msg })); + } else { + console.error(msg); + } + process.exit(1); + } + + const engine = await createEngine(toEngineConfig(config)); + try { + await engine.connect(toEngineConfig(config)); + await engine.initSchema(); + } finally { + try { await engine.disconnect(); } catch { /* best-effort */ } + } + + if (opts.jsonOutput) { + console.log(JSON.stringify({ status: 'success', engine: config.engine, mode: 'migrate-only' })); + } else { + console.log(`Schema up to date (engine: ${config.engine}).`); + } +} + async function initPGLite(opts: { jsonOutput: boolean; apiKey: string | null; customPath: string | null }) { const dbPath = opts.customPath || join(homedir(), '.gbrain', 'brain.pglite'); console.log(`Setting up local brain with PGLite (no server needed)...`); diff --git a/test/init-migrate-only.test.ts b/test/init-migrate-only.test.ts new file mode 100644 index 000000000..b9c0fdf51 --- /dev/null +++ b/test/init-migrate-only.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for `gbrain init --migrate-only` — the schema-only primitive used by + * apply-migrations, the stopgap script, and the postinstall hook. + * + * The key contract: migrate-only MUST NOT call saveConfig. Running it on an + * existing Postgres install must not flip it to PGLite. Running it against a + * missing config must fail loudly with a clear "run gbrain init first" error. + * + * Uses child_process subprocess invocations (not in-proc) because runInit + * calls process.exit(1) on error paths, which breaks test isolation. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { execFileSync } from 'child_process'; + +const CLI = join(__dirname, '..', 'src', 'cli.ts'); + +let tmp: string; +let origHome: string | undefined; + +function run(args: string[]): { exitCode: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync('bun', ['run', CLI, ...args], { + env: { ...process.env, HOME: tmp }, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { exitCode: 0, stdout, stderr: '' }; + } catch (err: any) { + return { + exitCode: err.status ?? 1, + stdout: err.stdout?.toString?.() ?? '', + stderr: err.stderr?.toString?.() ?? '', + }; + } +} + +beforeEach(() => { + origHome = process.env.HOME; + tmp = mkdtempSync(join(tmpdir(), 'gbrain-init-migrate-only-test-')); +}); + +afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } +}); + +describe('gbrain init --migrate-only — error paths', () => { + test('errors with clear message when no config exists', () => { + const result = run(['init', '--migrate-only']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No brain configured'); + // Config file must not have been created (no saveConfig silently) + expect(existsSync(join(tmp, '.gbrain', 'config.json'))).toBe(false); + }); + + test('JSON output flag emits a structured error', () => { + const result = run(['init', '--migrate-only', '--json']); + expect(result.exitCode).toBe(1); + // --json writes the structured error to stdout per the pattern in init.ts + const lines = result.stdout.split('\n').filter((l: string) => l.trim().startsWith('{')); + expect(lines.length).toBeGreaterThanOrEqual(1); + const parsed = JSON.parse(lines[lines.length - 1]); + expect(parsed.status).toBe('error'); + expect(parsed.reason).toBe('no_config'); + }); +}); + +describe('gbrain init --migrate-only — happy path with PGLite config', () => { + test('applies schema against existing PGLite config; does NOT modify config.json', () => { + // Seed an existing PGLite config + brain file. + const gbrainDir = join(tmp, '.gbrain'); + mkdirSync(gbrainDir, { recursive: true }); + const dbPath = join(gbrainDir, 'brain.pglite'); + const configPath = join(gbrainDir, 'config.json'); + const cfg = { engine: 'pglite', database_path: dbPath }; + writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n'); + + // Capture the config's mtime + content to verify saveConfig was NOT called. + const mtimeBefore = statSync(configPath).mtimeMs; + const contentBefore = readFileSync(configPath, 'utf-8'); + + // First run: should apply schema. + const result = run(['init', '--migrate-only', '--json']); + expect(result.exitCode).toBe(0); + const jsonLines = result.stdout.split('\n').filter((l: string) => l.trim().startsWith('{')); + const parsed = JSON.parse(jsonLines[jsonLines.length - 1]); + expect(parsed.status).toBe('success'); + expect(parsed.engine).toBe('pglite'); + expect(parsed.mode).toBe('migrate-only'); + + // Critical: config.json MUST NOT have been overwritten. Either the mtime + // is unchanged (strictest) or at minimum the content is identical. + const contentAfter = readFileSync(configPath, 'utf-8'); + expect(contentAfter).toBe(contentBefore); + // mtime may or may not tick depending on OS resolution; content equality + // is the real invariant we need. + + // Brain file should exist (schema applied). + expect(existsSync(dbPath)).toBe(true); + }, 30_000); + + test('idempotent on rerun — second call succeeds without error', () => { + const gbrainDir = join(tmp, '.gbrain'); + mkdirSync(gbrainDir, { recursive: true }); + const dbPath = join(gbrainDir, 'brain.pglite'); + const configPath = join(gbrainDir, 'config.json'); + writeFileSync(configPath, JSON.stringify({ engine: 'pglite', database_path: dbPath }) + '\n'); + + const first = run(['init', '--migrate-only', '--json']); + expect(first.exitCode).toBe(0); + + const second = run(['init', '--migrate-only', '--json']); + expect(second.exitCode).toBe(0); + }, 60_000); +});