vibex-sh 0.10.1 → 0.11.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.
Files changed (2) hide show
  1. package/index.js +265 -143
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -11,6 +11,13 @@ import { fileURLToPath } from 'url';
11
11
  import WebSocket from 'ws';
12
12
  import crypto from 'crypto';
13
13
 
14
+ // Constants
15
+ const POLL_INTERVAL_MS = 1000;
16
+ const MAX_POLL_ATTEMPTS = 60;
17
+ const WEBSOCKET_CLOSE_TIMEOUT_MS = 2000;
18
+ const DEFAULT_WORKER_URL = 'https://ingest.vibex.sh';
19
+ const DEFAULT_WEB_URL = 'https://vibex.sh';
20
+
14
21
  // Get version from package.json
15
22
  const __filename = fileURLToPath(import.meta.url);
16
23
  const __dirname = dirname(__filename);
@@ -153,45 +160,16 @@ function normalizeToHybrid(message, level, payload) {
153
160
  return hybrid;
154
161
  }
155
162
 
156
- function deriveSocketUrl(webUrl) {
157
- // Always use production worker WebSocket endpoint
158
- const workerUrl = process.env.VIBEX_WORKER_URL || 'https://ingest.vibex.sh';
159
- return workerUrl.replace('https://', 'wss://').replace('http://', 'ws://');
160
- }
161
-
162
- function getUrls(options) {
163
- const { web, socket, server } = options;
164
-
165
- // Priority 1: Explicit --web and --socket flags (highest priority)
166
- if (web) {
167
- return {
168
- webUrl: web,
169
- socketUrl: socket || deriveSocketUrl(web),
170
- };
171
- }
172
-
173
- // Priority 2: --server flag (shorthand for --web)
174
- if (server) {
175
- return {
176
- webUrl: server,
177
- socketUrl: socket || deriveSocketUrl(server),
178
- };
179
- }
180
-
181
- // Priority 3: Environment variables
182
- if (process.env.VIBEX_WEB_URL) {
183
- return {
184
- webUrl: process.env.VIBEX_WEB_URL,
185
- socketUrl: process.env.VIBEX_SOCKET_URL || socket || deriveSocketUrl(process.env.VIBEX_WEB_URL),
186
- };
187
- }
188
-
189
- // Priority 4: Production defaults
163
+ /**
164
+ * Get production URLs
165
+ * CLI only supports production server
166
+ */
167
+ function getProductionUrls() {
190
168
  // Always use production worker WebSocket endpoint
191
- const defaultWorkerUrl = process.env.VIBEX_WORKER_URL || 'https://ingest.vibex.sh';
169
+ const workerUrl = process.env.VIBEX_WORKER_URL || DEFAULT_WORKER_URL;
192
170
  return {
193
- webUrl: 'https://vibex.sh',
194
- socketUrl: socket || defaultWorkerUrl.replace('https://', 'wss://').replace('http://', 'ws://'),
171
+ webUrl: DEFAULT_WEB_URL,
172
+ socketUrl: workerUrl.replace('https://', 'wss://').replace('http://', 'ws://'),
195
173
  };
196
174
  }
197
175
 
@@ -230,7 +208,7 @@ function getStoredConfig() {
230
208
  return null;
231
209
  }
232
210
 
233
- async function storeToken(token, webUrl = null) {
211
+ async function storeToken(token) {
234
212
  try {
235
213
  const configPath = getConfigPath();
236
214
  const configDir = join(homedir(), '.vibex');
@@ -240,7 +218,6 @@ async function storeToken(token, webUrl = null) {
240
218
 
241
219
  const config = {
242
220
  token,
243
- ...(webUrl && { webUrl }), // Store webUrl if provided
244
221
  updatedAt: new Date().toISOString(),
245
222
  };
246
223
 
@@ -252,9 +229,10 @@ async function storeToken(token, webUrl = null) {
252
229
  }
253
230
  }
254
231
 
255
- async function handleLogin(webUrl) {
232
+ async function handleLogin() {
256
233
  const configPath = getConfigPath();
257
234
  const existingConfig = getStoredConfig();
235
+ const { webUrl } = getProductionUrls();
258
236
 
259
237
  console.log('\n 🔐 vibex.sh CLI Authentication\n');
260
238
  console.log(` 📁 Config location: ${configPath}`);
@@ -263,8 +241,9 @@ async function handleLogin(webUrl) {
263
241
  console.log(` ⚠️ You already have a token stored. This will replace it.\n`);
264
242
  }
265
243
 
266
- const tempToken = `temp_${Date.now()}_${Math.random().toString(36).substring(7)}`;
267
- const authUrl = `${webUrl}/api/cli-auth?token=${tempToken}`;
244
+ // Generate unique state (nonce) for OAuth flow
245
+ const state = crypto.randomBytes(16).toString('hex');
246
+ const authUrl = `${webUrl}/api/cli-auth?state=${state}`;
268
247
 
269
248
  console.log(' Opening browser for authentication...\n');
270
249
  console.log(` If browser doesn't open, visit: ${authUrl}\n`);
@@ -284,21 +263,20 @@ async function handleLogin(webUrl) {
284
263
 
285
264
  // Poll for token
286
265
  console.log(' Waiting for authentication...');
287
- const maxAttempts = 60; // 60 seconds
288
266
  let attempts = 0;
289
267
 
290
- while (attempts < maxAttempts) {
291
- await new Promise(resolve => setTimeout(resolve, 1000));
268
+ while (attempts < MAX_POLL_ATTEMPTS) {
269
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
292
270
  attempts++;
293
271
 
294
272
  try {
295
- const response = await httpRequest(`${webUrl}/api/cli-auth?token=${tempToken}`, {
273
+ const response = await httpRequest(`${webUrl}/api/cli-auth?state=${state}`, {
296
274
  method: 'GET',
297
275
  });
298
276
  if (response.ok) {
299
277
  const data = await response.json();
300
278
  if (data.success && data.token) {
301
- await storeToken(data.token, webUrl);
279
+ await storeToken(data.token);
302
280
  const configPath = getConfigPath();
303
281
  console.log('\n ✅ Authentication successful!');
304
282
  console.log(` 📁 Token saved to: ${configPath}`);
@@ -342,39 +320,16 @@ function httpRequest(url, options) {
342
320
  });
343
321
  }
344
322
 
345
- async function claimSession(sessionId, token, webUrl) {
346
- if (!token) return null; // Return null instead of false to indicate no claim attempted
347
-
348
- try {
349
- // Normalize session ID before claiming
350
- const normalizedSessionId = normalizeSessionId(sessionId);
351
- const response = await httpRequest(`${webUrl}/api/auth/claim-session-with-token`, {
352
- method: 'POST',
353
- headers: { 'Content-Type': 'application/json' },
354
- body: JSON.stringify({
355
- sessionId: normalizedSessionId,
356
- token,
357
- }),
358
- });
359
-
360
- if (response.ok) {
361
- // Parse response to get auth code
362
- const responseData = await response.json();
363
- return responseData.authCode || null;
364
- }
365
-
366
- return null;
367
- } catch (error) {
368
- return null;
369
- }
370
- }
323
+ // claimSession function removed - session claiming is no longer supported
324
+ // All sessions must be created with authentication
371
325
 
372
326
  // Removed getSessionAuthCode - auth codes should only come from:
373
327
  // 1. claim-session-with-token response (for claimed sessions)
374
328
  // 2. socket.io session-auth-code event (for unclaimed sessions)
375
329
  // Never fetch auth codes via public API endpoint - security vulnerability
376
330
 
377
- function printBanner(sessionId, webUrl, authCode = null) {
331
+ function printBanner(sessionId, authCode = null) {
332
+ const { webUrl } = getProductionUrls();
378
333
  const dashboardUrl = authCode
379
334
  ? `${webUrl}/${sessionId}?auth=${authCode}`
380
335
  : `${webUrl}/${sessionId}`;
@@ -392,88 +347,249 @@ function printBanner(sessionId, webUrl, authCode = null) {
392
347
  console.log('\n');
393
348
  }
394
349
 
395
- async function main() {
396
- // Handle --version flag early (before commander parses)
397
- const allArgs = process.argv;
398
- const args = process.argv.slice(2);
350
+ /**
351
+ * Handle init command - create a new session with parser selection
352
+ */
353
+ async function handleInit(options) {
354
+ const { webUrl } = getProductionUrls();
399
355
 
400
- // Check for --version or -V flag
401
- if (allArgs.includes('--version') || allArgs.includes('-V') || args.includes('--version') || args.includes('-V')) {
402
- console.log(cliVersion);
403
- process.exit(0);
404
- }
356
+ // Interactive prompt for parser selection
357
+ const rl = readline.createInterface({
358
+ input: process.stdin,
359
+ output: process.stdout,
360
+ });
405
361
 
406
- // Handle login command separately - check BEFORE commander parses
407
- // Check process.argv directly - look for 'login' as a standalone argument
408
- // This must happen FIRST, before any commander parsing
409
- // Check if 'login' appears anywhere in process.argv (works with npx too)
410
- const hasLogin = allArgs.includes('login') || args.includes('login');
362
+ const question = (query) => new Promise((resolve) => rl.question(query, resolve));
411
363
 
412
- if (hasLogin) {
413
- // Find login position to get args after it
414
- const loginIndex = args.indexOf('login');
415
- const loginArgs = loginIndex !== -1 ? args.slice(loginIndex + 1) : [];
416
-
417
- // Create a separate command instance for login
418
- const loginCmd = new Command();
419
- loginCmd
420
- .option('--web <url>', 'Web server URL')
421
- .option('--server <url>', 'Shorthand for --web');
422
-
423
- // Parse only the options (args after 'login')
424
- if (loginArgs.length > 0) {
425
- loginCmd.parse(['node', 'vibex', ...loginArgs], { from: 'user' });
364
+ try {
365
+ console.log('\n 🔧 vibex.sh Session Initialization\n');
366
+
367
+ // Fetch available parsers from public API
368
+ let availableParsers = [];
369
+ try {
370
+ const parsersResponse = await httpRequest(`${webUrl}/api/parsers`, {
371
+ method: 'GET',
372
+ });
373
+ if (parsersResponse.ok) {
374
+ availableParsers = await parsersResponse.json();
375
+ } else {
376
+ console.warn(' ⚠️ Failed to fetch parsers from API, using fallback list');
377
+ }
378
+ } catch (e) {
379
+ console.warn(' ⚠️ Failed to fetch parsers from API, using fallback list');
380
+ }
381
+
382
+ // Fallback to hardcoded list if API fails or returns empty
383
+ if (!availableParsers || availableParsers.length === 0) {
384
+ availableParsers = [
385
+ { id: 'nginx', name: 'Nginx Access Log', category: 'web' },
386
+ { id: 'apache', name: 'Apache Access Log', category: 'web' },
387
+ { id: 'docker', name: 'Docker Container Logs', category: 'system' },
388
+ { id: 'kubernetes', name: 'Kubernetes Pod/Container Logs', category: 'system' },
389
+ ];
390
+ }
391
+
392
+ // Filter out mandatory parsers for selection (if any)
393
+ const selectableParsers = availableParsers.filter(p => !p.isMandatory);
394
+
395
+ console.log(' What kind of logs are these? (Optional - leave empty for auto-detection)');
396
+ if (selectableParsers.length > 0) {
397
+ console.log(' Available log types:');
398
+ selectableParsers.forEach((p, i) => {
399
+ console.log(` ${i + 1}. ${p.name} (${p.id})`);
400
+ });
401
+ console.log(' (Leave empty for auto-detection)\n');
402
+ } else {
403
+ console.log(' Available log types: (Leave empty for auto-detection)\n');
404
+ }
405
+
406
+ const answer = await question(' Enter comma-separated numbers or parser IDs (e.g., 1,2 or nginx,apache): ');
407
+ rl.close();
408
+
409
+ let enabledParsers = [];
410
+ if (answer.trim()) {
411
+ const selections = answer.split(',').map(s => s.trim());
412
+ selections.forEach(sel => {
413
+ // Check if it's a number
414
+ const num = parseInt(sel, 10);
415
+ if (!isNaN(num) && num > 0 && num <= selectableParsers.length) {
416
+ enabledParsers.push(selectableParsers[num - 1].id);
417
+ } else if (selectableParsers.find(p => p.id === sel)) {
418
+ enabledParsers.push(sel);
419
+ }
420
+ });
421
+ }
422
+
423
+ // Use parser flag if provided, otherwise use interactive selection
424
+ const parserFlag = options.parser || options.parsers;
425
+ if (parserFlag) {
426
+ if (typeof parserFlag === 'string') {
427
+ enabledParsers = parserFlag.split(',').map(p => p.trim());
428
+ }
429
+ }
430
+
431
+ // Get token - required for authenticated session creation
432
+ let token = process.env.VIBEX_TOKEN || await getStoredToken();
433
+ if (!token) {
434
+ console.error('\n ✗ Authentication required');
435
+ console.error(' 💡 Run: npx vibex-sh login');
436
+ process.exit(1);
437
+ }
438
+
439
+ // Create session with enabledParsers (authenticated)
440
+ const createUrl = `${webUrl}/api/sessions/create`;
441
+ const response = await httpRequest(createUrl, {
442
+ method: 'POST',
443
+ headers: {
444
+ 'Content-Type': 'application/json',
445
+ 'Authorization': `Bearer ${token}`,
446
+ },
447
+ body: JSON.stringify({
448
+ enabledParsers: enabledParsers.length > 0 ? enabledParsers : undefined,
449
+ }),
450
+ });
451
+
452
+ if (!response.ok) {
453
+ const errorData = await response.json();
454
+ if (response.status === 401 || response.status === 403) {
455
+ console.error(`\n ✗ Authentication failed: ${errorData.message || 'Invalid token'}`);
456
+ console.error(' 💡 Run: npx vibex-sh login');
457
+ } else {
458
+ console.error(`\n ✗ Failed to create session: ${errorData.message || 'Unknown error'}`);
459
+ }
460
+ process.exit(1);
461
+ }
462
+
463
+ const data = await response.json();
464
+ const createdSessionId = data.sessionId;
465
+ const createdAuthCode = data.authCode;
466
+
467
+ console.log('\n ✅ Session created successfully!\n');
468
+ printBanner(createdSessionId, createdAuthCode);
469
+ if (enabledParsers.length > 0) {
470
+ console.log(` 📋 Log Types: ${enabledParsers.join(', ')}`);
426
471
  } else {
427
- loginCmd.parse(['node', 'vibex'], { from: 'user' });
472
+ console.log(' 📋 Log Types: Auto-detection (default parsers)');
428
473
  }
474
+ console.log(`\n 💡 Use this session ID: ${createdSessionId}`);
475
+ console.log(` Example: echo '{"cpu": 45}' | npx vibex-sh -s ${createdSessionId}\n`);
429
476
 
430
- const options = loginCmd.opts();
431
- const { webUrl } = getUrls(options);
432
- await handleLogin(webUrl);
433
477
  process.exit(0);
478
+ } catch (error) {
479
+ rl.close();
480
+ console.error(`\n ✗ Error: ${error.message}`);
481
+ process.exit(1);
434
482
  }
483
+ }
435
484
 
485
+ async function main() {
486
+ // Configure main program
487
+ program
488
+ .name('vibex')
489
+ .description('vibex.sh CLI - Send logs to vibex.sh for real-time analysis')
490
+ .version(cliVersion, '-v, --version', 'Display version number');
491
+
492
+ // Login command
493
+ program
494
+ .command('login')
495
+ .description('Authenticate with vibex.sh and save your token')
496
+ .action(async () => {
497
+ await handleLogin();
498
+ process.exit(0);
499
+ });
500
+
501
+ // Init command
502
+ program
503
+ .command('init')
504
+ .description('Create a new session with parser selection')
505
+ .option('--parser <parsers>', 'Comma-separated list of parser IDs (e.g., nginx,postgres)')
506
+ .option('--parsers <parsers>', 'Alias for --parser')
507
+ .action(async (options) => {
508
+ await handleInit(options);
509
+ });
510
+
511
+ // Main command (default) - send logs
436
512
  program
437
- .version(cliVersion, '-v, --version', 'Display version number')
438
513
  .option('-s, --session-id <id>', 'Reuse existing session ID')
439
- .option('--web <url>', 'Web server URL')
440
- .option('--socket <url>', 'Socket server URL')
441
- .option('--server <url>', 'Shorthand for --web (auto-derives socket URL)')
442
514
  .option('--token <token>', 'Authentication token (or use VIBEX_TOKEN env var)')
443
- .parse();
515
+ .option('--parser <parsers>', 'Comma-separated list of parser IDs (e.g., nginx,postgres)')
516
+ .option('--parsers <parsers>', 'Alias for --parser')
517
+ .action(async (options) => {
518
+ await handleSendLogs(options);
519
+ });
520
+
521
+ // Parse arguments
522
+ program.parse();
523
+ }
444
524
 
445
- const options = program.opts();
446
- const { webUrl, socketUrl } = getUrls(options);
525
+ /**
526
+ * Handle send logs command (default/main command)
527
+ */
528
+ async function handleSendLogs(options) {
529
+ const { webUrl, socketUrl } = getProductionUrls();
447
530
 
448
- // Get token from flag, env var, or stored config
531
+ // Get token - REQUIRED for all operations
449
532
  let token = options.token || process.env.VIBEX_TOKEN || await getStoredToken();
533
+ if (!token) {
534
+ console.error('\n ✗ Authentication required');
535
+ console.error(' 💡 Run: npx vibex-sh login to authenticate');
536
+ console.error(' 💡 Or set VIBEX_TOKEN environment variable\n');
537
+ process.exit(1);
538
+ }
450
539
 
451
540
  let sessionId;
452
541
  let authCode = null;
453
542
 
543
+ // Check if stdin is available (piped input)
544
+ const isTTY = process.stdin.isTTY;
545
+ const hasStdin = !isTTY;
546
+
547
+ // If no session ID and no stdin, show usage and exit
548
+ if (!options.sessionId && !hasStdin) {
549
+ program.help();
550
+ process.exit(0);
551
+ }
552
+
454
553
  // If session ID is provided, use it (existing session)
455
554
  if (options.sessionId) {
456
555
  sessionId = normalizeSessionId(options.sessionId);
457
556
 
458
- // If token is available, try to claim the session
459
- if (token) {
460
- authCode = await claimSession(sessionId, token, webUrl);
461
- }
462
-
463
557
  // When reusing a session, show minimal info
464
558
  console.log(` 🔍 Sending logs to session: ${sessionId}\n`);
465
559
  } else {
466
- // No session ID provided - create a new anonymous session
560
+ // No session ID provided - create a new authenticated session
561
+ // Check for --parser or --parsers flag for parser selection
562
+ let enabledParsers = [];
563
+ if (options.parser || options.parsers) {
564
+ const parserList = options.parser || options.parsers;
565
+ if (Array.isArray(parserList)) {
566
+ enabledParsers = parserList;
567
+ } else if (typeof parserList === 'string') {
568
+ enabledParsers = parserList.split(',').map(p => p.trim());
569
+ }
570
+ }
571
+
467
572
  try {
468
- const createUrl = `${webUrl}/api/sessions/create-anonymous`;
573
+ const createUrl = `${webUrl}/api/sessions/create`;
469
574
  const response = await httpRequest(createUrl, {
470
575
  method: 'POST',
471
- headers: { 'Content-Type': 'application/json' },
576
+ headers: {
577
+ 'Content-Type': 'application/json',
578
+ 'Authorization': `Bearer ${token}`,
579
+ },
580
+ body: JSON.stringify({
581
+ enabledParsers: enabledParsers.length > 0 ? enabledParsers : undefined,
582
+ }),
472
583
  });
473
584
 
474
585
  if (!response.ok) {
475
586
  const errorData = await response.json();
476
- console.error(` ✗ Failed to create session: ${errorData.message || 'Unknown error'}`);
587
+ if (response.status === 401 || response.status === 403) {
588
+ console.error(` ✗ Authentication failed: ${errorData.message || 'Invalid token'}`);
589
+ console.error(' 💡 Run: npx vibex-sh login');
590
+ } else {
591
+ console.error(` ✗ Failed to create session: ${errorData.message || 'Unknown error'}`);
592
+ }
477
593
  process.exit(1);
478
594
  }
479
595
 
@@ -481,17 +597,13 @@ async function main() {
481
597
  sessionId = data.sessionId; // Server-generated unique session ID
482
598
  authCode = data.authCode; // Server-generated auth code
483
599
 
484
- // If token is available, claim the session
485
- if (token) {
486
- const claimAuthCode = await claimSession(sessionId, token, webUrl);
487
- if (claimAuthCode) {
488
- authCode = claimAuthCode;
489
- console.log(' ✓ Session automatically claimed to your account\n');
490
- }
491
- }
492
-
493
600
  // Print banner for new session
494
- printBanner(sessionId, webUrl, authCode);
601
+ printBanner(sessionId, authCode);
602
+ if (enabledParsers.length > 0) {
603
+ console.log(` 📋 Log Types: ${enabledParsers.join(', ')}`);
604
+ } else {
605
+ console.log(' 📋 Log Types: Auto-detection (default parsers)');
606
+ }
495
607
  console.log(' 💡 Tip: Use -s to send more logs to this session');
496
608
  console.log(` Example: echo '{"cpu": 45, "memory": 78}' | npx vibex-sh -s ${sessionId}\n`);
497
609
  } catch (error) {
@@ -639,6 +751,7 @@ async function main() {
639
751
  if (!receivedAuthCode || receivedAuthCode !== message.data.authCode) {
640
752
  receivedAuthCode = message.data.authCode;
641
753
  if (isNewSession) {
754
+ const { webUrl } = getProductionUrls();
642
755
  console.log(` 🔑 Auth Code: ${receivedAuthCode}`);
643
756
  console.log(` 📋 Dashboard: ${webUrl}/${sessionId}?auth=${receivedAuthCode}\n`);
644
757
  }
@@ -798,21 +911,19 @@ async function main() {
798
911
 
799
912
  // Send logs via HTTP POST (non-blocking, same as SDKs)
800
913
  // Always use production Cloudflare Worker endpoint
801
- // Token is optional - anonymous sessions can send logs without authentication
914
+ // Token is REQUIRED - all sessions must be authenticated
802
915
  // HTTP POST works independently of WebSocket - don't wait for WebSocket connection
803
916
  const sendLogViaHTTP = async (logData) => {
804
917
  try {
805
918
  // Always use production worker URL
806
- const workerUrl = process.env.VIBEX_WORKER_URL || 'https://ingest.vibex.sh';
919
+ const workerUrl = process.env.VIBEX_WORKER_URL || DEFAULT_WORKER_URL;
807
920
  const ingestUrl = `${workerUrl}/api/v1/ingest`;
808
921
 
809
- // Build headers - only include Authorization if token exists
922
+ // Build headers - Authorization is REQUIRED
810
923
  const headers = {
811
924
  'Content-Type': 'application/json',
925
+ 'Authorization': `Bearer ${token}`,
812
926
  };
813
- if (token) {
814
- headers['Authorization'] = `Bearer ${token}`;
815
- }
816
927
 
817
928
  const response = await fetch(ingestUrl, {
818
929
  method: 'POST',
@@ -913,8 +1024,17 @@ async function main() {
913
1024
  }
914
1025
  };
915
1026
 
916
- // Start WebSocket connection
917
- connectWebSocket();
1027
+ // Only start WebSocket connection if we have stdin (piped input)
1028
+ // Don't connect WebSocket when run without parameters
1029
+ if (hasStdin) {
1030
+ connectWebSocket();
1031
+ }
1032
+
1033
+ // Only read from stdin if we have piped input
1034
+ if (!hasStdin) {
1035
+ // No stdin - exit after showing session info
1036
+ process.exit(0);
1037
+ }
918
1038
 
919
1039
  const rl = readline.createInterface({
920
1040
  input: process.stdin,
@@ -993,8 +1113,10 @@ async function main() {
993
1113
  reconnectTimeout = null;
994
1114
  }
995
1115
 
996
- // Graceful shutdown - wait for close handshake
997
- await closeWebSocket();
1116
+ // Graceful shutdown - wait for close handshake (only if WebSocket was connected)
1117
+ if (hasStdin && socket) {
1118
+ await closeWebSocket();
1119
+ }
998
1120
 
999
1121
  // Give a moment for any final cleanup
1000
1122
  setTimeout(() => process.exit(0), 100);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibex-sh",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Zero-config observability CLI - pipe logs and visualize instantly",
5
5
  "type": "module",
6
6
  "bin": {