@logtrace/tracker 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/src/index.js ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * packages/cli/src/index.js
4
+ *
5
+ * tracker — LogTrace CLI entry point.
6
+ *
7
+ * Uses yargs for command parsing. Each subcommand lives in its own file
8
+ * under commands/ and exports a single async handler function.
9
+ *
10
+ * Usage:
11
+ * node packages/cli/src/index.js status
12
+ * node packages/cli/src/index.js --help
13
+ */
14
+
15
+ import yargs from 'yargs'
16
+ import { hideBin } from 'yargs/helpers'
17
+ import { statusCommand } from './commands/status.js'
18
+ import { logsListCommand, logsSearchCommand } from './commands/logs.js'
19
+ import { dockerListCommand, dockerRestartCommand, dockerLogsCommand } from './commands/docker.js'
20
+ import { alertTestCommand } from './commands/alert.js'
21
+ import { doctorCommand, healthCommand } from './commands/doctor.js'
22
+ import { configListCommand, configGetCommand, configSetCommand, configUnsetCommand } from './commands/config.js'
23
+ import { updateCommand, updateCheckCommand, checkUpdateBackground } from './commands/update.js'
24
+ import { loginCommand } from './commands/login.js'
25
+ import { startInteractive } from './interactive.js'
26
+ import { VERSION } from '@logtrace/shared/constants'
27
+
28
+ // No args → interactive REPL
29
+ if (hideBin(process.argv).length === 0) {
30
+ startInteractive()
31
+ } else {
32
+
33
+ yargs(hideBin(process.argv))
34
+ .scriptName('tracker')
35
+ .version(VERSION)
36
+ .alias('v', 'version')
37
+ .alias('h', 'help')
38
+ .usage('$0 <command> [options]')
39
+
40
+ // ── status ──────────────────────────────────────────────────────────────────
41
+ .command(
42
+ 'status',
43
+ 'Show daemon version, uptime, and active modules',
44
+ (_yargs) => {
45
+ // No extra options for status yet
46
+ },
47
+ (argv) => statusCommand(argv),
48
+ )
49
+
50
+ // ── logs ────────────────────────────────────────────────────────────────────
51
+ .command(
52
+ 'logs',
53
+ 'Manage logs',
54
+ (y) => {
55
+ y.command(
56
+ 'list',
57
+ 'List stored logs with optional filters',
58
+ (yy) => yy
59
+ .option('level', { alias: 'l', type: 'string', describe: 'Filter by level (debug|info|warn|error|critical)' })
60
+ .option('service', { alias: 's', type: 'string', describe: 'Filter by service name' })
61
+ .option('limit', { alias: 'n', type: 'number', describe: 'Number of results (default 20)', default: 20 })
62
+ .option('from', { alias: 'f', type: 'string', describe: 'Show logs from last N (e.g. 1h, 30m, 1d)' }),
63
+ (argv) => logsListCommand(argv),
64
+ )
65
+ y.command(
66
+ 'search <query>',
67
+ 'Search logs by message or service',
68
+ (yy) => yy
69
+ .positional('query', { type: 'string', describe: 'Search term' })
70
+ .option('limit', { alias: 'n', type: 'number', describe: 'Number of results (default 20)', default: 20 }),
71
+ (argv) => logsSearchCommand(argv),
72
+ )
73
+ y.demandCommand(1, 'Specify a subcommand: list, search')
74
+ },
75
+ () => {},
76
+ )
77
+
78
+ // ── docker ───────────────────────────────────────────────────────────────────
79
+ .command(
80
+ 'docker',
81
+ 'Manage Docker containers',
82
+ (y) => {
83
+ y.command(
84
+ 'list',
85
+ 'List all containers',
86
+ (_yy) => {},
87
+ (argv) => dockerListCommand(argv),
88
+ )
89
+ y.command(
90
+ 'restart <name>',
91
+ 'Restart a container by name',
92
+ (yy) => yy
93
+ .positional('name', { type: 'string', describe: 'Container name' }),
94
+ (argv) => dockerRestartCommand(argv),
95
+ )
96
+ y.command(
97
+ 'logs <name>',
98
+ 'Tail container log lines',
99
+ (yy) => yy
100
+ .positional('name', { type: 'string', describe: 'Container name' })
101
+ .option('tail', { alias: 't', type: 'number', describe: 'Number of lines (default 50)', default: 50 }),
102
+ (argv) => dockerLogsCommand(argv),
103
+ )
104
+ y.demandCommand(1, 'Specify a subcommand: list, restart, logs')
105
+ },
106
+ () => {},
107
+ )
108
+
109
+ // ── alert ────────────────────────────────────────────────────────────────────
110
+ .command(
111
+ 'alert',
112
+ 'Manage alerts',
113
+ (y) => {
114
+ y.command(
115
+ 'test',
116
+ 'Send a test alert through configured channels',
117
+ (_yy) => {},
118
+ (argv) => alertTestCommand(argv),
119
+ )
120
+ y.demandCommand(1, 'Specify a subcommand: test')
121
+ },
122
+ () => {},
123
+ )
124
+
125
+ // ── doctor ───────────────────────────────────────────────────────────────────
126
+ .command(
127
+ 'doctor',
128
+ 'Run all system health checks and show a full diagnostic table',
129
+ (_yy) => {},
130
+ (argv) => doctorCommand(argv),
131
+ )
132
+
133
+ // ── health ───────────────────────────────────────────────────────────────────
134
+ .command(
135
+ 'health',
136
+ 'Show only problems (warn + critical); "sistema saludable" if all OK',
137
+ (_yy) => {},
138
+ (argv) => healthCommand(argv),
139
+ )
140
+
141
+ // ── config ───────────────────────────────────────────────────────────────────
142
+ .command(
143
+ 'config',
144
+ 'Read and write the LogTrace .env config file',
145
+ (y) => {
146
+ y.command(
147
+ 'list',
148
+ 'Show all config keys (sensitive values masked)',
149
+ (_yy) => {},
150
+ (argv) => configListCommand(argv),
151
+ )
152
+ y.command(
153
+ 'get <key>',
154
+ 'Print the exact value for a key (no masking)',
155
+ (yy) => yy
156
+ .positional('key', { type: 'string', describe: 'Config key name' }),
157
+ (argv) => configGetCommand(argv),
158
+ )
159
+ y.command(
160
+ 'set <key> <value>',
161
+ 'Set or update a config key',
162
+ (yy) => yy
163
+ .positional('key', { type: 'string', describe: 'Config key name' })
164
+ .positional('value', { type: 'string', describe: 'Value to set' }),
165
+ (argv) => configSetCommand(argv),
166
+ )
167
+ y.command(
168
+ 'unset <key>',
169
+ 'Remove a key from the config file',
170
+ (yy) => yy
171
+ .positional('key', { type: 'string', describe: 'Config key to remove' }),
172
+ (argv) => configUnsetCommand(argv),
173
+ )
174
+ y.demandCommand(1, 'Specify a subcommand: list, get, set, unset')
175
+ },
176
+ () => {},
177
+ )
178
+
179
+ // ── update ───────────────────────────────────────────────────────────────────
180
+ .command(
181
+ 'update',
182
+ 'Check for and install tracker updates',
183
+ (y) => {
184
+ y.option('check', {
185
+ alias: 'c',
186
+ type: 'boolean',
187
+ default: false,
188
+ describe: 'Only check for updates without installing',
189
+ })
190
+ },
191
+ async (argv) => {
192
+ if (argv.check) {
193
+ await updateCheckCommand(argv)
194
+ } else {
195
+ await updateCommand(argv)
196
+ }
197
+ },
198
+ )
199
+
200
+ // ── login ─────────────────────────────────────────────────────────────────────
201
+ .command(
202
+ 'login',
203
+ 'Log in to LogTrace and save your license key',
204
+ (y) => {
205
+ y.option('key', {
206
+ alias: 'k',
207
+ type: 'string',
208
+ describe: 'Authenticate directly with a license key (for VPS / headless servers)',
209
+ })
210
+ },
211
+ (argv) => loginCommand(argv),
212
+ )
213
+
214
+ // ── future commands registered here in subsequent pasos ─────────────────────
215
+
216
+ .demandCommand(1, 'Please specify a command. Use --help to see available commands.')
217
+ .strict()
218
+ .help()
219
+ .wrap(null)
220
+ .parse()
221
+
222
+ } // end else (non-interactive mode)
223
+
224
+ // ── Background update hint (runs after yargs parses + executes the command) ──
225
+ // We defer to next tick so the command output prints first, then the hint.
226
+ process.on('exit', () => {
227
+ // Only show the hint when stdout is a TTY (suppress in scripts/piped output)
228
+ if (process.stdout.isTTY) {
229
+ try {
230
+ checkUpdateBackground()
231
+ } catch {
232
+ // Never crash the CLI on a background check failure
233
+ }
234
+ }
235
+ })
@@ -0,0 +1,384 @@
1
+ /**
2
+ * packages/cli/src/interactive.js
3
+ *
4
+ * tracker — interactive REPL mode.
5
+ *
6
+ * Run `tracker` with no arguments to enter this mode.
7
+ * Commands start with / — e.g. /login, /status, /logs list, /exit
8
+ */
9
+
10
+ import readline from 'node:readline'
11
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import chalk from 'chalk'
15
+ import { LOGTRACE_DIR } from '@logtrace/shared/constants'
16
+ import { statusCommand } from './commands/status.js'
17
+ import { logsListCommand, logsSearchCommand } from './commands/logs.js'
18
+ import { dockerListCommand, dockerRestartCommand, dockerLogsCommand } from './commands/docker.js'
19
+ import { doctorCommand, healthCommand } from './commands/doctor.js'
20
+ import { configListCommand, configGetCommand, configSetCommand, configUnsetCommand } from './commands/config.js'
21
+ import { alertTestCommand } from './commands/alert.js'
22
+ import { updateCommand, updateCheckCommand } from './commands/update.js'
23
+
24
+ const AUTH_FILE = join(homedir(), '.logtrace', 'auth.json')
25
+
26
+ // -------------------------------------------------------------------
27
+ // Helpers
28
+ // -------------------------------------------------------------------
29
+
30
+ function _loadAuth() {
31
+ if (!existsSync(AUTH_FILE)) return null
32
+ try { return JSON.parse(readFileSync(AUTH_FILE, 'utf8')) } catch { return null }
33
+ }
34
+
35
+ function _getCentralApiUrl() {
36
+ if (process.env.CENTRAL_API_URL) return process.env.CENTRAL_API_URL
37
+ const envPath = join(LOGTRACE_DIR, '.env')
38
+ if (existsSync(envPath)) {
39
+ const line = readFileSync(envPath, 'utf8').split('\n').find((l) => l.startsWith('CENTRAL_API_URL='))
40
+ if (line) return line.slice('CENTRAL_API_URL='.length).trim()
41
+ }
42
+ return 'https://api.logtrace.cloud'
43
+ }
44
+
45
+ // -------------------------------------------------------------------
46
+ // Banner
47
+ // -------------------------------------------------------------------
48
+
49
+ function _banner() {
50
+ const auth = _loadAuth()
51
+ console.log()
52
+ console.log(chalk.bold.white(' LogTrace') + chalk.dim(' · tracker v0.1.0'))
53
+ if (auth) {
54
+ console.log(
55
+ chalk.dim(' ') + chalk.green('●') +
56
+ chalk.dim(` Logged in as `) + chalk.white(auth.email) +
57
+ chalk.dim(` · Plan: ${auth.plan}`),
58
+ )
59
+ } else {
60
+ console.log(chalk.dim(' ○ Not logged in — type ') + chalk.cyan('/login') + chalk.dim(' to authenticate'))
61
+ }
62
+ console.log(chalk.dim(' Type /help for commands, /exit to quit'))
63
+ console.log()
64
+ }
65
+
66
+ // -------------------------------------------------------------------
67
+ // /help
68
+ // -------------------------------------------------------------------
69
+
70
+ function _showHelp() {
71
+ console.log()
72
+ const row = (cmd, desc) =>
73
+ console.log(' ' + chalk.cyan(cmd.padEnd(28)) + chalk.dim(desc))
74
+
75
+ row('/login', 'Authenticate with license key or browser')
76
+ row('/logout', 'Remove saved credentials')
77
+ row('/status', 'Daemon status — uptime, modules, version')
78
+ row('/doctor', 'Full system diagnostic with all checks')
79
+ row('/health', 'Show only warnings and critical issues')
80
+ row('/logs list', 'List recent logs (add --level, --service, --limit)')
81
+ row('/logs search <query>', 'Search logs by keyword')
82
+ row('/docker list', 'List all Docker containers')
83
+ row('/docker restart <name>', 'Restart a container')
84
+ row('/docker logs <name>', 'Tail container output')
85
+ row('/config list', 'Show all config keys (masked)')
86
+ row('/config get <key>', 'Get exact value for a key')
87
+ row('/config set <key> <value>', 'Set a config key')
88
+ row('/config unset <key>', 'Remove a config key')
89
+ row('/alert test', 'Send a test alert')
90
+ row('/update', 'Check for and install updates')
91
+ row('/exit', 'Exit tracker')
92
+ console.log()
93
+ }
94
+
95
+ // -------------------------------------------------------------------
96
+ // Flag parser
97
+ // -------------------------------------------------------------------
98
+
99
+ function _parseFlags(parts) {
100
+ const out = {}
101
+ for (let i = 0; i < parts.length; i++) {
102
+ const p = parts[i]
103
+ if (p.startsWith('--') || p.startsWith('-')) {
104
+ const key = p.replace(/^-+/, '')
105
+ const val = parts[i + 1] && !parts[i + 1].startsWith('-') ? parts[++i] : true
106
+ out[key] = val
107
+ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ // -------------------------------------------------------------------
113
+ // Command dispatcher (normal state)
114
+ // -------------------------------------------------------------------
115
+
116
+ async function _dispatch(input) {
117
+ if (!input.startsWith('/')) {
118
+ console.log(chalk.dim('\n Commands start with / · try /help\n'))
119
+ return
120
+ }
121
+
122
+ const parts = input.slice(1).trim().split(/\s+/)
123
+ const cmd = parts[0].toLowerCase()
124
+ const args = parts.slice(1)
125
+
126
+ console.log()
127
+
128
+ switch (cmd) {
129
+ case 'help':
130
+ _showHelp()
131
+ return
132
+
133
+ case 'login':
134
+ return 'START_LOGIN'
135
+
136
+ case 'logout':
137
+ if (!existsSync(AUTH_FILE)) {
138
+ console.log(chalk.dim(' Not logged in.\n'))
139
+ } else {
140
+ rmSync(AUTH_FILE)
141
+ console.log(chalk.green(' ✓ Logged out.\n'))
142
+ }
143
+ return
144
+
145
+ case 'status':
146
+ await statusCommand({})
147
+ console.log()
148
+ return
149
+
150
+ case 'doctor':
151
+ await doctorCommand({})
152
+ console.log()
153
+ return
154
+
155
+ case 'health':
156
+ await healthCommand({})
157
+ console.log()
158
+ return
159
+
160
+ case 'logs': {
161
+ const sub = args[0]?.toLowerCase()
162
+ if (sub === 'list' || !sub) {
163
+ const opts = _parseFlags(args.slice(sub ? 1 : 0))
164
+ await logsListCommand({ level: opts.level || opts.l, service: opts.service || opts.s, limit: Number(opts.limit || opts.n) || 20, from: opts.from || opts.f })
165
+ } else if (sub === 'search') {
166
+ const query = args.slice(1).join(' ')
167
+ if (!query) { console.log(chalk.yellow(' Usage: /logs search <query>\n')); return }
168
+ await logsSearchCommand({ query, limit: 20 })
169
+ } else {
170
+ console.log(chalk.dim(' Usage: /logs list · /logs search <query>\n'))
171
+ }
172
+ console.log()
173
+ return
174
+ }
175
+
176
+ case 'docker': {
177
+ const sub = args[0]?.toLowerCase()
178
+ if (sub === 'list' || !sub) {
179
+ await dockerListCommand({})
180
+ } else if (sub === 'restart') {
181
+ if (!args[1]) { console.log(chalk.yellow(' Usage: /docker restart <name>\n')); return }
182
+ await dockerRestartCommand({ name: args[1] })
183
+ } else if (sub === 'logs') {
184
+ if (!args[1]) { console.log(chalk.yellow(' Usage: /docker logs <name>\n')); return }
185
+ await dockerLogsCommand({ name: args[1], tail: Number(args[2]) || 50 })
186
+ } else {
187
+ console.log(chalk.dim(' Usage: /docker list · /docker restart <name> · /docker logs <name>\n'))
188
+ }
189
+ console.log()
190
+ return
191
+ }
192
+
193
+ case 'config': {
194
+ const sub = args[0]?.toLowerCase()
195
+ if (sub === 'list' || !sub) {
196
+ await configListCommand({})
197
+ } else if (sub === 'get') {
198
+ if (!args[1]) { console.log(chalk.yellow(' Usage: /config get <key>\n')); return }
199
+ await configGetCommand({ key: args[1] })
200
+ } else if (sub === 'set') {
201
+ if (!args[1] || !args[2]) { console.log(chalk.yellow(' Usage: /config set <key> <value>\n')); return }
202
+ await configSetCommand({ key: args[1], value: args.slice(2).join(' ') })
203
+ } else if (sub === 'unset') {
204
+ if (!args[1]) { console.log(chalk.yellow(' Usage: /config unset <key>\n')); return }
205
+ await configUnsetCommand({ key: args[1] })
206
+ } else {
207
+ console.log(chalk.dim(' Usage: /config list · /config get <key> · /config set <key> <value>\n'))
208
+ }
209
+ console.log()
210
+ return
211
+ }
212
+
213
+ case 'alert': {
214
+ const sub = args[0]?.toLowerCase()
215
+ if (sub === 'test') {
216
+ await alertTestCommand({})
217
+ } else {
218
+ console.log(chalk.dim(' Usage: /alert test\n'))
219
+ }
220
+ console.log()
221
+ return
222
+ }
223
+
224
+ case 'update': {
225
+ const checkOnly = args.includes('--check') || args.includes('-c')
226
+ await (checkOnly ? updateCheckCommand({}) : updateCommand({}))
227
+ console.log()
228
+ return
229
+ }
230
+
231
+ case 'exit':
232
+ case 'quit':
233
+ case 'q':
234
+ console.log(chalk.dim(' Bye!\n'))
235
+ process.exit(0)
236
+ return
237
+
238
+ default:
239
+ console.log(chalk.dim(` Unknown command: /${cmd} · type /help\n`))
240
+ }
241
+ }
242
+
243
+ // -------------------------------------------------------------------
244
+ // Login state machine (runs inside the main queue)
245
+ // -------------------------------------------------------------------
246
+ //
247
+ // All lines go through a single sequential queue. When _loginState is
248
+ // not null, lines are routed to the login handler instead of dispatch.
249
+ // This avoids the race between registering a new listener and the next
250
+ // piped line arriving.
251
+
252
+ const S = { NORMAL: 'NORMAL', METHOD: 'METHOD', KEY: 'KEY', BROWSER: 'BROWSER' }
253
+
254
+ async function _loginHandleLine(line, state) {
255
+ const val = line.trim()
256
+
257
+ if (state.step === S.METHOD) {
258
+ if (val === '2') {
259
+ // Browser flow
260
+ state.step = S.BROWSER
261
+ process.stdout.write('\n')
262
+ const { loginCommand } = await import('./commands/login.js')
263
+ await loginCommand({})
264
+ return true // done
265
+ }
266
+ // Key flow (choice '1' or anything else)
267
+ state.step = S.KEY
268
+ process.stdout.write(chalk.dim('\n License key: '))
269
+ return false // need one more line
270
+ }
271
+
272
+ if (state.step === S.KEY) {
273
+ process.stdout.write('\n')
274
+ if (!val) {
275
+ console.log(chalk.yellow(' No key entered.\n'))
276
+ return true
277
+ }
278
+ await _doKeyLogin(val)
279
+ return true // done
280
+ }
281
+
282
+ return true
283
+ }
284
+
285
+ function _showLoginPrompt() {
286
+ console.log()
287
+ console.log(chalk.dim(' How do you want to log in?'))
288
+ console.log(' ' + chalk.cyan('1') + chalk.dim(' · Paste license key (VPS / servers)'))
289
+ console.log(' ' + chalk.cyan('2') + chalk.dim(' · Open browser (laptop / desktop)'))
290
+ console.log()
291
+ process.stdout.write(chalk.dim(' Choice: '))
292
+ }
293
+
294
+ async function _doKeyLogin(key) {
295
+ const CENTRAL_API_URL = _getCentralApiUrl()
296
+ const isTTY = process.stdout.isTTY
297
+ process.stdout.write(chalk.dim(' Verifying...') + (isTTY ? '' : '\n'))
298
+ try {
299
+ const res = await fetch(`${CENTRAL_API_URL}/api/v1/cli/auth/key-login/`, {
300
+ method: 'POST',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ license_key: key }),
303
+ })
304
+ const data = await res.json()
305
+ if (!res.ok) {
306
+ console.log((isTTY ? '\r' : '') + chalk.red(` ✗ ${data.message || 'Invalid license key.'}\n`))
307
+ return
308
+ }
309
+ const { _saveAuthData, _writeKeyToEnv } = await import('./commands/login.js')
310
+ _saveAuthData(data)
311
+ _writeKeyToEnv(data.license_key)
312
+ console.log((isTTY ? '\r' : '') + chalk.green(` ✓ Logged in as ${data.user.email}`) + chalk.dim(` · Plan: ${data.user.plan}\n`))
313
+ } catch (err) {
314
+ console.log((isTTY ? '\r' : '') + chalk.red(` ✗ Network error: ${err.message}\n`))
315
+ }
316
+ }
317
+
318
+ // -------------------------------------------------------------------
319
+ // Main REPL entry point
320
+ // -------------------------------------------------------------------
321
+
322
+ export async function startInteractive() {
323
+ _banner()
324
+
325
+ const rl = readline.createInterface({
326
+ input: process.stdin,
327
+ output: process.stdout,
328
+ prompt: chalk.cyan('logtrace') + chalk.dim(' › '),
329
+ })
330
+
331
+ const prompt = () => { try { rl.prompt() } catch { /* readline closed */ } }
332
+ prompt()
333
+
334
+ // All lines go through one sequential queue — no concurrency, no races
335
+ let _queue = Promise.resolve()
336
+ let _loginState = null // null = normal, or { step: S.METHOD|S.KEY }
337
+
338
+ rl.on('line', (line) => {
339
+ _queue = _queue.then(async () => {
340
+ const input = line.trim()
341
+
342
+ // ── Login state machine ─────────────────────────────────────
343
+ if (_loginState !== null) {
344
+ const done = await _loginHandleLine(line, _loginState)
345
+ if (done) {
346
+ _loginState = null
347
+ prompt()
348
+ }
349
+ // if not done, don't prompt — waiting for next line
350
+ return
351
+ }
352
+
353
+ // ── Normal dispatch ─────────────────────────────────────────
354
+ if (!input) {
355
+ prompt()
356
+ return
357
+ }
358
+
359
+ try {
360
+ const result = await _dispatch(input)
361
+ if (result === 'START_LOGIN') {
362
+ _loginState = { step: S.METHOD }
363
+ _showLoginPrompt()
364
+ // No prompt() here — login machine will prompt when done
365
+ } else {
366
+ prompt()
367
+ }
368
+ } catch (err) {
369
+ console.log(chalk.red(`\n Error: ${err.message}\n`))
370
+ prompt()
371
+ }
372
+ })
373
+ })
374
+
375
+ rl.on('SIGINT', () => {
376
+ console.log(chalk.dim('\n Bye!'))
377
+ process.exit(0)
378
+ })
379
+
380
+ // When stdin closes (Ctrl+D or EOF from a pipe), drain the queue first
381
+ rl.on('close', () => {
382
+ _queue.then(() => process.exit(0))
383
+ })
384
+ }