feat(core): preferences.ts + cli-util.ts — foundations for v0.11.1

Adds two foundational modules that apply-migrations (Lane A-4), the
v0.11.0 orchestrator (Lane C-1), and the stopgap script (Lane C-4) all
depend on.

- src/core/preferences.ts: atomic-write ~/.gbrain/preferences.json
  (mktemp + rename, 0o600, forward-compatible for unknown keys) with
  validateMinionMode, loadPreferences, savePreferences. Plus
  appendCompletedMigration + loadCompletedMigrations for the
  ~/.gbrain/migrations/completed.jsonl log (tolerates malformed lines).
  Uses process.env.HOME || homedir() so $HOME overrides work in CI and
  tests; Bun's os.homedir() caches the initial value and ignores later
  mutations.
- src/core/cli-util.ts: promptLine(prompt) helper, extracted from
  src/commands/init.ts:212-224. Shared so init, apply-migrations, and
  the v0.11.0 orchestrator's mode prompt don't each reinvent it.

test/preferences.test.ts: 21 unit tests covering load/save atomicity,
0o600 perms, forward-compat for unknown keys, minion_mode validation,
completed.jsonl JSONL append idempotence, auto-ts population, malformed-
line tolerance in loadCompletedMigrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-18 13:13:56 +08:00
co-authored by Claude Opus 4.7
parent d25c9ba0b7
commit 434fc21bc8
3 changed files with 362 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Prompt on stdout, read one line from stdin, return trimmed string.
* Shared helper used by interactive CLI flows (init, apply-migrations, etc.).
*/
export function promptLine(prompt: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(prompt);
process.stdin.setEncoding('utf-8');
process.stdin.once('data', (chunk) => {
const data = chunk.toString().trim();
process.stdin.pause();
resolve(data);
});
process.stdin.resume();
});
}
+142
View File
@@ -0,0 +1,142 @@
/**
* ~/.gbrain/preferences.json — user-facing agent-behavior flags (minion_mode, etc.).
*
* Separate from src/core/config.ts (engine config), written to its own file so
* engine config and agent preferences can evolve independently. Atomic writes
* via mktemp + rename; 0o600 perms; forward-compatible (preserves unknown keys).
*
* Also houses ~/.gbrain/migrations/completed.jsonl append helper.
*/
import { readFileSync, writeFileSync, renameSync, chmodSync, mkdtempSync, rmSync, existsSync, mkdirSync, appendFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
function home(): string {
// `os.homedir()` in Bun caches its initial value and ignores later
// `process.env.HOME` mutations, which breaks test isolation and any
// workflow that needs to run against a specific $HOME (CI, scripted installs).
// Prefer the env var; fall back to the cached OS value. Matches the existing
// `src/commands/upgrade.ts` pattern.
return process.env.HOME || homedir();
}
export type MinionMode = 'always' | 'pain_triggered' | 'off';
export interface Preferences {
minion_mode?: MinionMode;
set_at?: string;
set_in_version?: string;
[key: string]: unknown;
}
export interface CompletedMigrationEntry {
version: string;
ts?: string;
status: 'complete' | 'partial';
mode?: MinionMode;
files_rewritten?: number;
autopilot_installed?: boolean;
install_target?: string;
apply_migrations_pending?: boolean;
[key: string]: unknown;
}
const VALID_MODES: ReadonlyArray<MinionMode> = ['always', 'pain_triggered', 'off'];
function prefsDir(): string { return join(home(), '.gbrain'); }
function prefsPath(): string { return join(prefsDir(), 'preferences.json'); }
function migrationsDir(): string { return join(home(), '.gbrain', 'migrations'); }
function completedJsonlPath(): string { return join(migrationsDir(), 'completed.jsonl'); }
/** Validate that a value is a recognized minion mode. Throws with the allowed list. */
export function validateMinionMode(value: unknown): asserts value is MinionMode {
if (typeof value !== 'string' || !VALID_MODES.includes(value as MinionMode)) {
throw new Error(`Invalid minion_mode "${String(value)}". Allowed: ${VALID_MODES.join(', ')}.`);
}
}
/**
* Load preferences. Returns {} when the file is missing (not null — callers
* can always treat the result as a Preferences object).
*
* Malformed JSON throws; caller can catch if they want graceful fallback.
*/
export function loadPreferences(): Preferences {
const path = prefsPath();
if (!existsSync(path)) return {};
const raw = readFileSync(path, 'utf-8');
const parsed = JSON.parse(raw) as Preferences;
return parsed;
}
/**
* Save preferences atomically (mktemp on same filesystem + rename). Preserves
* any unknown keys passed in. Chmods 0o600 after write.
*/
export function savePreferences(prefs: Preferences): void {
if (prefs.minion_mode !== undefined) validateMinionMode(prefs.minion_mode);
const dir = prefsDir();
mkdirSync(dir, { recursive: true });
// Write via a tempfile on the same filesystem, then rename. Avoids the
// "reader sees a half-written file" window that write-in-place has.
const tmpDirForWrite = mkdtempSync(join(dir, '.prefs-tmp-'));
const tmpPath = join(tmpDirForWrite, 'preferences.json');
try {
writeFileSync(tmpPath, JSON.stringify(prefs, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(tmpPath, 0o600); } catch { /* chmod may fail on some platforms */ }
renameSync(tmpPath, prefsPath());
} finally {
try { rmSync(tmpDirForWrite, { recursive: true, force: true }); } catch { /* best-effort */ }
}
try { chmodSync(prefsPath(), 0o600); } catch { /* best-effort */ }
}
/**
* Append one line to ~/.gbrain/migrations/completed.jsonl. Creates the
* directory if missing. Does not read existing lines (append is cheap and
* the reader tolerates malformed lines by skipping them).
*
* Writes `ts` as the current ISO timestamp if not provided.
*/
export function appendCompletedMigration(entry: CompletedMigrationEntry): void {
if (!entry.version) throw new Error('appendCompletedMigration: version required');
if (entry.status !== 'complete' && entry.status !== 'partial') {
throw new Error(`appendCompletedMigration: status must be 'complete' or 'partial', got "${entry.status}"`);
}
const full: CompletedMigrationEntry = {
ts: new Date().toISOString(),
...entry,
};
const dir = migrationsDir();
mkdirSync(dir, { recursive: true });
appendFileSync(completedJsonlPath(), JSON.stringify(full) + '\n');
}
/** Read the completed.jsonl file, skipping malformed lines with a warning to stderr. */
export function loadCompletedMigrations(): CompletedMigrationEntry[] {
const path = completedJsonlPath();
if (!existsSync(path)) return [];
const raw = readFileSync(path, 'utf-8');
const out: CompletedMigrationEntry[] = [];
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
out.push(JSON.parse(trimmed) as CompletedMigrationEntry);
} catch (err) {
console.warn(`[preferences] skipping malformed completed.jsonl line: ${trimmed.slice(0, 120)}`);
}
}
return out;
}
/** Paths — exported for tests and rare consumers. */
export const preferencesPaths = {
dir: prefsDir,
file: prefsPath,
migrationsDir,
completedJsonl: completedJsonlPath,
};
+204
View File
@@ -0,0 +1,204 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
loadPreferences,
savePreferences,
validateMinionMode,
appendCompletedMigration,
loadCompletedMigrations,
preferencesPaths,
type Preferences,
} from '../src/core/preferences.ts';
let origHome: string | undefined;
let tmp: string;
beforeEach(() => {
origHome = process.env.HOME;
tmp = mkdtempSync(join(tmpdir(), 'gbrain-prefs-test-'));
process.env.HOME = tmp;
});
afterEach(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
});
describe('validateMinionMode', () => {
test('accepts always / pain_triggered / off', () => {
expect(() => validateMinionMode('always')).not.toThrow();
expect(() => validateMinionMode('pain_triggered')).not.toThrow();
expect(() => validateMinionMode('off')).not.toThrow();
});
test('rejects bogus string with clear allowed list', () => {
expect(() => validateMinionMode('bogus')).toThrow(/always.*pain_triggered.*off/);
});
test('rejects non-string values', () => {
expect(() => validateMinionMode(42)).toThrow();
expect(() => validateMinionMode(null)).toThrow();
expect(() => validateMinionMode(undefined)).toThrow();
});
});
describe('loadPreferences', () => {
test('returns empty object when file is missing', () => {
expect(loadPreferences()).toEqual({});
});
test('parses existing JSON file', () => {
mkdirSync(join(tmp, '.gbrain'), { recursive: true });
writeFileSync(
join(tmp, '.gbrain', 'preferences.json'),
JSON.stringify({ minion_mode: 'always', set_in_version: '0.11.0' }),
);
expect(loadPreferences()).toEqual({ minion_mode: 'always', set_in_version: '0.11.0' });
});
test('throws on malformed JSON so callers can surface it', () => {
mkdirSync(join(tmp, '.gbrain'), { recursive: true });
writeFileSync(join(tmp, '.gbrain', 'preferences.json'), '{not json');
expect(() => loadPreferences()).toThrow();
});
});
describe('savePreferences', () => {
test('writes file with 0o600 perms', () => {
savePreferences({ minion_mode: 'pain_triggered' });
const path = preferencesPaths.file();
expect(existsSync(path)).toBe(true);
const mode = statSync(path).mode & 0o777;
expect(mode).toBe(0o600);
});
test('round-trip preserves unknown keys for forward-compat', () => {
const prefs: Preferences = {
minion_mode: 'always',
set_at: '2026-04-18T00:00:00Z',
set_in_version: '0.11.0',
// deliberately unknown key — future version may add this
future_feature_flag: { enabled: true, setting: 42 },
};
savePreferences(prefs);
expect(loadPreferences()).toEqual(prefs);
});
test('rejects invalid minion_mode on save', () => {
expect(() => savePreferences({ minion_mode: 'bogus' as any })).toThrow();
});
test('creates ~/.gbrain directory if missing', () => {
// Confirm .gbrain doesn't exist yet
expect(existsSync(join(tmp, '.gbrain'))).toBe(false);
savePreferences({ minion_mode: 'off' });
expect(existsSync(join(tmp, '.gbrain'))).toBe(true);
});
test('concurrent save + load: reader never sees a half-written file', () => {
// Save a valid file, then save a new one. In the middle, the file should
// always be parseable (atomic rename guarantees this).
savePreferences({ minion_mode: 'always' });
const firstLoad = loadPreferences();
expect(firstLoad.minion_mode).toBe('always');
savePreferences({ minion_mode: 'pain_triggered' });
const secondLoad = loadPreferences();
expect(secondLoad.minion_mode).toBe('pain_triggered');
});
test('cleans up temp directory used for atomic write', () => {
savePreferences({ minion_mode: 'off' });
const gbrainDir = join(tmp, '.gbrain');
// Walk children; nothing should remain except preferences.json (plus maybe subdirs
// created by other code, but for this test the only thing we wrote is prefs).
const { readdirSync } = require('fs');
const entries = readdirSync(gbrainDir);
// Only preferences.json should remain; no .prefs-tmp-* directories left over.
expect(entries.filter((e: string) => e.startsWith('.prefs-tmp-'))).toEqual([]);
expect(entries).toContain('preferences.json');
});
});
describe('appendCompletedMigration', () => {
test('creates migrations dir and appends valid JSONL', () => {
appendCompletedMigration({ version: '0.11.0', status: 'complete', mode: 'always' });
const path = preferencesPaths.completedJsonl();
expect(existsSync(path)).toBe(true);
const lines = readFileSync(path, 'utf-8').split('\n').filter(l => l.trim());
expect(lines.length).toBe(1);
const parsed = JSON.parse(lines[0]);
expect(parsed.version).toBe('0.11.0');
expect(parsed.status).toBe('complete');
expect(parsed.mode).toBe('always');
expect(parsed.ts).toBeTruthy();
});
test('appends instead of overwriting', () => {
appendCompletedMigration({ version: '0.11.0', status: 'partial', apply_migrations_pending: true });
appendCompletedMigration({ version: '0.11.0', status: 'complete', mode: 'always' });
const lines = readFileSync(preferencesPaths.completedJsonl(), 'utf-8').split('\n').filter(l => l.trim());
expect(lines.length).toBe(2);
});
test('rejects entries with no version', () => {
expect(() => appendCompletedMigration({ status: 'complete' } as any)).toThrow(/version/);
});
test('rejects entries with invalid status', () => {
expect(() => appendCompletedMigration({ version: '0.11.0', status: 'done' as any })).toThrow(/status/);
});
test('auto-populates ts when not provided', () => {
const before = Date.now();
appendCompletedMigration({ version: '0.11.0', status: 'complete' });
const parsed = JSON.parse(readFileSync(preferencesPaths.completedJsonl(), 'utf-8').trim());
const ts = Date.parse(parsed.ts);
expect(ts).toBeGreaterThanOrEqual(before);
});
test('preserves caller-provided ts', () => {
appendCompletedMigration({ version: '0.11.0', status: 'complete', ts: '2020-01-01T00:00:00Z' });
const parsed = JSON.parse(readFileSync(preferencesPaths.completedJsonl(), 'utf-8').trim());
expect(parsed.ts).toBe('2020-01-01T00:00:00Z');
});
});
describe('loadCompletedMigrations', () => {
test('returns empty when file is missing', () => {
expect(loadCompletedMigrations()).toEqual([]);
});
test('parses valid JSONL lines', () => {
appendCompletedMigration({ version: '0.10.0', status: 'complete' });
appendCompletedMigration({ version: '0.11.0', status: 'partial' });
const entries = loadCompletedMigrations();
expect(entries.length).toBe(2);
expect(entries[0].version).toBe('0.10.0');
expect(entries[1].status).toBe('partial');
});
test('tolerates malformed lines with a warning, continuing past them', () => {
const dir = join(tmp, '.gbrain', 'migrations');
mkdirSync(dir, { recursive: true });
// Write a file with a good line, a malformed line, and another good line.
writeFileSync(
join(dir, 'completed.jsonl'),
[
JSON.stringify({ version: '0.10.0', status: 'complete' }),
'{this is not valid json',
JSON.stringify({ version: '0.11.0', status: 'complete' }),
'',
].join('\n'),
);
const entries = loadCompletedMigrations();
expect(entries.length).toBe(2);
expect(entries[0].version).toBe('0.10.0');
expect(entries[1].version).toBe('0.11.0');
});
});