Skip to content

Commit e671f8d

Browse files
CoderCococlaude
andauthored
feat(desktop-main): rotate Winston logs in userData with daily rotation (#226)
Closes #149 ## Summary - Installs `winston-daily-rotate-file ^5.0.0` in `@hyveon/desktop-main` and refactors `logger.ts` to a `createLogger(logDir)` factory that adds a `DailyRotateFile` transport writing `main-YYYY-MM-DD.log` under a caller-supplied directory (14-day retention, daily rotation at midnight) - `main.ts` resolves `app.getPath('userData')` from Electron and calls `createLogger(path.join(userData, 'logs'))` before `bootstrap()`, ensuring log files land in the correct OS-specific user data directory on every platform - Adds `DiagnosticsService` (reads last N lines from today's log file, ENOENT-safe) and `DiagnosticsController` (`GET /api/diagnostics/tail`, `GET /api/diagnostics/path`) wired into `AppModule` with a `DIAGNOSTICS_LOG_DIR` injection token - Adds `DiagnosticsPanel` React component that fetches `api.diagnosticsTail()` on mount, polls every 5 seconds, autoscrolls to the bottom, and surfaces loading/error states; mounted in a new "Diagnostics" section on `SettingsPage` ## Implementation notes - The `logger.ts` singleton is exported as `export let logger` (ESM live binding) so existing import sites (`LogsService`, etc.) receive the updated instance after `main.ts` calls `createLogger` — no refactor of call sites needed - `winston-daily-rotate-file` v5.0.0 is CJS but importable from ESM hosts in Node.js; v5 also ships bundled TypeScript declarations, so no `@types/` package is required - The `DIAGNOSTICS_LOG_DIR` AppModule provider uses `useFactory: () => path.join(electronApp.getPath('userData'), 'logs')` — this call is safe because the factory only runs during Nest bootstrap, which `main.ts` defers until after `createLogger` has already resolved the path - The preload extension (`window.gsd.diagnostics`) was intentionally omitted — the web layer uses the same HTTP `GET /api/*` pattern as every other feature; no IPC bridge is needed - `diagnosticsLogPath` is fetched once on mount only (stable across the component lifetime); `diagnosticsTail` is re-fetched every 5 seconds ## Test plan - [ ] `npm run app:test` — 525 tests pass (50 test files, 0 failures) - [ ] `npm run app:lint` — 0 errors - [ ] On macOS/Linux: packaged app writes `~/Library/Application Support/<app>/logs/main-YYYY-MM-DD.log` (macOS) or `~/.config/<app>/logs/...` (Linux) - [ ] Settings → Diagnostics page shows last log lines and auto-refreshes every 5 seconds --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8243c5c commit e671f8d

16 files changed

Lines changed: 756 additions & 25 deletions

app/packages/desktop-main/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"nestjs-electron-ipc-transport": "^1.0.2",
2828
"reflect-metadata": "^0.2.2",
2929
"rxjs": "^7.8.1",
30-
"winston": "^3.13.0"
30+
"winston": "^3.13.0",
31+
"winston-daily-rotate-file": "^5.0.0"
3132
},
3233
"devDependencies": {
3334
"@types/express": "^4.17.21",

app/packages/desktop-main/src/app.module.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import * as os from 'node:os';
2+
import * as path from 'node:path';
3+
import { createRequire } from 'module';
14
import { Module } from '@nestjs/common';
25
import { APP_GUARD } from '@nestjs/core';
36
import { AwsModule } from './modules/aws.module.js';
@@ -9,7 +12,9 @@ import { LogsController } from './controllers/logs.controller.js';
912
import { FilesController } from './controllers/files.controller.js';
1013
import { DiscordController } from './controllers/discord.controller.js';
1114
import { EnvController } from './controllers/env.controller.js';
15+
import { DiagnosticsController } from './controllers/diagnostics.controller.js';
1216
import { ApiTokenGuard } from './guards/api-token.guard.js';
17+
import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsService.js';
1318

1419
/**
1520
* Root Nest module. Wires the feature modules (`AwsModule`, `DiscordModule`) to
@@ -29,7 +34,22 @@ import { ApiTokenGuard } from './guards/api-token.guard.js';
2934
FilesController,
3035
DiscordController,
3136
EnvController,
37+
DiagnosticsController,
38+
],
39+
providers: [
40+
{ provide: APP_GUARD, useClass: ApiTokenGuard },
41+
{
42+
provide: DIAGNOSTICS_LOG_DIR,
43+
useFactory: () => {
44+
if (!process.versions['electron']) {
45+
return process.env['DIAGNOSTICS_LOG_DIR'] ?? os.tmpdir();
46+
}
47+
const _require = createRequire(import.meta.url);
48+
const { app } = _require('electron') as { app: { getPath(name: string): string } };
49+
return path.join(app.getPath('userData'), 'logs');
50+
},
51+
},
52+
DiagnosticsService,
3253
],
33-
providers: [{ provide: APP_GUARD, useClass: ApiTokenGuard }],
3454
})
3555
export class AppModule {}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import 'reflect-metadata';
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { DiagnosticsController } from './diagnostics.controller.js';
4+
import type { DiagnosticsService } from '../services/DiagnosticsService.js';
5+
6+
vi.mock('../logger.js', () => ({
7+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
8+
}));
9+
10+
/** Build a DiagnosticsService stub. */
11+
function makeDiagnostics(): DiagnosticsService {
12+
return {
13+
readTail: vi.fn().mockResolvedValue(['line1', 'line2', 'line3']),
14+
getTodayLogPath: vi.fn().mockReturnValue('/var/log/app/main-2026-05-23.log'),
15+
} as unknown as DiagnosticsService;
16+
}
17+
18+
describe('DiagnosticsController', () => {
19+
describe('getTail', () => {
20+
it('should return lines from DiagnosticsService', async () => {
21+
const svc = makeDiagnostics();
22+
const result = await new DiagnosticsController(svc).getTail();
23+
expect(result).toEqual({ lines: ['line1', 'line2', 'line3'] });
24+
});
25+
26+
it('should call DiagnosticsService.readTail with 500 lines', async () => {
27+
const svc = makeDiagnostics();
28+
await new DiagnosticsController(svc).getTail();
29+
expect(svc.readTail).toHaveBeenCalledWith(500);
30+
});
31+
});
32+
33+
describe('getPath', () => {
34+
it('should return the current log path from DiagnosticsService', () => {
35+
const svc = makeDiagnostics();
36+
const result = new DiagnosticsController(svc).getPath();
37+
expect(result).toEqual({ path: '/var/log/app/main-2026-05-23.log' });
38+
});
39+
40+
it('should delegate to DiagnosticsService.getTodayLogPath', () => {
41+
const svc = makeDiagnostics();
42+
new DiagnosticsController(svc).getPath();
43+
expect(svc.getTodayLogPath).toHaveBeenCalled();
44+
});
45+
});
46+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { DiagnosticsService } from '../services/DiagnosticsService.js';
3+
4+
/** Exposes local application log data for operator diagnostics. */
5+
@Controller('diagnostics')
6+
export class DiagnosticsController {
7+
constructor(private readonly diagnostics: DiagnosticsService) {}
8+
9+
/** Returns the last 500 lines from today's local log file. */
10+
@Get('tail')
11+
async getTail(): Promise<{ lines: string[] }> {
12+
const lines = await this.diagnostics.readTail(500);
13+
return { lines };
14+
}
15+
16+
/** Returns the absolute path of today's local log file. */
17+
@Get('path')
18+
getPath(): { path: string } {
19+
return { path: this.diagnostics.getTodayLogPath() };
20+
}
21+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, it, expect } from 'vitest';
2+
import winston from 'winston';
3+
import DailyRotateFile from 'winston-daily-rotate-file';
4+
5+
/**
6+
* Re-import logger module fresh for each test that cares about the singleton
7+
* state. We use a plain import at the top for the factory tests; the
8+
* singleton re-assignment tests use the same module reference.
9+
*/
10+
import { createLogger, logger as initialLogger } from './logger.js';
11+
12+
describe('createLogger', () => {
13+
it('should return a winston.Logger instance', () => {
14+
const result = createLogger('/tmp/test-logs');
15+
// Winston's createLogger returns a DerivedLogger that extends EventEmitter.
16+
// Check the duck-typed API surface rather than constructor identity, which
17+
// can differ when the same package is loaded from two module cache entries.
18+
expect(typeof result.info).toBe('function');
19+
expect(typeof result.error).toBe('function');
20+
expect(typeof result.debug).toBe('function');
21+
expect(Array.isArray(result.transports)).toBe(true);
22+
});
23+
24+
it('should include a DailyRotateFile transport in the transports array', () => {
25+
const result = createLogger('/tmp/test-logs');
26+
const hasRotate = result.transports.some((t) => t instanceof DailyRotateFile);
27+
expect(hasRotate).toBe(true);
28+
});
29+
30+
it('should include a Console transport in the transports array', () => {
31+
const result = createLogger('/tmp/test-logs');
32+
const hasConsole = result.transports.some(
33+
(t) => t instanceof winston.transports.Console,
34+
);
35+
expect(hasConsole).toBe(true);
36+
});
37+
38+
it('should configure the DailyRotateFile transport with the provided logDir', () => {
39+
const logDir = '/tmp/my-custom-log-dir';
40+
const result = createLogger(logDir);
41+
const rotateTransport = result.transports.find(
42+
(t) => t instanceof DailyRotateFile,
43+
) as InstanceType<typeof DailyRotateFile> | undefined;
44+
45+
expect(rotateTransport).toBeDefined();
46+
// The `dirname` option is stored as `options.dirname` on the transport.
47+
const opts = (rotateTransport as unknown as { options: { dirname: string } })
48+
.options;
49+
expect(opts.dirname).toBe(logDir);
50+
});
51+
});
52+
53+
describe('logger singleton', () => {
54+
it('should reassign the exported logger binding when createLogger is called', async () => {
55+
// Capture the initial (console-only) reference.
56+
const beforeCall = initialLogger;
57+
58+
// createLogger mutates the exported binding.
59+
const returned = createLogger('/tmp/test-logs-singleton');
60+
61+
// The returned value and the freshly-imported binding must be the same object.
62+
// We re-import via the same module to read the live binding.
63+
const { logger: afterCall } = await import('./logger.js');
64+
expect(afterCall).toBe(returned);
65+
// And it must differ from the pre-call fallback logger.
66+
expect(afterCall).not.toBe(beforeCall);
67+
});
68+
});
Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,72 @@
11
import winston from 'winston';
2+
import DailyRotateFile from 'winston-daily-rotate-file';
23

34
const isDev = process.env['NODE_ENV'] !== 'production';
45

5-
export const logger = winston.createLogger({
6-
level: isDev ? 'debug' : 'info',
7-
format: isDev
8-
? winston.format.combine(
9-
winston.format.colorize(),
10-
winston.format.timestamp({ format: 'HH:mm:ss' }),
11-
winston.format.printf((info) => {
12-
const { timestamp, level, message, ...meta } = info as Record<string, unknown>;
13-
const metaStr = Object.keys(meta).length
14-
? '\n' + JSON.stringify(meta, null, 2)
15-
: '';
16-
return `${timestamp} [${level}] ${message}${metaStr}`;
17-
}),
18-
)
19-
: winston.format.combine(
20-
winston.format.timestamp(),
21-
winston.format.json(),
22-
),
23-
transports: [new winston.transports.Console()],
6+
const devPrintf = winston.format.printf((info) => {
7+
const { timestamp, level, message, ...meta } = info as Record<string, unknown>;
8+
const metaStr = Object.keys(meta).length ? '\n' + JSON.stringify(meta, null, 2) : '';
9+
return `${timestamp} [${level}] ${message}${metaStr}`;
2410
});
11+
12+
/** Format for the Console transport — colorized in dev for readability. */
13+
const consoleFormat = isDev
14+
? winston.format.combine(
15+
winston.format.colorize(),
16+
winston.format.timestamp({ format: 'HH:mm:ss' }),
17+
devPrintf,
18+
)
19+
: winston.format.combine(winston.format.timestamp(), winston.format.json());
20+
21+
/** Format for file transports — no ANSI escape codes, safe for log parsers. */
22+
const fileFormat = isDev
23+
? winston.format.combine(winston.format.timestamp({ format: 'HH:mm:ss' }), devPrintf)
24+
: winston.format.combine(winston.format.timestamp(), winston.format.json());
25+
26+
/**
27+
* Creates a console-only fallback logger used as the initial singleton value
28+
* before {@link createLogger} is called by main.ts.
29+
*/
30+
function createConsoleOnlyLogger(): winston.Logger {
31+
return winston.createLogger({
32+
level: isDev ? 'debug' : 'info',
33+
format: consoleFormat,
34+
transports: [new winston.transports.Console()],
35+
});
36+
}
37+
38+
/**
39+
* Module-level logger singleton. Initially console-only; reassigned (as a
40+
* live ESM binding) when {@link createLogger} is called from main.ts so that
41+
* all existing importers automatically see the upgraded instance.
42+
*/
43+
export let logger: winston.Logger = createConsoleOnlyLogger();
44+
45+
/**
46+
* Creates a full winston logger with both a Console transport and a
47+
* DailyRotateFile transport that writes to `logDir`. Also reassigns the
48+
* exported {@link logger} singleton so that modules that imported it before
49+
* `main.ts` started still get the upgraded instance.
50+
*
51+
* @param logDir - Directory in which daily log files will be written.
52+
* @returns The newly created {@link winston.Logger} instance.
53+
*/
54+
export function createLogger(logDir: string): winston.Logger {
55+
const newLogger = winston.createLogger({
56+
level: isDev ? 'debug' : 'info',
57+
transports: [
58+
new winston.transports.Console({ format: consoleFormat }),
59+
new DailyRotateFile({
60+
format: fileFormat,
61+
dirname: logDir,
62+
filename: 'main-%DATE%.log',
63+
datePattern: 'YYYY-MM-DD',
64+
maxFiles: '14d',
65+
}),
66+
],
67+
});
68+
69+
// Reassign the live ESM binding so importers see the upgraded instance.
70+
logger = newLogger;
71+
return newLogger;
72+
}

app/packages/desktop-main/src/main.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
66
* vi.mock() factory functions run (vi.mock calls are hoisted to the top of the
77
* compiled output, above regular const/let declarations).
88
*/
9-
const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock } = vi.hoisted(() => {
9+
const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock, mockApp, createLoggerMock } = vi.hoisted(() => {
1010
/** Fake NestJS microservice app returned by `NestFactory.createMicroservice`. */
1111
const fakeApp = { listen: vi.fn().mockResolvedValue(undefined) };
1212
/** Spy constructor for ElectronIPCTransport — tracks `new` invocations. */
@@ -15,9 +15,21 @@ const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathM
1515
const createMicroserviceMock = vi.fn().mockResolvedValue(fakeApp);
1616
/** Spy for `applyFixPath` — verifies the fix-path bootstrap is called during startup. */
1717
const applyFixPathMock = vi.fn();
18-
return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock };
18+
/** Fake Electron `app` object with a `getPath` spy. */
19+
const mockApp = { getPath: vi.fn().mockReturnValue('/fake/userData') };
20+
/** Spy for `createLogger` — verifies the file logger is initialised before bootstrap. */
21+
const createLoggerMock = vi.fn();
22+
return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock, mockApp, createLoggerMock };
1923
});
2024

25+
vi.mock('electron', () => ({
26+
app: mockApp,
27+
}));
28+
29+
vi.mock('./logger.js', () => ({
30+
createLogger: createLoggerMock,
31+
}));
32+
2133
vi.mock('nestjs-electron-ipc-transport', () => ({
2234
ElectronIPCTransport: ElectronIPCTransportMock,
2335
}));
@@ -52,6 +64,8 @@ describe('main bootstrap', () => {
5264
createMicroserviceMock.mockResolvedValue(fakeApp);
5365
fakeApp.listen.mockResolvedValue(undefined);
5466
applyFixPathMock.mockImplementation(() => undefined);
67+
mockApp.getPath.mockReturnValue('/fake/userData');
68+
createLoggerMock.mockImplementation(() => undefined);
5569
// Simulate an Electron main-process environment so the module-level guard passes.
5670
vi.stubGlobal('process', { ...process, versions: { ...process.versions, electron: '36.0.0' } });
5771
});
@@ -110,4 +124,18 @@ describe('main bootstrap', () => {
110124
// applyFixPath must be invoked exactly once before NestFactory.createMicroservice.
111125
expect(applyFixPathMock).toHaveBeenCalledTimes(1);
112126
});
127+
128+
it('should resolve userData path and initialise the file logger before bootstrap', async () => {
129+
vi.resetModules();
130+
await import('./main.js');
131+
132+
// Flush the event loop so the async bootstrap chain fully resolves.
133+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
134+
135+
// Electron app.getPath should have been called with 'userData' to derive the log directory.
136+
expect(mockApp.getPath).toHaveBeenCalledWith('userData');
137+
138+
// createLogger must have been called with the userData/logs path before NestFactory boots.
139+
expect(createLoggerMock).toHaveBeenCalledWith('/fake/userData/logs');
140+
});
113141
});

app/packages/desktop-main/src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
import 'reflect-metadata';
2+
import path from 'node:path';
23
import { NestFactory } from '@nestjs/core';
34
import { MicroserviceOptions } from '@nestjs/microservices';
45
import { ElectronIPCTransport } from 'nestjs-electron-ipc-transport';
56
import { AppModule } from './app.module.js';
67
import { applyFixPath } from './fix-path-bootstrap.js';
8+
import { createLogger } from './logger.js';
79

810
applyFixPath();
911

1012
// ElectronIPCTransport requires ipcMain, which is only available inside an
1113
// Electron main process. Fail fast with a readable message rather than a
1214
// cryptic module-resolution error when someone runs `node dist/main.js`.
15+
// This guard must run before any Electron API calls (e.g. app.getPath).
1316
if (!process.versions['electron']) {
1417
throw new Error(
1518
'desktop-main must run inside an Electron main process. ' +
1619
'Launch via Electron — running with plain Node is not supported.',
1720
);
1821
}
1922

23+
const { app } = await import('electron') as unknown as { app: { getPath(name: string): string } };
24+
createLogger(path.join(app.getPath('userData'), 'logs'));
25+
2026
async function bootstrap(): Promise<void> {
2127
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
2228
strategy: new ElectronIPCTransport(),

0 commit comments

Comments
 (0)