quilltap 4.5.0-dev → 4.6.0-dev

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,383 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { resolveDataDirAndPassphrase, printDefaultInstanceHint } = require('./db-helpers');
6
+
7
+ // ANSI color codes
8
+ const RESET = '\x1b[0m';
9
+ const DIM = '\x1b[2m';
10
+ const RED = '\x1b[31m';
11
+ const YELLOW = '\x1b[33m';
12
+ const BLUE = '\x1b[34m';
13
+ const GRAY = '\x1b[90m';
14
+
15
+ const VALID_STREAMS = new Set(['combined', 'error', 'stdout', 'stderr', 'startup']);
16
+
17
+ function isTty() {
18
+ return Boolean(process.stdout.isTTY);
19
+ }
20
+
21
+ function colorize(text, color) {
22
+ if (!isTty()) return text;
23
+ return `${color}${text}${RESET}`;
24
+ }
25
+
26
+ /**
27
+ * Extract the level from a log line (JSON).
28
+ * Returns one of: error, warn, info, debug, trace, or null if unparseable.
29
+ */
30
+ function extractLogLevel(line) {
31
+ try {
32
+ const obj = JSON.parse(line);
33
+ return obj.level || null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Color a log line based on its level. Timestamps are dimmed;
41
+ * the full line is colored by level.
42
+ */
43
+ function colorizeLogLine(line) {
44
+ if (!isTty()) return line;
45
+
46
+ const level = extractLogLevel(line);
47
+ let color = RESET;
48
+
49
+ switch (level) {
50
+ case 'error':
51
+ color = RED;
52
+ break;
53
+ case 'warn':
54
+ color = YELLOW;
55
+ break;
56
+ case 'info':
57
+ color = BLUE;
58
+ break;
59
+ case 'debug':
60
+ color = GRAY;
61
+ break;
62
+ default:
63
+ // For unparseable or unknown levels, try substring matching
64
+ if (line.includes('"level":"error"')) color = RED;
65
+ else if (line.includes('"level":"warn"')) color = YELLOW;
66
+ else if (line.includes('"level":"info"')) color = BLUE;
67
+ else if (line.includes('"level":"debug"')) color = GRAY;
68
+ break;
69
+ }
70
+
71
+ return `${color}${line}${RESET}`;
72
+ }
73
+
74
+ /**
75
+ * Parse command-line arguments for the logs command.
76
+ */
77
+ function parseFlags(args) {
78
+ const flags = {
79
+ dataDir: '',
80
+ instance: '',
81
+ passphrase: '',
82
+ stream: 'combined',
83
+ tail: 100,
84
+ follow: false,
85
+ grep: '',
86
+ help: false,
87
+ };
88
+
89
+ for (let i = 0; i < args.length; i++) {
90
+ const arg = args[i];
91
+
92
+ if (arg === '-h' || arg === '--help') {
93
+ flags.help = true;
94
+ } else if (arg === '-d' || arg === '--data-dir') {
95
+ flags.dataDir = args[++i];
96
+ } else if (arg === '-i' || arg === '--instance') {
97
+ flags.instance = args[++i];
98
+ } else if (arg === '--passphrase') {
99
+ flags.passphrase = args[++i];
100
+ } else if (arg === '-f' || arg === '--follow') {
101
+ flags.follow = true;
102
+ } else if (arg === '--stream') {
103
+ flags.stream = args[++i];
104
+ } else if (arg === '--tail') {
105
+ flags.tail = parseInt(args[++i], 10) || 100;
106
+ } else if (arg === '--grep') {
107
+ flags.grep = args[++i];
108
+ }
109
+ }
110
+
111
+ return flags;
112
+ }
113
+
114
+ /**
115
+ * Print help text for the logs command.
116
+ */
117
+ function printLogsHelp() {
118
+ console.log(`
119
+ Quilltap Logs Tool
120
+
121
+ Usage: quilltap logs [options]
122
+
123
+ Print or follow an instance's log files.
124
+
125
+ Options:
126
+ -d, --data-dir <path> Override data directory
127
+ -i, --instance <name> Use a registered instance (see 'quilltap instances')
128
+ --passphrase <pass> Decrypt .dbkey if peppered
129
+ --stream <name> Which log to read (default: combined)
130
+ Values: combined, error, stdout, stderr, startup
131
+ Comma-separated for multiple streams
132
+ --tail N Last N lines (default: 100; 0 = full file)
133
+ -f, --follow Keep streaming as new lines arrive (like tail -F)
134
+ --grep <pattern> Filter lines by regex pattern
135
+ -h, --help Show this help
136
+ `);
137
+ }
138
+
139
+ /**
140
+ * Resolve the logs directory from the data dir.
141
+ * dataDir is the path to <instance>/data, so logs are <instance>/logs
142
+ */
143
+ function getLogsDir(dataDir) {
144
+ return path.join(path.dirname(dataDir), 'logs');
145
+ }
146
+
147
+ /**
148
+ * Map stream names to file names.
149
+ */
150
+ function streamToFilename(stream) {
151
+ switch (stream) {
152
+ case 'combined':
153
+ return 'combined.log';
154
+ case 'error':
155
+ return 'error.log';
156
+ case 'stdout':
157
+ return 'quilltap-stdout.log';
158
+ case 'stderr':
159
+ return 'quilltap-stderr.log';
160
+ case 'startup':
161
+ return 'startup.log';
162
+ default:
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Read the tail N lines from a file synchronously.
169
+ * If N is 0, return the entire file.
170
+ */
171
+ function readTail(filePath, n) {
172
+ if (!fs.existsSync(filePath)) {
173
+ return [];
174
+ }
175
+
176
+ const content = fs.readFileSync(filePath, 'utf-8');
177
+ const lines = content.split('\n').filter(line => line.length > 0);
178
+
179
+ if (n === 0) {
180
+ return lines;
181
+ }
182
+
183
+ return lines.slice(Math.max(0, lines.length - n));
184
+ }
185
+
186
+ /**
187
+ * Filter lines by a grep pattern (JS regex).
188
+ */
189
+ function filterLines(lines, pattern) {
190
+ if (!pattern) return lines;
191
+
192
+ try {
193
+ const re = new RegExp(pattern);
194
+ return lines.filter(line => re.test(line));
195
+ } catch (e) {
196
+ console.error(`Invalid grep pattern: ${e.message}`);
197
+ process.exit(1);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Print lines to stdout, optionally with a stream prefix.
203
+ */
204
+ function printLines(lines, prefix = null) {
205
+ for (const line of lines) {
206
+ const output = prefix ? `[${prefix}] ${line}` : line;
207
+ console.log(colorizeLogLine(output));
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Follow a log file (or multiple files) as new lines arrive.
213
+ * Handles file rotation: re-opens on inode change or size reduction.
214
+ */
215
+ async function followLogs(logsDir, streams, grepPattern) {
216
+ const filePaths = {};
217
+ const fileState = {};
218
+ let lastOutputTime = Date.now();
219
+
220
+ // Initialize file tracking
221
+ for (const stream of streams) {
222
+ const filename = streamToFilename(stream);
223
+ if (!filename) {
224
+ console.error(`Invalid stream: ${stream}`);
225
+ process.exit(1);
226
+ }
227
+
228
+ const filePath = path.join(logsDir, filename);
229
+ filePaths[stream] = filePath;
230
+
231
+ if (fs.existsSync(filePath)) {
232
+ const stat = fs.statSync(filePath);
233
+ fileState[stream] = {
234
+ ino: stat.ino,
235
+ size: stat.size,
236
+ position: stat.size,
237
+ };
238
+ } else {
239
+ fileState[stream] = {
240
+ ino: null,
241
+ size: 0,
242
+ position: 0,
243
+ };
244
+ }
245
+ }
246
+
247
+ const watchDir = logsDir;
248
+ const watcher = fs.watch(watchDir, { persistent: true }, (eventType, filename) => {
249
+ // On any event, check each tracked file for changes
250
+ for (const stream of streams) {
251
+ const filePath = filePaths[stream];
252
+ const logFilename = path.basename(filePath);
253
+
254
+ // Check if this event is about our file or a rotation of it
255
+ if (!filename || filename === logFilename || filename.startsWith(logFilename.replace('.log', '.'))) {
256
+ try {
257
+ if (fs.existsSync(filePath)) {
258
+ const stat = fs.statSync(filePath);
259
+ const state = fileState[stream];
260
+
261
+ // Detect rotation: inode changed or size decreased
262
+ if (state.ino !== null && (stat.ino !== state.ino || stat.size < state.position)) {
263
+ state.ino = stat.ino;
264
+ state.size = stat.size;
265
+ state.position = 0; // Reset position on rotation
266
+ return;
267
+ }
268
+
269
+ // If file exists and has grown, read new content
270
+ if (stat.size > state.position) {
271
+ state.ino = stat.ino;
272
+ state.size = stat.size;
273
+
274
+ const fd = fs.openSync(filePath, 'r');
275
+ const buffer = Buffer.alloc(state.size - state.position);
276
+ fs.readSync(fd, buffer, 0, buffer.length, state.position);
277
+ fs.closeSync(fd);
278
+
279
+ const newContent = buffer.toString('utf-8');
280
+ const newLines = newContent.split('\n').filter(line => line.length > 0);
281
+
282
+ let linesToPrint = filterLines(newLines, grepPattern);
283
+ const streamPrefix = streams.length > 1 ? stream : null;
284
+
285
+ printLines(linesToPrint, streamPrefix);
286
+
287
+ state.position = stat.size;
288
+ lastOutputTime = Date.now();
289
+ }
290
+ } else if (fileState[stream].ino !== null) {
291
+ // File was deleted; reset state
292
+ fileState[stream] = { ino: null, size: 0, position: 0 };
293
+ }
294
+ } catch (e) {
295
+ // Ignore transient errors (file locked, etc.)
296
+ }
297
+ }
298
+ }
299
+ });
300
+
301
+ // Keep the watcher alive indefinitely
302
+ await new Promise(() => {
303
+ // This never resolves; the user must Ctrl+C to exit
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Main entry point for the logs command.
309
+ */
310
+ async function logsCommand(args) {
311
+ const flags = parseFlags(args);
312
+
313
+ if (flags.help) {
314
+ printLogsHelp();
315
+ return;
316
+ }
317
+
318
+ try {
319
+ const { dataDir, instanceName, usedPlatformDefault } = resolveDataDirAndPassphrase({
320
+ dataDir: flags.dataDir,
321
+ instance: flags.instance,
322
+ passphrase: flags.passphrase,
323
+ });
324
+
325
+ if (usedPlatformDefault) {
326
+ printDefaultInstanceHint();
327
+ }
328
+
329
+ const logsDir = getLogsDir(dataDir);
330
+
331
+ // Parse stream argument (comma-separated)
332
+ const streamList = flags.stream
333
+ .split(',')
334
+ .map(s => s.trim())
335
+ .filter(s => s.length > 0);
336
+
337
+ // Validate streams
338
+ for (const stream of streamList) {
339
+ if (!VALID_STREAMS.has(stream)) {
340
+ console.error(`Invalid stream: ${stream}. Valid values: ${Array.from(VALID_STREAMS).join(', ')}`);
341
+ process.exit(1);
342
+ }
343
+ }
344
+
345
+ if (streamList.length === 0) {
346
+ console.error('No streams specified.');
347
+ process.exit(1);
348
+ }
349
+
350
+ // Follow mode
351
+ if (flags.follow) {
352
+ await followLogs(logsDir, streamList, flags.grep);
353
+ return;
354
+ }
355
+
356
+ // Print mode (tail)
357
+ const allLines = [];
358
+
359
+ for (const stream of streamList) {
360
+ const filename = streamToFilename(stream);
361
+ const filePath = path.join(logsDir, filename);
362
+
363
+ const lines = readTail(filePath, flags.tail);
364
+ const filtered = filterLines(lines, flags.grep);
365
+
366
+ const streamPrefix = streamList.length > 1 ? stream : null;
367
+ printLines(filtered, streamPrefix);
368
+
369
+ allLines.push(...filtered);
370
+ }
371
+
372
+ if (allLines.length === 0 && flags.tail > 0) {
373
+ console.error('No matching log entries.');
374
+ }
375
+ } catch (e) {
376
+ console.error(`Error: ${e.message}`);
377
+ process.exit(1);
378
+ }
379
+ }
380
+
381
+ module.exports = {
382
+ logsCommand,
383
+ };