From 434fc21bc8c8ab42449ed975c15346ae461790df Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 18 Apr 2026 13:13:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20preferences.ts=20+=20cli-util.ts?= =?UTF-8?q?=20=E2=80=94=20foundations=20for=20v0.11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/core/cli-util.ts | 16 +++ src/core/preferences.ts | 142 +++++++++++++++++++++++++++ test/preferences.test.ts | 204 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 src/core/cli-util.ts create mode 100644 src/core/preferences.ts create mode 100644 test/preferences.test.ts diff --git a/src/core/cli-util.ts b/src/core/cli-util.ts new file mode 100644 index 000000000..33f001eb1 --- /dev/null +++ b/src/core/cli-util.ts @@ -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 { + 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(); + }); +} diff --git a/src/core/preferences.ts b/src/core/preferences.ts new file mode 100644 index 000000000..2a2d70b30 --- /dev/null +++ b/src/core/preferences.ts @@ -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 = ['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, +}; diff --git a/test/preferences.test.ts b/test/preferences.test.ts new file mode 100644 index 000000000..5001f5236 --- /dev/null +++ b/test/preferences.test.ts @@ -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'); + }); +});