mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
feat(init): add --migrate-only flag (schema-only, no saveConfig)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
434fc21bc8
commit
3f287dca79
+44
-1
@@ -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)...`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user