/** * Minimal persistence functions for HAPI CLI * * Handles settings and private key storage in ~/.hapi/ (or HAPI_HOME override) */ import { FileHandle } from 'node:fs/promises' import { readFile, writeFile, mkdir, open, unlink, rename, stat } from 'node:fs/promises' import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs' import { constants } from 'node:fs' import { configuration } from '@/configuration' import * as z from 'zod'; import { encodeBase64 } from '@/api/encryption'; interface Settings { onboardingCompleted: boolean // This ID is used as the actual database ID on the server // All machine operations use this ID machineId?: string machineIdConfirmedByServer?: boolean daemonAutoStartWhenRunningHappy?: boolean cliApiToken?: string } const defaultSettings: Settings = { onboardingCompleted: false } /** * Daemon state persisted locally (different from API DaemonState) * This is written to disk by the daemon to track its local process state */ export interface DaemonLocallyPersistedState { pid: number; httpPort: number; startTime: string; startedWithCliVersion: string; startedWithCliMtimeMs?: number; lastHeartbeat?: string; daemonLogPath?: string; } export async function readSettings(): Promise { if (!existsSync(configuration.settingsFile)) { return { ...defaultSettings } } try { const content = await readFile(configuration.settingsFile, 'utf8') return JSON.parse(content) } catch { return { ...defaultSettings } } } export async function writeSettings(settings: Settings): Promise { if (!existsSync(configuration.happyHomeDir)) { await mkdir(configuration.happyHomeDir, { recursive: true }) } await writeFile(configuration.settingsFile, JSON.stringify(settings, null, 2)) } /** * Atomically update settings with multi-process safety via file locking * @param updater Function that takes current settings and returns updated settings * @returns The updated settings */ export async function updateSettings( updater: (current: Settings) => Settings | Promise ): Promise { // Timing constants const LOCK_RETRY_INTERVAL_MS = 100; // How long to wait between lock attempts const MAX_LOCK_ATTEMPTS = 50; // Maximum number of attempts (5 seconds total) const STALE_LOCK_TIMEOUT_MS = 10000; // Consider lock stale after 10 seconds if (!existsSync(configuration.happyHomeDir)) { await mkdir(configuration.happyHomeDir, { recursive: true }); } const lockFile = configuration.settingsFile + '.lock'; const tmpFile = configuration.settingsFile + '.tmp'; let fileHandle; let attempts = 0; // Acquire exclusive lock with retries while (attempts < MAX_LOCK_ATTEMPTS) { try { // O_CREAT | O_EXCL | O_WRONLY = create exclusively, fail if exists fileHandle = await open(lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); break; } catch (err: any) { if (err.code === 'EEXIST') { // Lock file exists, wait and retry attempts++; await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); // Check for stale lock try { const stats = await stat(lockFile); if (Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS) { await unlink(lockFile).catch(() => { }); } } catch { } } else { throw err; } } } if (!fileHandle) { throw new Error(`Failed to acquire settings lock after ${MAX_LOCK_ATTEMPTS * LOCK_RETRY_INTERVAL_MS / 1000} seconds`); } try { // Read current settings with defaults const current = await readSettings() || { ...defaultSettings }; // Apply update const updated = await updater(current); // Write atomically using rename await writeFile(tmpFile, JSON.stringify(updated, null, 2)); await rename(tmpFile, configuration.settingsFile); // Atomic on POSIX return updated; } finally { // Release lock await fileHandle.close(); await unlink(lockFile).catch(() => { }); // Remove lock file } } // // Authentication // const credentialsSchema = z.object({ token: z.string(), secret: z.string().base64().nullish(), // Legacy encryption: z.object({ publicKey: z.string().base64(), machineKey: z.string().base64() }).nullish() }) export type Credentials = { token: string, encryption: { type: 'legacy', secret: Uint8Array } | { type: 'dataKey', publicKey: Uint8Array, machineKey: Uint8Array } } export async function readCredentials(): Promise { if (!existsSync(configuration.privateKeyFile)) { return null } try { const keyBase64 = (await readFile(configuration.privateKeyFile, 'utf8')); const credentials = credentialsSchema.parse(JSON.parse(keyBase64)); if (credentials.secret) { return { token: credentials.token, encryption: { type: 'legacy', secret: new Uint8Array(Buffer.from(credentials.secret, 'base64')) } }; } else if (credentials.encryption) { return { token: credentials.token, encryption: { type: 'dataKey', publicKey: new Uint8Array(Buffer.from(credentials.encryption.publicKey, 'base64')), machineKey: new Uint8Array(Buffer.from(credentials.encryption.machineKey, 'base64')) } } } } catch { return null } return null } export async function writeCredentialsLegacy(credentials: { secret: Uint8Array, token: string }): Promise { if (!existsSync(configuration.happyHomeDir)) { await mkdir(configuration.happyHomeDir, { recursive: true }) } await writeFile(configuration.privateKeyFile, JSON.stringify({ secret: encodeBase64(credentials.secret), token: credentials.token }, null, 2)); } export async function writeCredentialsDataKey(credentials: { publicKey: Uint8Array, machineKey: Uint8Array, token: string }): Promise { if (!existsSync(configuration.happyHomeDir)) { await mkdir(configuration.happyHomeDir, { recursive: true }) } await writeFile(configuration.privateKeyFile, JSON.stringify({ encryption: { publicKey: encodeBase64(credentials.publicKey), machineKey: encodeBase64(credentials.machineKey) }, token: credentials.token }, null, 2)); } export async function clearCredentials(): Promise { if (existsSync(configuration.privateKeyFile)) { await unlink(configuration.privateKeyFile); } } export async function clearMachineId(): Promise { await updateSettings(settings => ({ ...settings, machineId: undefined })); } /** * Read daemon state from local file */ export async function readDaemonState(): Promise { try { if (!existsSync(configuration.daemonStateFile)) { return null; } const content = await readFile(configuration.daemonStateFile, 'utf-8'); return JSON.parse(content) as DaemonLocallyPersistedState; } catch (error) { // State corrupted somehow :( console.error(`[PERSISTENCE] Daemon state file corrupted: ${configuration.daemonStateFile}`, error); return null; } } /** * Write daemon state to local file (synchronously for atomic operation) */ export function writeDaemonState(state: DaemonLocallyPersistedState): void { writeFileSync(configuration.daemonStateFile, JSON.stringify(state, null, 2), 'utf-8'); } /** * Clean up daemon state file and lock file */ export async function clearDaemonState(): Promise { if (existsSync(configuration.daemonStateFile)) { await unlink(configuration.daemonStateFile); } // Also clean up lock file if it exists (for stale cleanup) if (existsSync(configuration.daemonLockFile)) { try { await unlink(configuration.daemonLockFile); } catch { // Lock file might be held by running daemon, ignore error } } } /** * Acquire an exclusive lock file for the daemon. * The lock file proves the daemon is running and prevents multiple instances. * Returns the file handle to hold for the daemon's lifetime, or null if locked. */ export async function acquireDaemonLock( maxAttempts: number = 5, delayIncrementMs: number = 200 ): Promise { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { // O_EXCL ensures we only create if it doesn't exist (atomic lock acquisition) const fileHandle = await open( configuration.daemonLockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY ); // Write PID to lock file for debugging await fileHandle.writeFile(String(process.pid)); return fileHandle; } catch (error: any) { if (error.code === 'EEXIST') { // Lock file exists, check if process is still running try { const lockPid = readFileSync(configuration.daemonLockFile, 'utf-8').trim(); if (lockPid && !isNaN(Number(lockPid))) { try { process.kill(Number(lockPid), 0); // Check if process exists } catch { // Process doesn't exist, remove stale lock unlinkSync(configuration.daemonLockFile); continue; // Retry acquisition } } } catch { // Can't read lock file, might be corrupted } } if (attempt === maxAttempts) { return null; } const delayMs = attempt * delayIncrementMs; await new Promise(resolve => setTimeout(resolve, delayMs)); } } return null; } /** * Release daemon lock by closing handle and deleting lock file */ export async function releaseDaemonLock(lockHandle: FileHandle): Promise { try { await lockHandle.close(); } catch { } try { if (existsSync(configuration.daemonLockFile)) { unlinkSync(configuration.daemonLockFile); } } catch { } }