From 70c3960527c8acba69d0bc8efd190a0bda76c08e Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 2 Feb 2026 20:56:45 -0800 Subject: [PATCH] feat: add daemon skeleton with lifecycle management Co-Authored-By: Claude Opus 4.5 --- src/daemon/index.ts | 30 +++++++++++++++++++++++++++ src/daemon/lifecycle.test.ts | 39 ++++++++++++++++++++++++++++++++++++ src/daemon/lifecycle.ts | 34 +++++++++++++++++++++++++++++++ src/index.ts | 31 +++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/daemon/index.ts create mode 100644 src/daemon/lifecycle.test.ts create mode 100644 src/daemon/lifecycle.ts diff --git a/src/daemon/index.ts b/src/daemon/index.ts new file mode 100644 index 0000000..8575972 --- /dev/null +++ b/src/daemon/index.ts @@ -0,0 +1,30 @@ +import { Lifecycle } from './lifecycle.js'; +import type { Config } from '../config/index.js'; + +export interface DaemonContext { + config: Config; + lifecycle: Lifecycle; +} + +export async function startDaemon(config: Config): Promise { + const lifecycle = new Lifecycle(); + + // Register signal handlers + const signalHandler = () => { + lifecycle.shutdown().then(() => process.exit(0)); + }; + + process.on('SIGINT', signalHandler); + process.on('SIGTERM', signalHandler); + + lifecycle.onShutdown(async () => { + process.off('SIGINT', signalHandler); + process.off('SIGTERM', signalHandler); + }); + + console.log('Flynn daemon started'); + + return { config, lifecycle }; +} + +export { Lifecycle } from './lifecycle.js'; diff --git a/src/daemon/lifecycle.test.ts b/src/daemon/lifecycle.test.ts new file mode 100644 index 0000000..76cf202 --- /dev/null +++ b/src/daemon/lifecycle.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Lifecycle } from './lifecycle.js'; + +describe('Lifecycle', () => { + it('registers and calls shutdown handlers in reverse order', async () => { + const lifecycle = new Lifecycle(); + const calls: number[] = []; + + lifecycle.onShutdown(async () => { calls.push(1); }); + lifecycle.onShutdown(async () => { calls.push(2); }); + lifecycle.onShutdown(async () => { calls.push(3); }); + + await lifecycle.shutdown(); + + expect(calls).toEqual([3, 2, 1]); + }); + + it('only shuts down once', async () => { + const lifecycle = new Lifecycle(); + let count = 0; + + lifecycle.onShutdown(async () => { count++; }); + + await lifecycle.shutdown(); + await lifecycle.shutdown(); + + expect(count).toBe(1); + }); + + it('reports running state', async () => { + const lifecycle = new Lifecycle(); + + expect(lifecycle.isRunning).toBe(true); + + await lifecycle.shutdown(); + + expect(lifecycle.isRunning).toBe(false); + }); +}); diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts new file mode 100644 index 0000000..5d188b6 --- /dev/null +++ b/src/daemon/lifecycle.ts @@ -0,0 +1,34 @@ +type ShutdownHandler = () => Promise; + +export class Lifecycle { + private shutdownHandlers: ShutdownHandler[] = []; + private shuttingDown = false; + private _isRunning = true; + + get isRunning(): boolean { + return this._isRunning; + } + + onShutdown(handler: ShutdownHandler): void { + this.shutdownHandlers.push(handler); + } + + async shutdown(): Promise { + if (this.shuttingDown) return; + this.shuttingDown = true; + this._isRunning = false; + + console.log('Shutting down...'); + + // Execute handlers in reverse order (LIFO) + for (const handler of [...this.shutdownHandlers].reverse()) { + try { + await handler(); + } catch (error) { + console.error('Shutdown handler error:', error); + } + } + + console.log('Shutdown complete'); + } +} diff --git a/src/index.ts b/src/index.ts index b9bbcef..91df62e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,30 @@ -console.log('Flynn starting...'); +import { loadConfig } from './config/index.js'; +import { startDaemon } from './daemon/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +const CONFIG_PATH = process.env.FLYNN_CONFIG + ?? resolve(homedir(), '.config/flynn/config.yaml'); + +async function main() { + console.log('Flynn starting...'); + console.log(`Loading config from: ${CONFIG_PATH}`); + + try { + const config = loadConfig(CONFIG_PATH); + const daemon = await startDaemon(config); + + console.log(`Telegram bot configured for chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`); + console.log(`Server port: ${config.server.port}`); + + // Keep process alive + await new Promise((resolve) => { + daemon.lifecycle.onShutdown(async () => resolve()); + }); + } catch (error) { + console.error('Failed to start Flynn:', error); + process.exit(1); + } +} + +main();