mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Opus 4.7
parent
d25c9ba0b7
commit
434fc21bc8
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user