diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 286f787a..223ae502 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir } from '../cliproxy/config-generator'; -import { getNativeLogsDir } from '../services/logging'; +import { getLogArchiveDir, getNativeLogsDir } from '../services/logging'; import { info, ok, warn } from '../utils/ui'; /** Default age in days for error log cleanup */ @@ -24,6 +24,10 @@ function getCcsLogsDir(): string { return getNativeLogsDir(); } +function getCcsLogArchiveDir(): string { + return getLogArchiveDir(); +} + /** Format bytes to human-readable size */ function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; @@ -210,6 +214,7 @@ export async function handleCleanupCommand(args: string[]): Promise { const cleanErrors = args.includes('--errors'); const logsDir = getLogsDir(); const ccsLogsDir = getCcsLogsDir(); + const ccsArchiveDir = getCcsLogArchiveDir(); // Parse --days=N option let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS; @@ -227,7 +232,13 @@ export async function handleCleanupCommand(args: string[]): Promise { if (cleanErrors) { await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force); } else { - await handleMainLogCleanup({ cliproxyLogsDir: logsDir, ccsLogsDir, dryRun, force }); + await handleMainLogCleanup({ + cliproxyLogsDir: logsDir, + ccsLogsDir, + ccsArchiveDir, + dryRun, + force, + }); } } @@ -325,11 +336,13 @@ async function handleErrorLogCleanup( async function handleMainLogCleanup(options: { cliproxyLogsDir: string; ccsLogsDir: string; + ccsArchiveDir: string; dryRun: boolean; force: boolean; }): Promise { const targets = [ { label: 'CCS Logs', dir: options.ccsLogsDir }, + { label: 'CCS Log Archives', dir: options.ccsArchiveDir }, { label: 'CLIProxy Logs', dir: options.cliproxyLogsDir }, ].map((target) => ({ ...target, diff --git a/src/services/logging/log-paths.ts b/src/services/logging/log-paths.ts index 3bc30c57..258e256d 100644 --- a/src/services/logging/log-paths.ts +++ b/src/services/logging/log-paths.ts @@ -23,8 +23,8 @@ export function getLegacyCliproxyLogsDir(): string { } export function ensureLoggingDirectories(): void { - fs.mkdirSync(getNativeLogsDir(), { recursive: true }); - fs.mkdirSync(getLogArchiveDir(), { recursive: true }); + fs.mkdirSync(getNativeLogsDir(), { recursive: true, mode: 0o700 }); + fs.mkdirSync(getLogArchiveDir(), { recursive: true, mode: 0o700 }); } export function isPathInsideDirectory(candidatePath: string, rootDir: string): boolean { diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts index bed8f528..1c560762 100644 --- a/tests/unit/commands/cleanup-command.test.ts +++ b/tests/unit/commands/cleanup-command.test.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { handleCleanupCommand } from '../../../src/commands/cleanup-command'; import { getCliproxyDir } from '../../../src/cliproxy/config-generator'; -import { getNativeLogsDir } from '../../../src/services/logging'; +import { getLogArchiveDir, getNativeLogsDir } from '../../../src/services/logging'; describe('cleanup command', () => { let tempHome = ''; @@ -27,9 +27,9 @@ describe('cleanup command', () => { tempHome = ''; }); - it('reports only top-level log sizes in dry-run mode', async () => { + it('reports CCS archives alongside current logs in dry-run mode', async () => { const ccsLogsDir = getNativeLogsDir(); - const archiveDir = path.join(ccsLogsDir, 'archive'); + const archiveDir = getLogArchiveDir(); const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs'); fs.mkdirSync(archiveDir, { recursive: true }); @@ -47,8 +47,8 @@ describe('cleanup command', () => { .join('\n'); expect(output).toContain('CCS Logs: 1 files (100.00 B)'); - expect(output).toContain('Would delete 1 files (100.00 B)'); - expect(output).not.toContain('1.95 KB'); + expect(output).toContain('CCS Log Archives: 1 files (1.95 KB)'); + expect(output).toContain('Would delete 2 files (2.05 KB)'); } finally { logSpy.mockRestore(); } diff --git a/tests/unit/services/logging/log-paths.test.ts b/tests/unit/services/logging/log-paths.test.ts index 4e1ce391..41ef7a84 100644 --- a/tests/unit/services/logging/log-paths.test.ts +++ b/tests/unit/services/logging/log-paths.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + ensureLoggingDirectories, getCurrentLogPath, getLogArchiveDir, getNativeLogsDir, @@ -43,4 +44,14 @@ describe('logging path helpers', () => { false ); }); + + it('creates log directories with restrictive permissions', () => { + ensureLoggingDirectories(); + + const logsMode = fs.statSync(getNativeLogsDir()).mode & 0o777; + const archiveMode = fs.statSync(getLogArchiveDir()).mode & 0o777; + + expect(logsMode).toBe(0o700); + expect(archiveMode).toBe(0o700); + }); }); diff --git a/tests/unit/services/logging/log-storage.test.ts b/tests/unit/services/logging/log-storage.test.ts new file mode 100644 index 00000000..23decb23 --- /dev/null +++ b/tests/unit/services/logging/log-storage.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types'; +import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader'; +import { clearRecentLogEntries } from '../../../../src/services/logging/log-buffer'; +import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config'; +import { getCurrentLogPath, getLogArchiveDir } from '../../../../src/services/logging/log-paths'; +import { + appendStructuredLogEntry, + pruneExpiredLogArchives, +} from '../../../../src/services/logging/log-storage'; +import type { LogEntry } from '../../../../src/services/logging/log-types'; + +function createEntry(overrides: Partial): LogEntry { + return { + id: overrides.id ?? 'entry-1', + timestamp: overrides.timestamp ?? new Date().toISOString(), + level: overrides.level ?? 'info', + source: overrides.source ?? 'unit:test', + event: overrides.event ?? 'test.event', + message: overrides.message ?? 'message', + processId: overrides.processId ?? 1234, + runId: overrides.runId ?? 'run-1', + context: overrides.context ?? {}, + }; +} + +describe('log storage', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-storage-')); + process.env.CCS_HOME = tempHome; + clearRecentLogEntries(); + invalidateLoggingConfigCache(); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + clearRecentLogEntries(); + invalidateLoggingConfigCache(); + fs.rmSync(tempHome, { recursive: true, force: true }); + tempHome = ''; + }); + + it('rotates the current log into the archive when the file exceeds the age threshold', () => { + const config = createEmptyUnifiedConfig(); + config.logging.retain_days = 7; + config.logging.rotate_mb = 10; + saveUnifiedConfig(config); + invalidateLoggingConfigCache(); + + const currentLogPath = getCurrentLogPath(); + fs.mkdirSync(path.dirname(currentLogPath), { recursive: true }); + fs.writeFileSync( + currentLogPath, + `${JSON.stringify(createEntry({ id: 'old-entry' }))}\n`, + 'utf8' + ); + const staleTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + fs.utimesSync(currentLogPath, staleTimestamp, staleTimestamp); + + appendStructuredLogEntry( + createEntry({ + id: 'new-entry', + message: 'new log entry after rotation', + }) + ); + + const archiveDir = getLogArchiveDir(); + const archives = fs.readdirSync(archiveDir); + expect(archives).toHaveLength(1); + + const archivedContent = zlib + .gunzipSync(fs.readFileSync(path.join(archiveDir, archives[0]))) + .toString('utf8'); + expect(archivedContent).toContain('"id":"old-entry"'); + + const currentContent = fs.readFileSync(currentLogPath, 'utf8'); + expect(currentContent).toContain('"id":"new-entry"'); + expect(currentContent).not.toContain('"id":"old-entry"'); + }); + + it('prunes expired archives according to retention settings', () => { + const config = createEmptyUnifiedConfig(); + config.logging.retain_days = 1; + saveUnifiedConfig(config); + invalidateLoggingConfigCache(); + + const archiveDir = getLogArchiveDir(); + fs.mkdirSync(archiveDir, { recursive: true }); + + const oldArchive = path.join(archiveDir, 'ccs-old.jsonl.gz'); + const freshArchive = path.join(archiveDir, 'ccs-fresh.jsonl.gz'); + fs.writeFileSync(oldArchive, zlib.gzipSync('old archive'), { mode: 0o600 }); + fs.writeFileSync(freshArchive, zlib.gzipSync('fresh archive'), { mode: 0o600 }); + + const oldTimestamp = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + const freshTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(oldArchive, oldTimestamp, oldTimestamp); + fs.utimesSync(freshArchive, freshTimestamp, freshTimestamp); + + pruneExpiredLogArchives(); + + expect(fs.existsSync(oldArchive)).toBe(false); + expect(fs.existsSync(freshArchive)).toBe(true); + }); +});