mega-tail 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # mega-tail
2
+
3
+ Tail dynamic logs in a directory tree.
4
+
5
+ `mega-tail` follows appended lines across all matching log files under a root directory, and automatically starts following newly created log files as they appear.
6
+
7
+ ## Features
8
+
9
+ - Recursive discovery under a root directory
10
+ - Live follow of existing files
11
+ - Auto-discovery of new files
12
+ - Per-line prefix with source file path and detection timestamp
13
+ - Handles truncation/rotation safely
14
+
15
+ ## Usage
16
+
17
+ Run via `npx` (no prior install needed):
18
+
19
+ ```bash
20
+ npx mega-tail /var/log/myapp
21
+ ```
22
+
23
+ Run local Python script:
24
+
25
+ ```bash
26
+ ./mega-tail <directory>
27
+ ```
28
+
29
+ Run local Node script:
30
+
31
+ ```bash
32
+ node bin/mega-tail.js <directory>
33
+ ```
34
+
35
+ Examples:
36
+
37
+ ```bash
38
+ ./mega-tail /var/log/myapp
39
+ node bin/mega-tail.js /var/log/myapp
40
+ npx mega-tail /var/log/myapp
41
+ ```
42
+
43
+ Sample output:
44
+
45
+ ```text
46
+ [subdir1/somelog.log] [2026-02-05 16:09:07.333] 2026-02-02: 12:23:23.123 [DEBUG] my log message....
47
+ [subdir2/somelog2.log] [2026-02-05 16:09:07.337] 2026-02-02: 12:23:23.223 [DEBUG] my otherlog message....
48
+ ```
49
+
50
+ ## Options
51
+
52
+ ```bash
53
+ npx mega-tail --help
54
+ ```
55
+
56
+ Key options:
57
+
58
+ - `--glob <pattern>`: Add include glob (repeatable)
59
+ - `--poll-interval <seconds>`: Read loop interval
60
+ - `--scan-interval <seconds>`: New-file scan interval
61
+ - `-n, --initial-lines <N>`: Show last N lines on startup
62
+ - `--color auto|always|never`: Color mode
63
+
64
+ ## Publishing to npm
65
+
66
+ ```bash
67
+ npm login
68
+ npm whoami
69
+ npm version patch
70
+ npm publish --access public
71
+ ```
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { performance } = require("node:perf_hooks");
8
+
9
+ const DEFAULT_GLOBS = ["*.log", "*.log.*"];
10
+ const DEFAULT_POLL_INTERVAL = 0.2;
11
+ const DEFAULT_SCAN_INTERVAL = 1.0;
12
+
13
+ const C_RESET = "\u001b[0m";
14
+ const C_TIMESTAMP = "\u001b[38;5;81m";
15
+ const C_FILE = "\u001b[38;5;245m";
16
+ const C_CONTENT = "\u001b[38;5;252m";
17
+ const C_INFO = "\u001b[38;5;244m";
18
+
19
+ function usage() {
20
+ return [
21
+ "mega-tail - Tail dynamic log files in a directory tree.",
22
+ "",
23
+ "Usage:",
24
+ " mega-tail <directory> [options]",
25
+ "",
26
+ "Options:",
27
+ " --glob <pattern> Add include glob (repeatable).",
28
+ ` --poll-interval <seconds> Read loop interval (default: ${DEFAULT_POLL_INTERVAL}).`,
29
+ ` --scan-interval <seconds> New-file scan interval (default: ${DEFAULT_SCAN_INTERVAL}).`,
30
+ " -n, --initial-lines <N> Show last N lines on startup (default: 0).",
31
+ " --color auto|always|never Color mode (default: auto).",
32
+ " -h, --help Show help.",
33
+ ].join("\n");
34
+ }
35
+
36
+ function fail(message) {
37
+ process.stderr.write(`${message}\n`);
38
+ process.exitCode = 1;
39
+ }
40
+
41
+ function parseArgs(argv) {
42
+ const args = {
43
+ directory: null,
44
+ globs: [],
45
+ pollInterval: DEFAULT_POLL_INTERVAL,
46
+ scanInterval: DEFAULT_SCAN_INTERVAL,
47
+ initialLines: 0,
48
+ color: "auto",
49
+ help: false,
50
+ };
51
+
52
+ for (let i = 2; i < argv.length; i += 1) {
53
+ const token = argv[i];
54
+
55
+ if (token === "-h" || token === "--help") {
56
+ args.help = true;
57
+ continue;
58
+ }
59
+
60
+ if (token === "--glob") {
61
+ const value = argv[i + 1];
62
+ if (!value) {
63
+ throw new Error("Error: --glob requires a value");
64
+ }
65
+ args.globs.push(value);
66
+ i += 1;
67
+ continue;
68
+ }
69
+
70
+ if (token === "--poll-interval") {
71
+ const value = Number(argv[i + 1]);
72
+ if (!Number.isFinite(value)) {
73
+ throw new Error("Error: --poll-interval requires a numeric value");
74
+ }
75
+ args.pollInterval = value;
76
+ i += 1;
77
+ continue;
78
+ }
79
+
80
+ if (token === "--scan-interval") {
81
+ const value = Number(argv[i + 1]);
82
+ if (!Number.isFinite(value)) {
83
+ throw new Error("Error: --scan-interval requires a numeric value");
84
+ }
85
+ args.scanInterval = value;
86
+ i += 1;
87
+ continue;
88
+ }
89
+
90
+ if (token === "-n" || token === "--initial-lines") {
91
+ const raw = argv[i + 1];
92
+ const value = Number(raw);
93
+ if (!Number.isInteger(value)) {
94
+ throw new Error("Error: --initial-lines requires an integer value");
95
+ }
96
+ args.initialLines = value;
97
+ i += 1;
98
+ continue;
99
+ }
100
+
101
+ if (token === "--color") {
102
+ const value = argv[i + 1];
103
+ if (!value) {
104
+ throw new Error("Error: --color requires a value");
105
+ }
106
+ args.color = value;
107
+ i += 1;
108
+ continue;
109
+ }
110
+
111
+ if (token.startsWith("-")) {
112
+ throw new Error(`Error: unknown option: ${token}`);
113
+ }
114
+
115
+ if (args.directory !== null) {
116
+ throw new Error("Error: only one directory argument is allowed");
117
+ }
118
+ args.directory = token;
119
+ }
120
+
121
+ if (!args.help && args.directory === null) {
122
+ throw new Error("Error: directory is required");
123
+ }
124
+
125
+ return args;
126
+ }
127
+
128
+ function colorEnabled(mode) {
129
+ if (mode === "always") {
130
+ return true;
131
+ }
132
+ if (mode === "never") {
133
+ return false;
134
+ }
135
+ return Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb";
136
+ }
137
+
138
+ function paint(text, color, enabled) {
139
+ if (!enabled) {
140
+ return text;
141
+ }
142
+ return `${color}${text}${C_RESET}`;
143
+ }
144
+
145
+ function pad2(value) {
146
+ return String(value).padStart(2, "0");
147
+ }
148
+
149
+ function pad3(value) {
150
+ return String(value).padStart(3, "0");
151
+ }
152
+
153
+ function detectionTimestamp() {
154
+ const now = new Date();
155
+ const yyyy = now.getFullYear();
156
+ const mm = pad2(now.getMonth() + 1);
157
+ const dd = pad2(now.getDate());
158
+ const hh = pad2(now.getHours());
159
+ const min = pad2(now.getMinutes());
160
+ const ss = pad2(now.getSeconds());
161
+ const ms = pad3(now.getMilliseconds());
162
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}.${ms}`;
163
+ }
164
+
165
+ function escapeRegexChar(ch) {
166
+ return /[\\^$.*+?()[\]{}|]/.test(ch) ? `\\${ch}` : ch;
167
+ }
168
+
169
+ function globToRegex(glob) {
170
+ let out = "^";
171
+ for (let i = 0; i < glob.length; i += 1) {
172
+ const ch = glob[i];
173
+ if (ch === "*") {
174
+ out += ".*";
175
+ continue;
176
+ }
177
+ if (ch === "?") {
178
+ out += ".";
179
+ continue;
180
+ }
181
+ if (ch === "[") {
182
+ let j = i + 1;
183
+ if (j < glob.length && glob[j] === "!") {
184
+ j += 1;
185
+ }
186
+ if (j < glob.length && glob[j] === "]") {
187
+ j += 1;
188
+ }
189
+ while (j < glob.length && glob[j] !== "]") {
190
+ j += 1;
191
+ }
192
+ if (j >= glob.length) {
193
+ out += "\\[";
194
+ } else {
195
+ let classContent = glob.slice(i + 1, j);
196
+ if (classContent.startsWith("!")) {
197
+ classContent = `^${classContent.slice(1)}`;
198
+ }
199
+ classContent = classContent.replace(/\\/g, "\\\\");
200
+ out += `[${classContent}]`;
201
+ i = j;
202
+ }
203
+ continue;
204
+ }
205
+ out += escapeRegexChar(ch);
206
+ }
207
+ out += "$";
208
+ return new RegExp(out);
209
+ }
210
+
211
+ function discoverLogFiles(root, globRegexes) {
212
+ const matches = new Set();
213
+ const stack = [root];
214
+
215
+ while (stack.length > 0) {
216
+ const current = stack.pop();
217
+ let entries;
218
+ try {
219
+ entries = fs.readdirSync(current, { withFileTypes: true });
220
+ } catch {
221
+ continue;
222
+ }
223
+
224
+ for (const entry of entries) {
225
+ const full = path.join(current, entry.name);
226
+ if (entry.isDirectory()) {
227
+ stack.push(full);
228
+ continue;
229
+ }
230
+ if (!entry.isFile()) {
231
+ continue;
232
+ }
233
+
234
+ const lower = entry.name.toLowerCase();
235
+ if (globRegexes.some((regex) => regex.test(lower))) {
236
+ matches.add(full);
237
+ }
238
+ }
239
+ }
240
+
241
+ return matches;
242
+ }
243
+
244
+ function readLastLines(filePath, count) {
245
+ if (count <= 0) {
246
+ return [];
247
+ }
248
+
249
+ let content;
250
+ try {
251
+ content = fs.readFileSync(filePath, "utf8");
252
+ } catch {
253
+ return [];
254
+ }
255
+
256
+ const lines = content.split(/\r\n|\n|\r/);
257
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
258
+ lines.pop();
259
+ }
260
+ return lines.slice(-count);
261
+ }
262
+
263
+ function relativeDisplay(root, filePath) {
264
+ const rel = path.relative(root, filePath);
265
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
266
+ return filePath;
267
+ }
268
+ return rel.split(path.sep).join("/");
269
+ }
270
+
271
+ function formatOutput(relPath, line, useColor) {
272
+ const fileBlock = paint(`[${relPath}]`, C_FILE, useColor);
273
+ const tsBlock = paint(`[${detectionTimestamp()}]`, C_TIMESTAMP, useColor);
274
+ const content = paint(line, C_CONTENT, useColor);
275
+ return `${fileBlock} ${tsBlock} ${content}`;
276
+ }
277
+
278
+ function drainNewLines(filePath, state) {
279
+ let stats;
280
+ try {
281
+ stats = fs.statSync(filePath);
282
+ } catch {
283
+ return [];
284
+ }
285
+
286
+ const inode = stats.ino ?? null;
287
+ const size = stats.size;
288
+
289
+ if (state.inode === null) {
290
+ state.inode = inode;
291
+ }
292
+
293
+ const rotated = state.inode !== inode;
294
+ const truncated = size < state.position;
295
+ if (rotated || truncated) {
296
+ state.inode = inode;
297
+ state.position = 0;
298
+ state.partial = "";
299
+ }
300
+
301
+ if (size <= state.position) {
302
+ return [];
303
+ }
304
+
305
+ const toRead = size - state.position;
306
+ let payload = Buffer.alloc(0);
307
+ let fd = null;
308
+ try {
309
+ fd = fs.openSync(filePath, "r");
310
+ payload = Buffer.allocUnsafe(toRead);
311
+ const bytesRead = fs.readSync(fd, payload, 0, toRead, state.position);
312
+ state.position += bytesRead;
313
+ payload = payload.subarray(0, bytesRead);
314
+ } catch {
315
+ return [];
316
+ } finally {
317
+ if (fd !== null) {
318
+ try {
319
+ fs.closeSync(fd);
320
+ } catch {
321
+ // ignore close failures
322
+ }
323
+ }
324
+ }
325
+
326
+ if (payload.length === 0) {
327
+ return [];
328
+ }
329
+
330
+ const chunk = state.partial + payload.toString("utf8");
331
+ const parts = chunk.split(/(\r\n|\n|\r)/);
332
+ const lines = [];
333
+
334
+ for (let i = 0; i + 1 < parts.length; i += 2) {
335
+ lines.push(parts[i]);
336
+ }
337
+
338
+ state.partial = parts.length % 2 === 1 ? parts[parts.length - 1] : "";
339
+ return lines;
340
+ }
341
+
342
+ function expandHome(inputPath) {
343
+ if (inputPath === "~") {
344
+ return os.homedir();
345
+ }
346
+ if (inputPath.startsWith(`~${path.sep}`)) {
347
+ return path.join(os.homedir(), inputPath.slice(2));
348
+ }
349
+ return inputPath;
350
+ }
351
+
352
+ function sleep(ms) {
353
+ return new Promise((resolve) => setTimeout(resolve, ms));
354
+ }
355
+
356
+ async function main() {
357
+ let args;
358
+ try {
359
+ args = parseArgs(process.argv);
360
+ } catch (error) {
361
+ fail(String(error.message || error));
362
+ process.stderr.write("\n");
363
+ process.stderr.write(`${usage()}\n`);
364
+ return 1;
365
+ }
366
+
367
+ if (args.help) {
368
+ process.stdout.write(`${usage()}\n`);
369
+ return 0;
370
+ }
371
+
372
+ const root = path.resolve(expandHome(args.directory));
373
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
374
+ fail(`Error: not a directory: ${root}`);
375
+ return 1;
376
+ }
377
+
378
+ if (args.pollInterval <= 0 || args.scanInterval <= 0) {
379
+ fail("Error: poll and scan intervals must be positive");
380
+ return 1;
381
+ }
382
+
383
+ if (args.initialLines < 0) {
384
+ fail("Error: --initial-lines must be >= 0");
385
+ return 1;
386
+ }
387
+
388
+ if (!["auto", "always", "never"].includes(args.color)) {
389
+ fail("Error: --color must be one of: auto, always, never");
390
+ return 1;
391
+ }
392
+
393
+ const useColor = colorEnabled(args.color);
394
+ const globs = args.globs.length > 0 ? args.globs : DEFAULT_GLOBS;
395
+ const globRegexes = globs.map((glob) => globToRegex(glob.toLowerCase()));
396
+ const tracked = new Map();
397
+
398
+ const existingFiles = Array.from(discoverLogFiles(root, globRegexes)).sort();
399
+ for (const filePath of existingFiles) {
400
+ let stats;
401
+ try {
402
+ stats = fs.statSync(filePath);
403
+ } catch {
404
+ continue;
405
+ }
406
+
407
+ tracked.set(filePath, {
408
+ position: stats.size,
409
+ inode: stats.ino ?? null,
410
+ partial: "",
411
+ });
412
+
413
+ if (args.initialLines > 0) {
414
+ const rel = relativeDisplay(root, filePath);
415
+ for (const line of readLastLines(filePath, args.initialLines)) {
416
+ process.stdout.write(`${formatOutput(rel, line, useColor)}\n`);
417
+ }
418
+ }
419
+ }
420
+
421
+ const info =
422
+ `Monitoring ${tracked.size} files under ${root} ` +
423
+ `(globs: ${globs.join(", ")} | poll=${args.pollInterval}s | scan=${args.scanInterval}s). ` +
424
+ "Press Ctrl+C to stop.";
425
+ process.stdout.write(`${paint(info, C_INFO, useColor)}\n`);
426
+
427
+ let stopped = false;
428
+ const stop = () => {
429
+ stopped = true;
430
+ };
431
+ process.on("SIGINT", stop);
432
+ process.on("SIGTERM", stop);
433
+
434
+ let lastScan = performance.now() / 1000;
435
+
436
+ try {
437
+ while (!stopped) {
438
+ const now = performance.now() / 1000;
439
+ if (now - lastScan >= args.scanInterval) {
440
+ const currentFiles = discoverLogFiles(root, globRegexes);
441
+ const trackedFiles = new Set(tracked.keys());
442
+
443
+ const newFiles = Array.from(currentFiles)
444
+ .filter((filePath) => !trackedFiles.has(filePath))
445
+ .sort();
446
+
447
+ for (const newPath of newFiles) {
448
+ let stats;
449
+ try {
450
+ stats = fs.statSync(newPath);
451
+ } catch {
452
+ continue;
453
+ }
454
+
455
+ tracked.set(newPath, {
456
+ position: 0,
457
+ inode: stats.ino ?? null,
458
+ partial: "",
459
+ });
460
+
461
+ const rel = relativeDisplay(root, newPath);
462
+ process.stdout.write(`${paint(`[watch] ${rel}`, C_INFO, useColor)}\n`);
463
+ }
464
+
465
+ for (const filePath of trackedFiles) {
466
+ if (!currentFiles.has(filePath)) {
467
+ tracked.delete(filePath);
468
+ }
469
+ }
470
+
471
+ lastScan = now;
472
+ }
473
+
474
+ const trackedPaths = Array.from(tracked.keys()).sort();
475
+ for (const filePath of trackedPaths) {
476
+ const state = tracked.get(filePath);
477
+ if (!state) {
478
+ continue;
479
+ }
480
+
481
+ const lines = drainNewLines(filePath, state);
482
+ if (lines.length === 0) {
483
+ continue;
484
+ }
485
+
486
+ const rel = relativeDisplay(root, filePath);
487
+ for (const line of lines) {
488
+ process.stdout.write(`${formatOutput(rel, line, useColor)}\n`);
489
+ }
490
+ }
491
+
492
+ if (stopped) {
493
+ break;
494
+ }
495
+ await sleep(args.pollInterval * 1000);
496
+ }
497
+ } finally {
498
+ process.off("SIGINT", stop);
499
+ process.off("SIGTERM", stop);
500
+ }
501
+
502
+ process.stdout.write(`${paint("Stopping mega-tail.", C_INFO, useColor)}\n`);
503
+ return 0;
504
+ }
505
+
506
+ main().then((code) => {
507
+ process.exitCode = code;
508
+ });
package/mega-tail ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ mega-tail - Follow dynamic log files under a directory tree.
4
+
5
+ Behavior:
6
+ - Follows appended lines from all matching log files under the target directory.
7
+ - Detects and starts following newly created log files as they appear.
8
+ - Handles truncation/rotation by checking inode and file size changes.
9
+ - Prefixes each line with relative file path and detection timestamp.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import fnmatch
16
+ import os
17
+ import signal
18
+ import sys
19
+ import time
20
+ from collections import deque
21
+ from dataclasses import dataclass
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+
25
+ DEFAULT_GLOBS = ("*.log", "*.log.*")
26
+ DEFAULT_POLL_INTERVAL = 0.2
27
+ DEFAULT_SCAN_INTERVAL = 1.0
28
+
29
+ # ANSI colors
30
+ C_RESET = "\033[0m"
31
+ C_TIMESTAMP = "\033[38;5;81m" # highlighted cyan
32
+ C_FILE = "\033[38;5;245m" # dim gray
33
+ C_CONTENT = "\033[38;5;252m" # brighter gray
34
+ C_INFO = "\033[38;5;244m"
35
+
36
+
37
+ @dataclass
38
+ class FileState:
39
+ position: int
40
+ inode: int | None
41
+ partial: str = ""
42
+
43
+
44
+ def parse_args() -> argparse.Namespace:
45
+ parser = argparse.ArgumentParser(
46
+ prog="mega-tail",
47
+ description="Tail dynamic log files in a directory tree.",
48
+ )
49
+ parser.add_argument("directory", help="Root directory to monitor recursively")
50
+ parser.add_argument(
51
+ "--glob",
52
+ dest="globs",
53
+ action="append",
54
+ help="Filename glob to include (repeatable). Default: *.log and *.log.*",
55
+ )
56
+ parser.add_argument(
57
+ "--poll-interval",
58
+ type=float,
59
+ default=DEFAULT_POLL_INTERVAL,
60
+ help=f"Seconds between reads of tracked files (default: {DEFAULT_POLL_INTERVAL})",
61
+ )
62
+ parser.add_argument(
63
+ "--scan-interval",
64
+ type=float,
65
+ default=DEFAULT_SCAN_INTERVAL,
66
+ help=f"Seconds between directory scans for new/deleted files (default: {DEFAULT_SCAN_INTERVAL})",
67
+ )
68
+ parser.add_argument(
69
+ "-n",
70
+ "--initial-lines",
71
+ type=int,
72
+ default=0,
73
+ help="Show last N lines from each existing file at startup (default: 0)",
74
+ )
75
+ parser.add_argument(
76
+ "--color",
77
+ choices=("auto", "always", "never"),
78
+ default="auto",
79
+ help="Color mode (default: auto)",
80
+ )
81
+ return parser.parse_args()
82
+
83
+
84
+ def color_enabled(mode: str) -> bool:
85
+ if mode == "always":
86
+ return True
87
+ if mode == "never":
88
+ return False
89
+ return sys.stdout.isatty() and os.environ.get("TERM", "") != "dumb"
90
+
91
+
92
+ def paint(text: str, color: str, enabled: bool) -> str:
93
+ if not enabled:
94
+ return text
95
+ return f"{color}{text}{C_RESET}"
96
+
97
+
98
+ def detection_timestamp() -> str:
99
+ now = datetime.now().astimezone()
100
+ return now.strftime("%Y-%m-%d %H:%M:%S.") + f"{now.microsecond // 1000:03d}"
101
+
102
+
103
+ def discover_log_files(root: Path, globs: list[str]) -> set[Path]:
104
+ lowered_globs = [g.lower() for g in globs]
105
+ matches: set[Path] = set()
106
+ for dirpath, _, filenames in os.walk(root):
107
+ base = Path(dirpath)
108
+ for filename in filenames:
109
+ name = filename.lower()
110
+ if any(fnmatch.fnmatch(name, pattern) for pattern in lowered_globs):
111
+ matches.add(base / filename)
112
+ return matches
113
+
114
+
115
+ def read_last_lines(path: Path, count: int) -> list[str]:
116
+ if count <= 0:
117
+ return []
118
+ lines = deque(maxlen=count)
119
+ try:
120
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
121
+ for line in handle:
122
+ lines.append(line.rstrip("\r\n"))
123
+ except OSError:
124
+ return []
125
+ return list(lines)
126
+
127
+
128
+ def relative_display(root: Path, path: Path) -> str:
129
+ try:
130
+ return path.relative_to(root).as_posix()
131
+ except ValueError:
132
+ return str(path)
133
+
134
+
135
+ def format_output(relpath: str, line: str, use_color: bool) -> str:
136
+ file_block = paint(f"[{relpath}]", C_FILE, use_color)
137
+ ts_block = paint(f"[{detection_timestamp()}]", C_TIMESTAMP, use_color)
138
+ content = paint(line, C_CONTENT, use_color)
139
+ return f"{file_block} {ts_block} {content}"
140
+
141
+
142
+ def drain_new_lines(path: Path, state: FileState) -> list[str]:
143
+ try:
144
+ stat = path.stat()
145
+ except OSError:
146
+ return []
147
+
148
+ inode = stat.st_ino
149
+ size = stat.st_size
150
+
151
+ if state.inode is None:
152
+ state.inode = inode
153
+
154
+ rotated = state.inode != inode
155
+ truncated = size < state.position
156
+ if rotated or truncated:
157
+ state.inode = inode
158
+ state.position = 0
159
+ state.partial = ""
160
+
161
+ if size <= state.position:
162
+ return []
163
+
164
+ try:
165
+ with path.open("rb") as handle:
166
+ handle.seek(state.position)
167
+ payload = handle.read()
168
+ state.position = handle.tell()
169
+ except OSError:
170
+ return []
171
+
172
+ if not payload:
173
+ return []
174
+
175
+ # Preserve a trailing partial line and emit only complete lines.
176
+ chunk = state.partial + payload.decode("utf-8", errors="replace")
177
+ parts = chunk.splitlines(keepends=True)
178
+ lines: list[str] = []
179
+ state.partial = ""
180
+
181
+ for part in parts:
182
+ if part.endswith("\n") or part.endswith("\r"):
183
+ lines.append(part.rstrip("\r\n"))
184
+ else:
185
+ state.partial = part
186
+
187
+ return lines
188
+
189
+
190
+ def install_signal_handlers() -> None:
191
+ def _stop(_signum, _frame):
192
+ raise KeyboardInterrupt
193
+
194
+ signal.signal(signal.SIGINT, _stop)
195
+ signal.signal(signal.SIGTERM, _stop)
196
+
197
+
198
+ def main() -> int:
199
+ args = parse_args()
200
+
201
+ root = Path(args.directory).expanduser().resolve()
202
+ if not root.is_dir():
203
+ print(f"Error: not a directory: {root}", file=sys.stderr)
204
+ return 1
205
+
206
+ if args.poll_interval <= 0 or args.scan_interval <= 0:
207
+ print("Error: poll and scan intervals must be positive", file=sys.stderr)
208
+ return 1
209
+
210
+ if args.initial_lines < 0:
211
+ print("Error: --initial-lines must be >= 0", file=sys.stderr)
212
+ return 1
213
+
214
+ use_color = color_enabled(args.color)
215
+ globs = args.globs if args.globs else list(DEFAULT_GLOBS)
216
+ tracked: dict[Path, FileState] = {}
217
+
218
+ existing_files = discover_log_files(root, globs)
219
+ for path in sorted(existing_files):
220
+ try:
221
+ stat = path.stat()
222
+ except OSError:
223
+ continue
224
+ tracked[path] = FileState(position=stat.st_size, inode=stat.st_ino)
225
+ if args.initial_lines > 0:
226
+ rel = relative_display(root, path)
227
+ for line in read_last_lines(path, args.initial_lines):
228
+ print(format_output(rel, line, use_color), flush=True)
229
+
230
+ info = (
231
+ f"Monitoring {len(tracked)} files under {root} "
232
+ f"(globs: {', '.join(globs)} | poll={args.poll_interval}s | scan={args.scan_interval}s). "
233
+ "Press Ctrl+C to stop."
234
+ )
235
+ print(paint(info, C_INFO, use_color), flush=True)
236
+
237
+ install_signal_handlers()
238
+ last_scan = time.monotonic()
239
+
240
+ try:
241
+ while True:
242
+ now = time.monotonic()
243
+ if now - last_scan >= args.scan_interval:
244
+ current_files = discover_log_files(root, globs)
245
+ tracked_files = set(tracked.keys())
246
+
247
+ for new_path in sorted(current_files - tracked_files):
248
+ try:
249
+ stat = new_path.stat()
250
+ except OSError:
251
+ continue
252
+ # Start new files at byte 0 so early writes are not missed.
253
+ tracked[new_path] = FileState(position=0, inode=stat.st_ino)
254
+ rel = relative_display(root, new_path)
255
+ watch_msg = f"[watch] {rel}"
256
+ print(paint(watch_msg, C_INFO, use_color), flush=True)
257
+
258
+ for removed_path in tracked_files - current_files:
259
+ tracked.pop(removed_path, None)
260
+
261
+ last_scan = now
262
+
263
+ for path in sorted(tracked.keys()):
264
+ lines = drain_new_lines(path, tracked[path])
265
+ if not lines:
266
+ continue
267
+ rel = relative_display(root, path)
268
+ for line in lines:
269
+ print(format_output(rel, line, use_color), flush=True)
270
+
271
+ time.sleep(args.poll_interval)
272
+ except KeyboardInterrupt:
273
+ print(paint("Stopping mega-tail.", C_INFO, use_color), flush=True)
274
+ return 0
275
+
276
+
277
+ if __name__ == "__main__":
278
+ raise SystemExit(main())
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "mega-tail",
3
+ "version": "0.1.0",
4
+ "description": "Tail dynamic log files in a directory tree.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "mega-tail": "bin/mega-tail.js"
8
+ },
9
+ "files": [
10
+ "bin/mega-tail.js",
11
+ "README.md",
12
+ "mega-tail"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "tail",
19
+ "logs",
20
+ "cli",
21
+ "monitoring"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/orlenko/mega-tail.git"
26
+ }
27
+ }