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
This commit is contained in:
123
src/logging/manager.ts
Normal file
123
src/logging/manager.ts
Normal file
@@ -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<LogConfig>) {
|
||||
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<void> {
|
||||
await mkdir(this.baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the process-specific log directory exists.
|
||||
* @param processId - The process identifier
|
||||
*/
|
||||
async ensureProcessDir(processId: string): Promise<void> {
|
||||
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<string[]> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
41
src/logging/types.ts
Normal file
41
src/logging/types.ts
Normal file
@@ -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';
|
||||
Reference in New Issue
Block a user