@lowdep/log-tail 1.0.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/LICENSE +21 -0
- package/README.md +123 -0
- package/bin/log-tail.js +239 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rushabh Shah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# log-tail
|
|
2
|
+
|
|
3
|
+
   
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
Cross-platform `tail -f` replacement with regex filtering, JSON log parsing, level-based colorization, and multi-file watching. Zero dependencies.
|
|
7
|
+
|
|
8
|
+
Windows has no `tail -f`. PowerShell's `Get-Content -Wait` is clunky and doesn't filter. `log-tail` runs anywhere Node.js does.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g log-tail
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or without installing:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx log-tail server.log
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
log-tail server.log # tail -f equivalent
|
|
30
|
+
log-tail app.log error.log # Multiple files, labeled output
|
|
31
|
+
log-tail server.log --grep "ERROR|WARN" # Filter lines
|
|
32
|
+
log-tail access.log --highlight "404" # Highlight matches in red
|
|
33
|
+
log-tail app.json --json # Pretty-print JSON log lines
|
|
34
|
+
log-tail server.log -n 100 --no-follow # Like `tail -100`, no follow
|
|
35
|
+
log-tail server.log --exclude "/health" # Skip noisy lines
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Auto-Colorization
|
|
41
|
+
|
|
42
|
+
Lines containing standard log levels are colored automatically:
|
|
43
|
+
|
|
44
|
+
| Pattern | Color |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `ERROR`, `FATAL`, `CRITICAL` | red |
|
|
47
|
+
| `WARN`, `WARNING` | yellow |
|
|
48
|
+
| `INFO`, `NOTICE` | cyan |
|
|
49
|
+
| `DEBUG`, `TRACE` | dim |
|
|
50
|
+
|
|
51
|
+
HTTP status codes (`4xx`, `5xx`, etc.) are also highlighted.
|
|
52
|
+
|
|
53
|
+
Disable with `--no-color`.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## JSON Log Mode
|
|
58
|
+
|
|
59
|
+
Given lines like:
|
|
60
|
+
```json
|
|
61
|
+
{"level":"error","msg":"DB query timed out","timestamp":"2024-08-01T12:00:00Z","duration_ms":4012}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run `log-tail app.log --json`:
|
|
65
|
+
```
|
|
66
|
+
2024-08-01T12:00:00Z ERROR DB query timed out duration_ms=4012
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Recognized fields:
|
|
70
|
+
- Message: `message`, `msg`, `text`
|
|
71
|
+
- Level: `level`, `severity`, `lvl`
|
|
72
|
+
- Timestamp: `timestamp`, `time`, `ts`
|
|
73
|
+
|
|
74
|
+
All other fields are shown as `key=value` extras.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Multi-File
|
|
79
|
+
|
|
80
|
+
When watching multiple files, each line is labeled with the filename in its own color:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
server.log │ GET /api/users 200 12ms
|
|
84
|
+
worker.log │ Processing job #12345
|
|
85
|
+
server.log │ POST /api/login 401 4ms
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Options
|
|
91
|
+
|
|
92
|
+
| Flag | Default | Description |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `-n, --lines <N>` | `10` | Show last N lines before following |
|
|
95
|
+
| `--follow / --no-follow` | follow | Keep watching for new lines |
|
|
96
|
+
| `--grep <pattern>` | — | Only show lines matching regex |
|
|
97
|
+
| `--exclude <pattern>` | — | Skip lines matching regex |
|
|
98
|
+
| `--highlight <pattern>` | — | Highlight matches in bold red |
|
|
99
|
+
| `--interval <ms>` | `250` | Poll interval |
|
|
100
|
+
| `--json` | off | Pretty-print JSON log lines |
|
|
101
|
+
| `--no-color` | off | Disable colorization |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Keywords
|
|
112
|
+
|
|
113
|
+
`tail -f windows` · `tail follow` · `log viewer` · `follow logs` · `tail logs` · `log tail` · `live logs` · `json logs` · `cross-platform` · `zero dependencies`
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
<div align="center">
|
|
118
|
+
|
|
119
|
+
**Built to solve, shared to help — Rushabh Shah 🛠️✨**
|
|
120
|
+
|
|
121
|
+
<sub>One of 40+ zero-dependency developer CLI tools — no <code>node_modules</code>, ever.</sub>
|
|
122
|
+
|
|
123
|
+
</div>
|
package/bin/log-tail.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const VERSION = '1.0.0';
|
|
8
|
+
|
|
9
|
+
// ─── ANSI ─────────────────────────────────────────────────────────────────────
|
|
10
|
+
const isTTY = process.stdout.isTTY;
|
|
11
|
+
const c = (code, t) => isTTY ? `\x1b[${code}m${t}\x1b[0m` : t;
|
|
12
|
+
const bold = t => c('1', t);
|
|
13
|
+
const dim = t => c('2', t);
|
|
14
|
+
const red = t => c('31', t);
|
|
15
|
+
const green = t => c('32', t);
|
|
16
|
+
const yellow = t => c('33', t);
|
|
17
|
+
const cyan = t => c('36', t);
|
|
18
|
+
const blue = t => c('34', t);
|
|
19
|
+
const magenta = t => c('35', t);
|
|
20
|
+
|
|
21
|
+
const FILE_COLORS = [cyan, magenta, yellow, blue, green];
|
|
22
|
+
|
|
23
|
+
// ─── Log line colorization (level detection) ──────────────────────────────────
|
|
24
|
+
const LEVEL_PATTERNS = [
|
|
25
|
+
{ re: /\b(ERROR|FATAL|CRITICAL|EMERG)\b/i, color: red },
|
|
26
|
+
{ re: /\b(WARN|WARNING)\b/i, color: yellow },
|
|
27
|
+
{ re: /\b(INFO|NOTICE)\b/i, color: cyan },
|
|
28
|
+
{ re: /\b(DEBUG|TRACE)\b/i, color: dim },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function colorizeLine(line, colorize) {
|
|
32
|
+
if (!colorize) return line;
|
|
33
|
+
for (const { re, color } of LEVEL_PATTERNS) {
|
|
34
|
+
if (re.test(line)) {
|
|
35
|
+
return color(line);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Highlight HTTP status codes
|
|
39
|
+
return line.replace(/\b([45]\d\d)\b/g, m => red(m))
|
|
40
|
+
.replace(/\b(3\d\d)\b/g, m => yellow(m))
|
|
41
|
+
.replace(/\b(2\d\d)\b/g, m => green(m));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── JSON line pretty-printer ─────────────────────────────────────────────────
|
|
45
|
+
function tryParseJson(line) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
|
|
48
|
+
try { return JSON.parse(trimmed); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatJsonLine(obj) {
|
|
52
|
+
const level = obj.level || obj.severity || obj.lvl || '';
|
|
53
|
+
const msg = obj.message || obj.msg || obj.text || '';
|
|
54
|
+
const ts = obj.timestamp || obj.time || obj.ts || '';
|
|
55
|
+
const levelUp = String(level).toUpperCase();
|
|
56
|
+
|
|
57
|
+
let levelColored = levelUp;
|
|
58
|
+
if (/ERROR|FATAL/.test(levelUp)) levelColored = red(levelUp.padEnd(5));
|
|
59
|
+
else if (/WARN/.test(levelUp)) levelColored = yellow(levelUp.padEnd(5));
|
|
60
|
+
else if (/INFO/.test(levelUp)) levelColored = cyan(levelUp.padEnd(5));
|
|
61
|
+
else if (/DEBUG/.test(levelUp)) levelColored = dim(levelUp.padEnd(5));
|
|
62
|
+
else if (levelUp) levelColored = levelUp.padEnd(5);
|
|
63
|
+
|
|
64
|
+
const extras = Object.entries(obj)
|
|
65
|
+
.filter(([k]) => !['level', 'severity', 'lvl', 'message', 'msg', 'text', 'timestamp', 'time', 'ts'].includes(k))
|
|
66
|
+
.map(([k, v]) => `${dim(k)}=${typeof v === 'object' ? JSON.stringify(v) : v}`)
|
|
67
|
+
.join(' ');
|
|
68
|
+
|
|
69
|
+
return `${dim(ts)} ${levelColored} ${msg} ${dim(extras)}`.trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Tail one file ────────────────────────────────────────────────────────────
|
|
73
|
+
function tailFile(filePath, opts, onLine, fileLabel) {
|
|
74
|
+
const { lines, follow, pollInterval } = opts;
|
|
75
|
+
|
|
76
|
+
let position = 0;
|
|
77
|
+
let leftover = '';
|
|
78
|
+
|
|
79
|
+
// Read last N lines from end
|
|
80
|
+
if (lines > 0 && fs.existsSync(filePath)) {
|
|
81
|
+
const stat = fs.statSync(filePath);
|
|
82
|
+
const size = stat.size;
|
|
83
|
+
const chunkSize = Math.min(size, 64 * 1024 * Math.max(1, Math.ceil(lines / 100)));
|
|
84
|
+
if (chunkSize > 0) {
|
|
85
|
+
const fd = fs.openSync(filePath, 'r');
|
|
86
|
+
const buf = Buffer.alloc(chunkSize);
|
|
87
|
+
fs.readSync(fd, buf, 0, chunkSize, Math.max(0, size - chunkSize));
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
const text = buf.toString('utf8');
|
|
90
|
+
const lineArr = text.split(/\r?\n/);
|
|
91
|
+
// Drop trailing empty string if file ended with newline
|
|
92
|
+
if (lineArr.length && lineArr[lineArr.length - 1] === '') lineArr.pop();
|
|
93
|
+
// Drop first entry if our read window started mid-line
|
|
94
|
+
if (chunkSize < size && lineArr.length) lineArr.shift();
|
|
95
|
+
const lastN = lineArr.slice(-lines);
|
|
96
|
+
for (const line of lastN) if (line) onLine(line, fileLabel);
|
|
97
|
+
}
|
|
98
|
+
position = size;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!follow) return null;
|
|
102
|
+
|
|
103
|
+
// Poll for changes (simpler & more reliable than fs.watch across platforms)
|
|
104
|
+
return setInterval(() => {
|
|
105
|
+
try {
|
|
106
|
+
const stat = fs.statSync(filePath);
|
|
107
|
+
if (stat.size < position) {
|
|
108
|
+
// Truncated/rotated — reset
|
|
109
|
+
position = 0;
|
|
110
|
+
leftover = '';
|
|
111
|
+
}
|
|
112
|
+
if (stat.size > position) {
|
|
113
|
+
const fd = fs.openSync(filePath, 'r');
|
|
114
|
+
const length = stat.size - position;
|
|
115
|
+
const buf = Buffer.alloc(length);
|
|
116
|
+
fs.readSync(fd, buf, 0, length, position);
|
|
117
|
+
fs.closeSync(fd);
|
|
118
|
+
position += length;
|
|
119
|
+
const chunk = leftover + buf.toString('utf8');
|
|
120
|
+
const parts = chunk.split(/\r?\n/);
|
|
121
|
+
leftover = parts.pop();
|
|
122
|
+
for (const line of parts) if (line) onLine(line, fileLabel);
|
|
123
|
+
}
|
|
124
|
+
} catch { /* file may be temporarily unavailable */ }
|
|
125
|
+
}, pollInterval);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
129
|
+
const args = process.argv.slice(2);
|
|
130
|
+
const VALUE_FLAGS = new Set(['--lines', '-n', '--grep', '--exclude', '--interval', '--highlight']);
|
|
131
|
+
|
|
132
|
+
function getFlag(...names) {
|
|
133
|
+
for (const f of names) {
|
|
134
|
+
const i = args.indexOf(f);
|
|
135
|
+
if (i !== -1) return args[i + 1];
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
function hasFlag(...names) { return names.some(f => args.includes(f)); }
|
|
140
|
+
|
|
141
|
+
const positional = [];
|
|
142
|
+
for (let i = 0; i < args.length; i++) {
|
|
143
|
+
if (args[i].startsWith('-')) {
|
|
144
|
+
if (VALUE_FLAGS.has(args[i])) i++;
|
|
145
|
+
} else positional.push(args[i]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (hasFlag('--version', '-v')) {
|
|
149
|
+
console.log(`log-tail v${VERSION}`); process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (hasFlag('--help', '-h') || !positional.length) {
|
|
153
|
+
console.log(`
|
|
154
|
+
${bold('log-tail')} — Cross-platform tail -f replacement with regex filtering
|
|
155
|
+
|
|
156
|
+
${bold('USAGE')}
|
|
157
|
+
log-tail <file...> [options]
|
|
158
|
+
|
|
159
|
+
${bold('OPTIONS')}
|
|
160
|
+
-n, --lines <N> Show last N lines before following (default: 10)
|
|
161
|
+
-f, --follow Keep watching for new lines (default: on for single TTY)
|
|
162
|
+
--no-follow Print last N lines and exit
|
|
163
|
+
--grep <pattern> Only show lines matching regex
|
|
164
|
+
--exclude <pattern> Skip lines matching regex
|
|
165
|
+
--highlight <pat> Highlight matches in bold red
|
|
166
|
+
--interval <ms> Poll interval (default: 250)
|
|
167
|
+
--no-color Disable level/status colorization
|
|
168
|
+
--json Pretty-print JSON log lines (level, msg, extras)
|
|
169
|
+
--version Show version
|
|
170
|
+
|
|
171
|
+
${bold('EXAMPLES')}
|
|
172
|
+
log-tail server.log # tail -f equivalent
|
|
173
|
+
log-tail app.log error.log # Multiple files, labeled output
|
|
174
|
+
log-tail server.log --grep "ERROR|WARN" # Filter
|
|
175
|
+
log-tail access.log --highlight "404" # Highlight 404s
|
|
176
|
+
log-tail app.json --json # Pretty-print JSON logs
|
|
177
|
+
log-tail server.log -n 100 --no-follow # Like tail -100
|
|
178
|
+
`);
|
|
179
|
+
process.exit(positional.length ? 0 : 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const files = positional.map(p => path.resolve(p));
|
|
183
|
+
const numLines = parseInt(getFlag('--lines', '-n') || '10', 10);
|
|
184
|
+
const noFollow = hasFlag('--no-follow');
|
|
185
|
+
const follow = !noFollow;
|
|
186
|
+
const grepPattern = getFlag('--grep') ? new RegExp(getFlag('--grep')) : null;
|
|
187
|
+
const excludePat = getFlag('--exclude') ? new RegExp(getFlag('--exclude')) : null;
|
|
188
|
+
const highlight = getFlag('--highlight') ? new RegExp(getFlag('--highlight'), 'g') : null;
|
|
189
|
+
const pollInterval= parseInt(getFlag('--interval') || '250', 10);
|
|
190
|
+
const noColor = hasFlag('--no-color');
|
|
191
|
+
const jsonMode = hasFlag('--json');
|
|
192
|
+
|
|
193
|
+
for (const f of files) {
|
|
194
|
+
if (!fs.existsSync(f)) {
|
|
195
|
+
console.error(red(`\nFile not found: ${f}\n`)); process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const showLabel = files.length > 1;
|
|
200
|
+
|
|
201
|
+
function emit(line, fileLabel) {
|
|
202
|
+
if (grepPattern && !grepPattern.test(line)) return;
|
|
203
|
+
if (excludePat && excludePat.test(line)) return;
|
|
204
|
+
|
|
205
|
+
let out = line;
|
|
206
|
+
|
|
207
|
+
if (jsonMode) {
|
|
208
|
+
const obj = tryParseJson(line);
|
|
209
|
+
if (obj) out = formatJsonLine(obj);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!noColor) out = colorizeLine(out, true);
|
|
213
|
+
|
|
214
|
+
if (highlight) {
|
|
215
|
+
out = out.replace(highlight, m => bold(red(m)));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (showLabel) {
|
|
219
|
+
process.stdout.write(`${fileLabel} ${dim('│')} ${out}\n`);
|
|
220
|
+
} else {
|
|
221
|
+
process.stdout.write(out + '\n');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
console.log();
|
|
226
|
+
const intervals = [];
|
|
227
|
+
files.forEach((f, i) => {
|
|
228
|
+
const label = showLabel ? FILE_COLORS[i % FILE_COLORS.length](path.basename(f)) : '';
|
|
229
|
+
const iv = tailFile(f, { lines: numLines, follow, pollInterval }, emit, label);
|
|
230
|
+
if (iv) intervals.push(iv);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (follow) {
|
|
234
|
+
process.on('SIGINT', () => {
|
|
235
|
+
intervals.forEach(clearInterval);
|
|
236
|
+
console.log(dim('\n Stopped.\n'));
|
|
237
|
+
process.exit(0);
|
|
238
|
+
});
|
|
239
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lowdep/log-tail",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cross-platform tail -f with regex filtering, multi-file, JSON pretty-print, and color — zero dependencies",
|
|
5
|
+
"bin": {
|
|
6
|
+
"log-tail": "bin/log-tail.js"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"tail",
|
|
10
|
+
"log",
|
|
11
|
+
"logs",
|
|
12
|
+
"cli",
|
|
13
|
+
"follow",
|
|
14
|
+
"filter",
|
|
15
|
+
"developer-tools",
|
|
16
|
+
"cross-platform",
|
|
17
|
+
"zero-dependencies",
|
|
18
|
+
"tail -f windows",
|
|
19
|
+
"tail follow",
|
|
20
|
+
"log viewer",
|
|
21
|
+
"follow logs",
|
|
22
|
+
"tail logs",
|
|
23
|
+
"log tail",
|
|
24
|
+
"live logs",
|
|
25
|
+
"json logs",
|
|
26
|
+
"zero dependencies"
|
|
27
|
+
],
|
|
28
|
+
"author": "Rushabh Shah",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=14"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/Rushabh5000/log-tail.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/Rushabh5000/log-tail/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/Rushabh5000/log-tail#readme",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|