claude-notification-plugin 1.0.59 → 1.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,613 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import process from 'process';
7
+ import { createLogger } from './logger.js';
8
+ import { TelegramPoller, escapeHtml } from './telegram-poller.js';
9
+ import { WorkQueue } from './work-queue.js';
10
+ import { TaskRunner } from './task-runner.js';
11
+ import { WorktreeManager } from './worktree-manager.js';
12
+ import { parseMessage, parseTarget } from './message-parser.js';
13
+
14
+ // ----------------------
15
+ // CONFIG
16
+ // ----------------------
17
+
18
+ const CONFIG_PATH = path.join(os.homedir(), '.claude', 'notifier.config.json');
19
+ const LOG_PATH = path.join(os.homedir(), '.claude', '.listener.log');
20
+
21
+ function loadConfig () {
22
+ try {
23
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
24
+ return JSON.parse(raw);
25
+ } catch (err) {
26
+ console.error(`Failed to load config from ${CONFIG_PATH}: ${err.message}`);
27
+ process.exit(1);
28
+ }
29
+ }
30
+
31
+ // ----------------------
32
+ // MAIN DAEMON
33
+ // ----------------------
34
+
35
+ const config = loadConfig();
36
+ const logger = createLogger(LOG_PATH);
37
+
38
+ // Validate required fields
39
+ const token = process.env.CLAUDE_NOTIFY_TELEGRAM_TOKEN || config.telegramToken || config.telegram?.token;
40
+ const chatId = process.env.CLAUDE_NOTIFY_TELEGRAM_CHAT_ID || config.telegramChatId || config.telegram?.chatId;
41
+
42
+ if (!token || !chatId) {
43
+ logger.error('Missing telegramToken or telegramChatId in config');
44
+ console.error('Missing telegramToken or telegramChatId in config');
45
+ process.exit(1);
46
+ }
47
+
48
+ if (!config.listener?.projects || Object.keys(config.listener.projects).length === 0) {
49
+ logger.error('No projects defined in config.listener.projects');
50
+ console.error('No projects defined in config.listener.projects');
51
+ process.exit(1);
52
+ }
53
+
54
+ const listenerConfig = config.listener;
55
+ const taskTimeout = listenerConfig.taskTimeout || 600_000;
56
+
57
+ const poller = new TelegramPoller(token, chatId, logger);
58
+ const queue = new WorkQueue(
59
+ logger,
60
+ listenerConfig.maxQueuePerWorkDir || 10,
61
+ listenerConfig.maxTotalTasks || 50,
62
+ );
63
+ const runner = new TaskRunner(logger, taskTimeout);
64
+ const worktreeManager = new WorktreeManager(config, logger);
65
+
66
+ const startTime = Date.now();
67
+
68
+ logger.info('Listener started');
69
+ logger.info(`Projects: ${JSON.stringify(Object.keys(listenerConfig.projects))}`);
70
+
71
+ // ----------------------
72
+ // DISCOVER WORKTREES ON START
73
+ // ----------------------
74
+
75
+ for (const alias of Object.keys(listenerConfig.projects)) {
76
+ worktreeManager.discoverWorktrees(alias);
77
+ }
78
+
79
+ // ----------------------
80
+ // WATCHDOG
81
+ // ----------------------
82
+
83
+ const recovered = queue.watchdog(taskTimeout);
84
+ for (const { workDir, next } of recovered) {
85
+ if (next) {
86
+ startTask(workDir, next);
87
+ }
88
+ }
89
+
90
+ // ----------------------
91
+ // TASK RUNNER EVENTS
92
+ // ----------------------
93
+
94
+ runner.on('complete', async (workDir, task, output) => {
95
+ const entry = queue.queues[workDir];
96
+ const label = formatLabel(entry);
97
+
98
+ // Send result
99
+ let text = `✅ [${label}] Done: ${escapeHtml(task.text)}`;
100
+ if (output) {
101
+ if (output.length > 20000) {
102
+ const head = output.slice(0, 2000);
103
+ const tail = output.slice(-2000);
104
+ text += `\n\n<pre>${escapeHtml(head)}\n\n... (${output.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
105
+ // Also send as file
106
+ await poller.sendDocument(
107
+ Buffer.from(output, 'utf-8'),
108
+ `result_${task.id}.txt`,
109
+ `Full output for: ${task.text.slice(0, 100)}`
110
+ );
111
+ } else {
112
+ text += `\n\n<pre>${escapeHtml(output)}</pre>`;
113
+ }
114
+ }
115
+ await poller.sendMessage(text, task.telegramMessageId);
116
+
117
+ // Process next in queue
118
+ const next = queue.onTaskComplete(workDir, output);
119
+ if (next) {
120
+ startTask(workDir, next);
121
+ }
122
+ });
123
+
124
+ runner.on('error', async (workDir, task, errorMsg) => {
125
+ const entry = queue.queues[workDir];
126
+ const label = formatLabel(entry);
127
+ await poller.sendMessage(
128
+ `❌ [${label}] Error: ${escapeHtml(task.text)}\n\n<pre>${escapeHtml(errorMsg)}</pre>`,
129
+ task.telegramMessageId,
130
+ );
131
+
132
+ const next = queue.onTaskComplete(workDir, `ERROR: ${errorMsg}`);
133
+ if (next) {
134
+ startTask(workDir, next);
135
+ }
136
+ });
137
+
138
+ runner.on('timeout', async (workDir, task) => {
139
+ const entry = queue.queues[workDir];
140
+ const label = formatLabel(entry);
141
+ await poller.sendMessage(
142
+ `⏰ [${label}] Timeout (${Math.round(taskTimeout / 60000)} min): ${escapeHtml(task.text)}`,
143
+ task.telegramMessageId,
144
+ );
145
+
146
+ const next = queue.onTaskComplete(workDir, 'TIMEOUT');
147
+ if (next) {
148
+ startTask(workDir, next);
149
+ }
150
+ });
151
+
152
+ // ----------------------
153
+ // HELPERS
154
+ // ----------------------
155
+
156
+ function formatLabel (entry) {
157
+ if (!entry) {
158
+ return 'unknown';
159
+ }
160
+ if (entry.branch && entry.branch !== 'main' && entry.branch !== 'master') {
161
+ return `@${entry.project}/${entry.branch}`;
162
+ }
163
+ return `@${entry.project}`;
164
+ }
165
+
166
+ function startTask (workDir, task) {
167
+ const entry = queue.queues[workDir];
168
+ const label = formatLabel(entry);
169
+ poller.sendMessage(`⏳ [${label}] Running: ${escapeHtml(task.text)}`, task.telegramMessageId);
170
+ try {
171
+ const started = runner.run(workDir, task);
172
+ queue.markStarted(workDir, started.pid);
173
+ } catch (err) {
174
+ logger.error(`Failed to start task: ${err.message}`);
175
+ poller.sendMessage(`❌ [${label}] Failed to start: ${escapeHtml(err.message)}`);
176
+ queue.onTaskComplete(workDir, `START_ERROR: ${err.message}`);
177
+ }
178
+ }
179
+
180
+ function formatDuration (ms) {
181
+ const sec = Math.floor(ms / 1000);
182
+ if (sec < 60) {
183
+ return `${sec}s`;
184
+ }
185
+ return `${Math.floor(sec / 60)}m ${sec % 60}s`;
186
+ }
187
+
188
+ // ----------------------
189
+ // COMMAND HANDLERS
190
+ // ----------------------
191
+
192
+ async function handleCommand (cmd, args) {
193
+ switch (cmd) {
194
+ case '/status':
195
+ return handleStatus(args);
196
+ case '/queue':
197
+ return handleQueue();
198
+ case '/cancel':
199
+ return handleCancel(args);
200
+ case '/drop':
201
+ return handleDrop(args);
202
+ case '/clear':
203
+ return handleClear(args);
204
+ case '/projects':
205
+ return handleProjects();
206
+ case '/worktrees':
207
+ return handleWorktrees(args);
208
+ case '/worktree':
209
+ return handleCreateWorktree(args);
210
+ case '/rmworktree':
211
+ return handleRemoveWorktree(args);
212
+ case '/history':
213
+ return handleHistory();
214
+ case '/stop':
215
+ return handleStop();
216
+ case '/help':
217
+ return handleHelp();
218
+ default:
219
+ return `Unknown command: ${cmd}`;
220
+ }
221
+ }
222
+
223
+ function handleStatus (args) {
224
+ const target = parseTarget(args);
225
+
226
+ if (target) {
227
+ const statuses = queue.getProjectStatus(target.project);
228
+ if (statuses.length === 0) {
229
+ return `📊 Project "${target.project}": no active queues`;
230
+ }
231
+ let text = `📊 Project "<b>${escapeHtml(target.project)}</b>":\n`;
232
+ for (const s of statuses) {
233
+ const branchLabel = s.branch || 'main';
234
+ if (s.active) {
235
+ const elapsed = s.active.startedAt
236
+ ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
237
+ : '?';
238
+ text += `\n<b>${escapeHtml(branchLabel)}</b>:\n`;
239
+ text += ` ▶ ${escapeHtml(s.active.text)} (${elapsed})\n`;
240
+ text += ` Queue: ${s.queueLength} tasks\n`;
241
+ } else {
242
+ text += `\n<b>${escapeHtml(branchLabel)}</b>: ✅ idle\n`;
243
+ text += ` Queue: ${s.queueLength} tasks\n`;
244
+ }
245
+ }
246
+ return text;
247
+ }
248
+
249
+ // All projects
250
+ const all = queue.getAllStatus();
251
+ if (Object.keys(all).length === 0) {
252
+ const uptime = formatDuration(Date.now() - startTime);
253
+ return `📊 No active tasks\nUptime: ${uptime}`;
254
+ }
255
+
256
+ let text = '📊 <b>Status:</b>\n';
257
+ const uptime = formatDuration(Date.now() - startTime);
258
+ text += `Uptime: ${uptime}\n`;
259
+ for (const [project, statuses] of Object.entries(all)) {
260
+ text += `\n<b>${escapeHtml(project)}</b>:`;
261
+ for (const s of statuses) {
262
+ const branchLabel = s.branch || 'main';
263
+ if (s.active) {
264
+ const elapsed = s.active.startedAt
265
+ ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
266
+ : '?';
267
+ text += `\n ${escapeHtml(branchLabel)}: ▶ ${escapeHtml(s.active.text)} (${elapsed})`;
268
+ if (s.queueLength > 0) {
269
+ text += ` +${s.queueLength} queued`;
270
+ }
271
+ } else {
272
+ text += `\n ${escapeHtml(branchLabel)}: ✅ idle`;
273
+ }
274
+ }
275
+ }
276
+ return text;
277
+ }
278
+
279
+ function handleQueue () {
280
+ const all = queue.getAllStatus();
281
+ if (Object.keys(all).length === 0) {
282
+ return '📋 All queues are empty';
283
+ }
284
+
285
+ let text = '📋 <b>Queues:</b>\n';
286
+ for (const [project, statuses] of Object.entries(all)) {
287
+ for (const s of statuses) {
288
+ const label = s.branch && s.branch !== 'main' && s.branch !== 'master'
289
+ ? `@${project}/${s.branch}`
290
+ : `@${project}`;
291
+ if (s.active || s.queueLength > 0) {
292
+ text += `\n<b>${escapeHtml(label)}</b>:`;
293
+ if (s.active) {
294
+ text += `\n ▶ ${escapeHtml(s.active.text)}`;
295
+ }
296
+ const entry = queue.queues[Object.keys(queue.queues).find(
297
+ (wd) => queue.queues[wd].project === project && queue.queues[wd].branch === s.branch
298
+ )];
299
+ if (entry?.queue) {
300
+ for (let i = 0; i < entry.queue.length; i++) {
301
+ text += `\n ${i + 1}. ${escapeHtml(entry.queue[i].text)}`;
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return text;
308
+ }
309
+
310
+ async function handleCancel (args) {
311
+ const target = parseTarget(args);
312
+ const projectAlias = target?.project || 'default';
313
+ const branch = target?.branch || null;
314
+
315
+ let workDir;
316
+ try {
317
+ workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
318
+ } catch (err) {
319
+ return `❌ ${escapeHtml(err.message)}`;
320
+ }
321
+
322
+ if (!runner.isRunning(workDir)) {
323
+ return `❌ No active task in @${escapeHtml(projectAlias)}${branch ? '/' + escapeHtml(branch) : ''}`;
324
+ }
325
+
326
+ runner.cancel(workDir);
327
+ const next = queue.cancelActive(workDir);
328
+ const label = branch ? `@${projectAlias}/${branch}` : `@${projectAlias}`;
329
+
330
+ if (next) {
331
+ startTask(workDir, next);
332
+ return `🛑 [${escapeHtml(label)}] Task cancelled. Starting next.`;
333
+ }
334
+ return `🛑 [${escapeHtml(label)}] Task cancelled`;
335
+ }
336
+
337
+ function handleDrop (args) {
338
+ const target = parseTarget(args);
339
+ if (!target) {
340
+ return '❌ Usage: /drop @project N';
341
+ }
342
+ const index = parseInt(target.rest, 10);
343
+ if (!index || index < 1) {
344
+ return '❌ Specify task number (starting from 1)';
345
+ }
346
+
347
+ let workDir;
348
+ try {
349
+ workDir = worktreeManager.resolveWorkDir(target.project, target.branch);
350
+ } catch (err) {
351
+ return `❌ ${escapeHtml(err.message)}`;
352
+ }
353
+
354
+ const removed = queue.removeFromQueue(workDir, index);
355
+ if (!removed) {
356
+ return `❌ Task #${index} not found in queue`;
357
+ }
358
+ return `🗑 Removed from queue: ${escapeHtml(removed.text)}`;
359
+ }
360
+
361
+ function handleClear (args) {
362
+ const target = parseTarget(args);
363
+ const projectAlias = target?.project || 'default';
364
+ const branch = target?.branch || null;
365
+
366
+ let workDir;
367
+ try {
368
+ workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
369
+ } catch (err) {
370
+ return `❌ ${escapeHtml(err.message)}`;
371
+ }
372
+
373
+ const count = queue.clearQueue(workDir);
374
+ const label = branch ? `@${projectAlias}/${branch}` : `@${projectAlias}`;
375
+ return `🧹 [${escapeHtml(label)}] Queue cleared (${count} tasks)`;
376
+ }
377
+
378
+ function handleProjects () {
379
+ const projects = listenerConfig.projects;
380
+ let text = '📂 <b>Projects:</b>\n';
381
+ for (const [alias, proj] of Object.entries(projects)) {
382
+ const projPath = typeof proj === 'string' ? proj : proj.path;
383
+ text += `\n<b>@${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
384
+ const worktrees = typeof proj === 'object' ? proj.worktrees : null;
385
+ if (worktrees && Object.keys(worktrees).length > 0) {
386
+ for (const [branch, wtPath] of Object.entries(worktrees)) {
387
+ text += `\n /${escapeHtml(branch)} → <code>${escapeHtml(wtPath)}</code>`;
388
+ }
389
+ }
390
+ }
391
+ return text;
392
+ }
393
+
394
+ function handleWorktrees (args) {
395
+ const target = parseTarget(args);
396
+ if (!target) {
397
+ return '❌ Usage: /worktrees @project';
398
+ }
399
+
400
+ const result = worktreeManager.listWorktrees(target.project);
401
+ if (!result) {
402
+ return `❌ Project "${escapeHtml(target.project)}" not found`;
403
+ }
404
+
405
+ let text = `🌳 Worktrees for "<b>${escapeHtml(target.project)}</b>":\n`;
406
+ text += `\n• <b>main</b> → <code>${escapeHtml(result.main)}</code>`;
407
+ for (const [branch, wtPath] of Object.entries(result.worktrees)) {
408
+ text += `\n• <b>${escapeHtml(branch)}</b> → <code>${escapeHtml(wtPath)}</code>`;
409
+ }
410
+ return text;
411
+ }
412
+
413
+ function handleCreateWorktree (args) {
414
+ const target = parseTarget(args);
415
+ if (!target || !target.rest) {
416
+ return '❌ Usage: /worktree @project branch-name';
417
+ }
418
+
419
+ const branch = target.rest;
420
+ try {
421
+ const wtDir = worktreeManager.createWorktree(target.project, branch);
422
+ return `🌿 Created worktree for "<b>${escapeHtml(target.project)}</b>":\n`
423
+ + `Branch: <b>${escapeHtml(branch)}</b>\n`
424
+ + `Path: <code>${escapeHtml(wtDir)}</code>`;
425
+ } catch (err) {
426
+ return `❌ ${escapeHtml(err.message)}`;
427
+ }
428
+ }
429
+
430
+ function handleRemoveWorktree (args) {
431
+ const target = parseTarget(args);
432
+ if (!target || !target.rest) {
433
+ return '❌ Usage: /rmworktree @project branch-name';
434
+ }
435
+
436
+ const branch = target.rest;
437
+
438
+ // Check if there's an active task in this worktree
439
+ let workDir;
440
+ try {
441
+ const project = listenerConfig.projects[target.project];
442
+ workDir = project?.worktrees?.[branch];
443
+ } catch {
444
+ // ignore
445
+ }
446
+
447
+ if (workDir && runner.isRunning(workDir)) {
448
+ return `❌ Cannot remove worktree: task is running. First /cancel @${escapeHtml(target.project)}/${escapeHtml(branch)}`;
449
+ }
450
+
451
+ try {
452
+ worktreeManager.removeWorktree(target.project, branch);
453
+ return `🗑 Worktree <b>${escapeHtml(branch)}</b> removed from "<b>${escapeHtml(target.project)}</b>"`;
454
+ } catch (err) {
455
+ return `❌ ${escapeHtml(err.message)}`;
456
+ }
457
+ }
458
+
459
+ function handleHistory () {
460
+ const history = queue.getHistory(10);
461
+ if (history.length === 0) {
462
+ return '📜 History is empty';
463
+ }
464
+ let text = '📜 <b>Recent tasks:</b>\n';
465
+ for (const h of history.reverse()) {
466
+ const label = h.branch && h.branch !== 'main' && h.branch !== 'master'
467
+ ? `@${h.project}/${h.branch}`
468
+ : `@${h.project}`;
469
+ const status = h.result === 'CANCELLED' ? '🛑' : h.result?.startsWith('ERROR') ? '❌' : '✅';
470
+ text += `\n${status} [${escapeHtml(label)}] ${escapeHtml(h.text)}`;
471
+ }
472
+ return text;
473
+ }
474
+
475
+ async function handleStop () {
476
+ await poller.sendMessage('👋 Listener shutting down...');
477
+ runner.cancelAll();
478
+ logger.info('Graceful shutdown requested via /stop');
479
+ setTimeout(() => process.exit(0), 1000);
480
+ return null; // Message already sent
481
+ }
482
+
483
+ function handleHelp () {
484
+ return '<b>📖 Commands:</b>\n'
485
+ + '\n/status — status of all projects'
486
+ + '\n/status @project — project status'
487
+ + '\n/queue — all queues'
488
+ + '\n/cancel [@project[/branch]] — cancel task'
489
+ + '\n/drop @project N — remove task from queue'
490
+ + '\n/clear @project[/branch] — clear queue'
491
+ + '\n/projects — list projects'
492
+ + '\n/worktrees @project — project worktrees'
493
+ + '\n/worktree @project branch — create worktree'
494
+ + '\n/rmworktree @project branch — remove worktree'
495
+ + '\n/history — task history'
496
+ + '\n/stop — stop listener'
497
+ + '\n/help — this help'
498
+ + '\n\n<b>Tasks:</b>'
499
+ + '\n<code>@project task</code> — main worktree'
500
+ + '\n<code>@project/branch task</code> — worktree'
501
+ + '\n<code>task</code> — default project';
502
+ }
503
+
504
+ // ----------------------
505
+ // TASK HANDLER
506
+ // ----------------------
507
+
508
+ async function handleTask (parsed, telegramMessageId) {
509
+ let workDir;
510
+ let autoCreated = false;
511
+
512
+ try {
513
+ const project = listenerConfig.projects[parsed.project];
514
+ if (!project) {
515
+ await poller.sendMessage(`❌ Project "<b>${escapeHtml(parsed.project)}</b>" not found. Use /projects to list.`);
516
+ return;
517
+ }
518
+
519
+ // Check if worktree needs auto-creation (for notification)
520
+ if (parsed.branch) {
521
+ const existing = typeof project === 'object' && project.worktrees?.[parsed.branch];
522
+ if (!existing) {
523
+ autoCreated = true;
524
+ }
525
+ }
526
+
527
+ workDir = worktreeManager.resolveWorkDir(parsed.project, parsed.branch);
528
+ } catch (err) {
529
+ await poller.sendMessage(`❌ ${escapeHtml(err.message)}`);
530
+ return;
531
+ }
532
+
533
+ if (autoCreated) {
534
+ await poller.sendMessage(`🌿 Created worktree <b>${escapeHtml(parsed.branch)}</b> for "<b>${escapeHtml(parsed.project)}</b>"`);
535
+ logger.info(`Auto-created worktree for task: @${parsed.project}/${parsed.branch} → ${workDir}`);
536
+ }
537
+
538
+ const result = queue.enqueue(
539
+ workDir,
540
+ parsed.project,
541
+ parsed.branch || 'main',
542
+ parsed.text,
543
+ telegramMessageId,
544
+ );
545
+
546
+ if (result.error) {
547
+ await poller.sendMessage(`❌ ${escapeHtml(result.error)}`);
548
+ return;
549
+ }
550
+
551
+ if (result.immediate) {
552
+ startTask(workDir, result.task);
553
+ } else {
554
+ const entry = queue.queues[workDir];
555
+ const label = formatLabel(entry);
556
+ await poller.sendMessage(
557
+ `📋 [${escapeHtml(label)}] Queued (position ${result.position}).\n`
558
+ + `Currently running: ${escapeHtml(result.activeTask.text)}`,
559
+ telegramMessageId,
560
+ );
561
+ }
562
+ }
563
+
564
+ // ----------------------
565
+ // MAIN LOOP
566
+ // ----------------------
567
+
568
+ let running = true;
569
+
570
+ process.on('SIGTERM', () => {
571
+ logger.info('Received SIGTERM');
572
+ running = false;
573
+ runner.cancelAll();
574
+ setTimeout(() => process.exit(0), 2000);
575
+ });
576
+
577
+ process.on('SIGINT', () => {
578
+ logger.info('Received SIGINT');
579
+ running = false;
580
+ runner.cancelAll();
581
+ setTimeout(() => process.exit(0), 2000);
582
+ });
583
+
584
+ async function mainLoop () {
585
+ while (running) {
586
+ try {
587
+ const messages = await poller.getUpdates();
588
+ for (const msg of messages) {
589
+ const parsed = parseMessage(msg.text);
590
+ if (!parsed) {
591
+ continue;
592
+ }
593
+
594
+ if (parsed.type === 'command') {
595
+ logger.info(`Command: ${parsed.cmd} ${parsed.args}`);
596
+ const response = await handleCommand(parsed.cmd, parsed.args);
597
+ if (response) {
598
+ await poller.sendMessage(response, msg.messageId);
599
+ }
600
+ } else if (parsed.type === 'task') {
601
+ logger.info(`Task for @${parsed.project}${parsed.branch ? '/' + parsed.branch : ''}: ${parsed.text}`);
602
+ await handleTask(parsed, msg.messageId);
603
+ }
604
+ }
605
+ } catch (err) {
606
+ logger.error(`Main loop error: ${err.message}`);
607
+ // Wait before retrying on error
608
+ await new Promise((resolve) => setTimeout(resolve, 5000));
609
+ }
610
+ }
611
+ }
612
+
613
+ mainLoop();
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
7
+
8
+ export function createLogger (logPath) {
9
+ const dir = path.dirname(logPath);
10
+ fs.mkdirSync(dir, { recursive: true });
11
+
12
+ function rotateIfNeeded () {
13
+ try {
14
+ const stat = fs.statSync(logPath);
15
+ if (stat.size > MAX_LOG_SIZE) {
16
+ const backup = logPath + '.old';
17
+ if (fs.existsSync(backup)) {
18
+ fs.unlinkSync(backup);
19
+ }
20
+ fs.renameSync(logPath, backup);
21
+ }
22
+ } catch {
23
+ // file doesn't exist yet
24
+ }
25
+ }
26
+
27
+ function formatLine (level, msg) {
28
+ const ts = new Date().toISOString();
29
+ return `[${ts}] [${level}] ${msg}\n`;
30
+ }
31
+
32
+ return {
33
+ info (msg) {
34
+ rotateIfNeeded();
35
+ fs.appendFileSync(logPath, formatLine('INFO', msg));
36
+ },
37
+ error (msg) {
38
+ rotateIfNeeded();
39
+ fs.appendFileSync(logPath, formatLine('ERROR', msg));
40
+ },
41
+ warn (msg) {
42
+ rotateIfNeeded();
43
+ fs.appendFileSync(logPath, formatLine('WARN', msg));
44
+ },
45
+ };
46
+ }