From 2a0d63accd96d707e0abd62aec0d59ef198fa660 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 13:52:58 -0600 Subject: [PATCH] =?UTF-8?q?test:=20Built=20Daemon=20class=20with=20lifecyc?= =?UTF-8?q?le=20management,=20CLI=20entry=20point=20wit=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "packages/daemon/src/daemon.ts" - "packages/daemon/src/cli.ts" - "packages/daemon/src/daemon.test.ts" - "packages/daemon/src/index.ts" GSD-Task: S01/T03 --- packages/daemon/src/cli.ts | 47 +++++++ packages/daemon/src/daemon.test.ts | 201 ++++++++++++++++++++++++++++- packages/daemon/src/daemon.ts | 58 +++++++++ packages/daemon/src/index.ts | 4 + 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 packages/daemon/src/cli.ts create mode 100644 packages/daemon/src/daemon.ts diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts new file mode 100644 index 000000000..8a4787b05 --- /dev/null +++ b/packages/daemon/src/cli.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; +import { resolveConfigPath, loadConfig } from './config.js'; +import { Logger } from './logger.js'; +import { Daemon } from './daemon.js'; + +const USAGE = `Usage: gsd-daemon [options] + +Options: + --config Path to YAML config file (default: ~/.gsd/daemon.yaml) + --verbose Print log entries to stderr in addition to the log file + --help Show this help message and exit +`; + +async function main(): Promise { + const { values } = parseArgs({ + options: { + config: { type: 'string', short: 'c' }, + verbose: { type: 'boolean', short: 'v', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + strict: true, + }); + + if (values.help) { + process.stdout.write(USAGE); + process.exit(0); + } + + const configPath = resolveConfigPath(values.config); + const config = loadConfig(configPath); + + const logger = new Logger({ + filePath: config.log.file, + level: config.log.level, + verbose: values.verbose, + }); + + const daemon = new Daemon(config, logger); + await daemon.start(); +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`gsd-daemon: fatal: ${msg}\n`); + process.exit(1); +}); diff --git a/packages/daemon/src/daemon.test.ts b/packages/daemon/src/daemon.test.ts index b428e66bf..6eb8c756f 100644 --- a/packages/daemon/src/daemon.test.ts +++ b/packages/daemon/src/daemon.test.ts @@ -4,9 +4,13 @@ import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'no import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { randomUUID } from 'node:crypto'; +import { execFileSync, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; import { resolveConfigPath, loadConfig, validateConfig } from './config.js'; import { Logger } from './logger.js'; -import type { LogEntry } from './types.js'; +import { Daemon } from './daemon.js'; +import type { DaemonConfig, LogEntry } from './types.js'; // ---------- helpers ---------- @@ -322,3 +326,198 @@ describe('token safety', () => { } }); }); + +// ---------- daemon lifecycle ---------- + +// Resolve the dist/ directory for spawning CLI +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('Daemon', () => { + it('logs lifecycle events on start and shutdown', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'daemon-lifecycle.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: ['/a', '/b'] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + + // start() should have logged 'daemon started' + // shutdown() directly — we override process.exit to prevent test runner from dying + const origExit = process.exit; + let exitCode: number | undefined; + // @ts-expect-error — overriding process.exit for test + process.exit = (code?: number) => { exitCode = code ?? 0; }; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + + assert.equal(exitCode, 0); + + const content = readFileSync(logPath, 'utf-8'); + const lines = content.trim().split('\n'); + + // First line: daemon started + const startEntry: LogEntry = JSON.parse(lines[0]!); + assert.equal(startEntry.msg, 'daemon started'); + assert.equal(startEntry.data?.scan_roots, 2); + assert.equal(startEntry.data?.discord_configured, false); + + // Second line: daemon shutting down + const stopEntry: LogEntry = JSON.parse(lines[1]!); + assert.equal(stopEntry.msg, 'daemon shutting down'); + }); + + it('shutdown is idempotent — second call is a no-op', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'idempotent.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + + const origExit = process.exit; + let exitCount = 0; + // @ts-expect-error — overriding process.exit for test + process.exit = () => { exitCount++; }; + try { + await daemon.shutdown(); + await daemon.shutdown(); // second call — should be no-op + } finally { + process.exit = origExit; + } + + assert.equal(exitCount, 1, 'process.exit should be called exactly once'); + + const lines = readFileSync(logPath, 'utf-8').trim().split('\n'); + const shutdownLines = lines.filter(l => { + const e: LogEntry = JSON.parse(l); + return e.msg === 'daemon shutting down'; + }); + assert.equal(shutdownLines.length, 1, 'shutdown log should appear exactly once'); + }); +}); + +// ---------- CLI integration ---------- + +describe('CLI integration', () => { + it('--help prints usage and exits 0', () => { + const result = execFileSync( + process.execPath, + [join(__dirname, 'cli.js'), '--help'], + { encoding: 'utf-8', timeout: 5000 }, + ); + assert.ok(result.includes('Usage: gsd-daemon')); + assert.ok(result.includes('--config')); + assert.ok(result.includes('--verbose')); + }); + + it('starts, logs to file, and exits cleanly on SIGTERM', { timeout: 15000 }, async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'integration.log'); + const configPath = join(dir, 'daemon.yaml'); + + writeFileSync(configPath, ` +projects: + scan_roots: + - /tmp/test-project +log: + file: "${logPath}" + level: info + max_size_mb: 10 +`); + + // Use execFile with a wrapper script approach: spawn, wait for start, SIGTERM, verify + const exitCode = await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [join(__dirname, 'cli.js'), '--config', configPath], + { stdio: 'ignore' }, + ); + + let resolved = false; + child.on('error', (err) => { if (!resolved) { resolved = true; reject(err); } }); + child.on('exit', (code) => { if (!resolved) { resolved = true; resolve(code ?? 1); } }); + + // Poll for startup, then send SIGTERM + const poll = setInterval(() => { + if (existsSync(logPath)) { + const content = readFileSync(logPath, 'utf-8'); + if (content.includes('daemon started')) { + clearInterval(poll); + child.kill('SIGTERM'); + } + } + }, 100); + + // Safety: kill child if it takes too long + setTimeout(() => { + clearInterval(poll); + if (!resolved) { + child.kill('SIGKILL'); + resolved = true; + reject(new Error('timed out waiting for daemon')); + } + }, 10000); + }); + + assert.equal(exitCode, 0, 'daemon should exit with code 0 on SIGTERM'); + + // Small delay for filesystem flush + await new Promise(r => setTimeout(r, 100)); + + // Verify log file contents + const finalContent = readFileSync(logPath, 'utf-8'); + assert.ok(finalContent.includes('daemon started'), 'log should contain startup entry'); + assert.ok(finalContent.includes('daemon shutting down'), 'log should contain shutdown entry'); + + // Verify log entries are valid JSON-lines + const lines = finalContent.trim().split('\n'); + for (const line of lines) { + const entry: LogEntry = JSON.parse(line); + assert.ok(entry.ts, 'each entry should have a timestamp'); + assert.ok(entry.level, 'each entry should have a level'); + assert.ok(entry.msg, 'each entry should have a message'); + } + }); + + it('exits with code 1 on invalid config', () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const configPath = join(dir, 'bad.yaml'); + writeFileSync(configPath, ':\n :\n bad: [unclosed'); + + try { + execFileSync( + process.execPath, + [join(__dirname, 'cli.js'), '--config', configPath], + { encoding: 'utf-8', timeout: 5000 }, + ); + assert.fail('should have thrown'); + } catch (err: unknown) { + // execFileSync throws on non-zero exit + const execErr = err as { status: number; stderr: string }; + assert.equal(execErr.status, 1); + assert.ok(execErr.stderr.includes('fatal')); + } + }); +}); diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts new file mode 100644 index 000000000..b3188d936 --- /dev/null +++ b/packages/daemon/src/daemon.ts @@ -0,0 +1,58 @@ +import type { DaemonConfig } from './types.js'; +import type { Logger } from './logger.js'; + +/** + * Core daemon class — ties config + logger together with lifecycle management. + * Registers SIGTERM/SIGINT handlers for clean shutdown. + */ +export class Daemon { + private shuttingDown = false; + private keepaliveTimer: ReturnType | undefined; + private readonly onSigterm: () => void; + private readonly onSigint: () => void; + + constructor( + private readonly config: DaemonConfig, + private readonly logger: Logger, + ) { + this.onSigterm = () => void this.shutdown(); + this.onSigint = () => void this.shutdown(); + } + + /** Start the daemon: log startup info, register signal handlers, start keepalive. */ + async start(): Promise { + this.logger.info('daemon started', { + log_level: this.config.log.level, + scan_roots: this.config.projects.scan_roots.length, + discord_configured: !!this.config.discord, + }); + + process.on('SIGTERM', this.onSigterm); + process.on('SIGINT', this.onSigint); + + // Keep the event loop alive. The write stream alone doesn't hold a ref + // when there's no pending I/O, so we need an explicit timer. + this.keepaliveTimer = setInterval(() => {}, 60_000); + } + + /** Idempotent shutdown: log, close logger, exit. */ + async shutdown(): Promise { + if (this.shuttingDown) return; + this.shuttingDown = true; + + this.logger.info('daemon shutting down'); + + // Remove signal handlers to avoid double-fire + process.removeListener('SIGTERM', this.onSigterm); + process.removeListener('SIGINT', this.onSigint); + + // Clear keepalive so the event loop can drain + if (this.keepaliveTimer) { + clearInterval(this.keepaliveTimer); + this.keepaliveTimer = undefined; + } + + await this.logger.close(); + process.exit(0); + } +} diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 91dd2cc2c..114e9163d 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -1 +1,5 @@ export type { DaemonConfig, LogLevel, LogEntry } from './types.js'; +export { resolveConfigPath, loadConfig, validateConfig } from './config.js'; +export { Logger } from './logger.js'; +export type { LoggerOptions } from './logger.js'; +export { Daemon } from './daemon.js';