From e64e243407c95e93a34d86bd954f90a33269436b Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 13:12:55 +0100 Subject: [PATCH] feat(01-04): create log directory management - LogLevel, LogEntry, LogConfig types in types.ts - LogManager class with directory management - Cross-platform paths using node:os and node:path - ensureLogDir/ensureProcessDir for directory creation - getLogPath returns ~/.cw/logs/{processId}/{stream}.log - cleanOldLogs removes directories older than N days - listLogs enumerates all process log directories --- src/logging/manager.ts | 123 +++++++++++++++++++++++++++++++++++++++++ src/logging/types.ts | 41 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/logging/manager.ts create mode 100644 src/logging/types.ts diff --git a/src/logging/manager.ts b/src/logging/manager.ts new file mode 100644 index 0000000..d4eb0eb --- /dev/null +++ b/src/logging/manager.ts @@ -0,0 +1,123 @@ +/** + * Log Manager + * + * Manages log directories and file paths for per-process logging. + */ + +import { mkdir, readdir, rm, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { LogConfig, LogStream } from './types.js'; + +/** + * Default base directory for logs: ~/.cw/logs + */ +const DEFAULT_LOG_DIR = join(homedir(), '.cw', 'logs'); + +/** + * Manages log directory structure and file paths. + * + * Log directory structure: + * ~/.cw/logs/{processId}/stdout.log + * ~/.cw/logs/{processId}/stderr.log + */ +export class LogManager { + private readonly baseDir: string; + private readonly retainDays?: number; + + constructor(config?: Partial) { + this.baseDir = config?.baseDir ?? DEFAULT_LOG_DIR; + this.retainDays = config?.retainDays; + } + + /** + * Ensures the base log directory exists. + * Creates it recursively if it doesn't exist. + */ + async ensureLogDir(): Promise { + await mkdir(this.baseDir, { recursive: true }); + } + + /** + * Ensures the process-specific log directory exists. + * @param processId - The process identifier + */ + async ensureProcessDir(processId: string): Promise { + const processDir = this.getProcessDir(processId); + await mkdir(processDir, { recursive: true }); + } + + /** + * Gets the directory path for a specific process's logs. + * @param processId - The process identifier + */ + getProcessDir(processId: string): string { + return join(this.baseDir, processId); + } + + /** + * Gets the full path to a log file for a process and stream. + * @param processId - The process identifier + * @param stream - Either 'stdout' or 'stderr' + */ + getLogPath(processId: string, stream: LogStream): string { + return join(this.baseDir, processId, `${stream}.log`); + } + + /** + * Lists all log directories (one per process). + * @returns Array of process IDs that have log directories + */ + async listLogs(): Promise { + try { + const entries = await readdir(this.baseDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch (error) { + // If directory doesn't exist, return empty list + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + } + + /** + * Removes log directories older than the specified number of days. + * @param retainDays - Number of days to retain logs (uses config value if not provided) + * @returns Number of directories removed + */ + async cleanOldLogs(retainDays?: number): Promise { + const days = retainDays ?? this.retainDays; + if (days === undefined) { + return 0; + } + + const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; + const processIds = await this.listLogs(); + let removedCount = 0; + + for (const processId of processIds) { + const processDir = this.getProcessDir(processId); + try { + const stats = await stat(processDir); + if (stats.mtime.getTime() < cutoffTime) { + await rm(processDir, { recursive: true, force: true }); + removedCount++; + } + } catch { + // Skip if we can't stat or remove the directory + } + } + + return removedCount; + } + + /** + * Gets the base directory path. + */ + getBaseDir(): string { + return this.baseDir; + } +} diff --git a/src/logging/types.ts b/src/logging/types.ts new file mode 100644 index 0000000..b15bfeb --- /dev/null +++ b/src/logging/types.ts @@ -0,0 +1,41 @@ +/** + * Logging Types + * + * Type definitions for the file-based logging infrastructure. + */ + +/** + * Log severity levels. + */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +/** + * A single log entry with metadata. + */ +export interface LogEntry { + /** When this entry was logged */ + timestamp: Date; + /** Severity level */ + level: LogLevel; + /** ID of the process that produced this log */ + processId: string; + /** The log message content */ + message: string; +} + +/** + * Configuration for the logging system. + */ +export interface LogConfig { + /** Base directory for log files. Defaults to ~/.cw/logs */ + baseDir: string; + /** Maximum size per log file in bytes before rotation. Optional. */ + maxFileSize?: number; + /** Number of days to retain logs. Optional. */ + retainDays?: number; +} + +/** + * Stream type for log output. + */ +export type LogStream = 'stdout' | 'stderr';