/** * 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; } }