mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-28 23:01:34 +00:00
Replace CLI version string comparison with file modification time (mtime) based detection. This provides a more reliable way to detect when the CLI binary has been updated, especially for bun-compiled executables where package.json may not be accessible. Changes: - Add getInstalledCliMtimeMs() utility to check CLI binary or package.json mtime - Store startedWithCliMtimeMs in daemon state on startup - Use mtime comparison in version check loop for daemon auto-restart - Move version flag handling to early CLI startup before daemon initialization - Fix machine active state merging to prefer newer activeAt timestamp This improves daemon restart behavior when CLI is updated via package managers.
324 lines
9.8 KiB
TypeScript
324 lines
9.8 KiB
TypeScript
/**
|
|
* 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<Settings> {
|
|
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<void> {
|
|
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<Settings>
|
|
): Promise<Settings> {
|
|
// 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
|
|
|
|
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);
|
|
|
|
// Ensure directory exists
|
|
if (!existsSync(configuration.happyHomeDir)) {
|
|
await mkdir(configuration.happyHomeDir, { recursive: true });
|
|
}
|
|
|
|
// 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<Credentials | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
if (existsSync(configuration.privateKeyFile)) {
|
|
await unlink(configuration.privateKeyFile);
|
|
}
|
|
}
|
|
|
|
export async function clearMachineId(): Promise<void> {
|
|
await updateSettings(settings => ({
|
|
...settings,
|
|
machineId: undefined
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Read daemon state from local file
|
|
*/
|
|
export async function readDaemonState(): Promise<DaemonLocallyPersistedState | null> {
|
|
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<void> {
|
|
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<FileHandle | null> {
|
|
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<void> {
|
|
try {
|
|
await lockHandle.close();
|
|
} catch { }
|
|
|
|
try {
|
|
if (existsSync(configuration.daemonLockFile)) {
|
|
unlinkSync(configuration.daemonLockFile);
|
|
}
|
|
} catch { }
|
|
}
|