@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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * packages/cli/src/commands/doctor.js
3
+ *
4
+ * tracker doctor — run all system health checks and display results as a table.
5
+ * tracker health — show only problems (warn + critical), or "sistema saludable".
6
+ *
7
+ * Both commands share the same daemon call (doctor.run) and differ only in
8
+ * how they filter and display the results.
9
+ */
10
+
11
+ import chalk from 'chalk'
12
+ import ora from 'ora'
13
+ import Table from 'cli-table3'
14
+ import { createClient } from '../client.js'
15
+ import { printError } from '../ui/renderer.js'
16
+
17
+ // ── Status icons and colors ───────────────────────────────────────────────────
18
+
19
+ const STATUS_ICON = {
20
+ ok: chalk.green('✔'),
21
+ warn: chalk.yellow('!'),
22
+ critical: chalk.red('✖'),
23
+ }
24
+
25
+ const STATUS_LABEL = {
26
+ ok: chalk.green('ok'),
27
+ warn: chalk.yellow('warn'),
28
+ critical: chalk.red('critical'),
29
+ }
30
+
31
+ function statusIcon(status) {
32
+ return STATUS_ICON[status] ?? chalk.dim('?')
33
+ }
34
+
35
+ function statusLabel(status) {
36
+ return STATUS_LABEL[status] ?? chalk.dim(status)
37
+ }
38
+
39
+ // ── Shared fetch ──────────────────────────────────────────────────────────────
40
+
41
+ async function fetchChecks() {
42
+ const client = createClient()
43
+ try {
44
+ await client.connect()
45
+ const result = await client.send('doctor.run', {})
46
+ return { checks: result.checks, client }
47
+ } catch (err) {
48
+ client.close()
49
+ throw err
50
+ }
51
+ }
52
+
53
+ // ── tracker doctor ────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * doctorCommand — full diagnostic table.
57
+ *
58
+ * Displays all 9 checks with their status and message.
59
+ * Shows a summary line at the end: "X/9 checks OK • Y advertencias • Z críticos"
60
+ *
61
+ * Example output:
62
+ * ┌────────────────┬──────────┬───────────────────────────────────────────┬─────────────────────────────┐
63
+ * │ Check │ Estado │ Mensaje │ Solución │
64
+ * ├────────────────┼──────────┼───────────────────────────────────────────┼─────────────────────────────┤
65
+ * │ ✔ disk_space │ ok │ Disco / al 45.2% │ │
66
+ * │ ! swap_enabled │ warn │ Sin swap configurado │ │
67
+ * │ ✖ last_backup │ critical │ Sin backups configurados │ tracker backup create │
68
+ * └────────────────┴──────────┴───────────────────────────────────────────┴─────────────────────────────┘
69
+ * X/9 checks OK • Y advertencias • Z críticos
70
+ */
71
+ export async function doctorCommand(_argv) {
72
+ const spinner = ora('Ejecutando checks...').start()
73
+ let client = null
74
+
75
+ try {
76
+ const { checks, client: c } = await fetchChecks()
77
+ client = c
78
+
79
+ spinner.stop()
80
+
81
+ const table = new Table({
82
+ head: [
83
+ chalk.bold.white('Check'),
84
+ chalk.bold.white('Estado'),
85
+ chalk.bold.white('Mensaje'),
86
+ chalk.bold.white('Solución'),
87
+ ],
88
+ style: { head: [], border: [] },
89
+ chars: {
90
+ 'top': '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
91
+ 'bottom': '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
92
+ 'left': '│', 'left-mid': '├', 'mid': '─', 'mid-mid': '┼',
93
+ 'right': '│', 'right-mid': '┤', 'middle': '│',
94
+ },
95
+ colWidths: [22, 12, 42, 30],
96
+ wordWrap: true,
97
+ })
98
+
99
+ let okCount = 0
100
+ let warnCount = 0
101
+ let criticalCount = 0
102
+
103
+ for (const check of checks) {
104
+ const icon = statusIcon(check.status)
105
+ const label = statusLabel(check.status)
106
+ const name = `${icon} ${chalk.bold(check.name)}`
107
+ const message = check.message || ''
108
+ const fix = check.fix_cmd ? chalk.dim(check.fix_cmd) : ''
109
+
110
+ table.push([name, label, message, fix])
111
+
112
+ if (check.status === 'ok') okCount++
113
+ else if (check.status === 'warn') warnCount++
114
+ else if (check.status === 'critical') criticalCount++
115
+ }
116
+
117
+ console.log()
118
+ console.log(table.toString())
119
+
120
+ // Summary line
121
+ const total = checks.length
122
+ const okStr = chalk.green(`${okCount}/${total} checks OK`)
123
+ const warnStr = warnCount > 0
124
+ ? chalk.yellow(`${warnCount} advertencia${warnCount > 1 ? 's' : ''}`)
125
+ : chalk.dim('0 advertencias')
126
+ const critStr = criticalCount > 0
127
+ ? chalk.red(`${criticalCount} crítico${criticalCount > 1 ? 's' : ''}`)
128
+ : chalk.dim('0 críticos')
129
+
130
+ console.log(` ${okStr} • ${warnStr} • ${critStr}`)
131
+ console.log()
132
+
133
+ // Exit with non-zero code if there are critical issues
134
+ if (criticalCount > 0) {
135
+ process.exitCode = 1
136
+ }
137
+ } catch (err) {
138
+ spinner.fail(chalk.red('Error al ejecutar diagnóstico'))
139
+ printError(err.message, err)
140
+ process.exitCode = 1
141
+ } finally {
142
+ if (client) client.close()
143
+ }
144
+ }
145
+
146
+ // ── tracker health ────────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * healthCommand — compact view of problems only.
150
+ *
151
+ * Filters out OK checks. Shows one line per issue.
152
+ * If everything is OK, prints a single success line.
153
+ *
154
+ * Example output (problems exist):
155
+ * ! swap_enabled Sin swap configurado
156
+ * ✖ last_backup Sin backups configurados tracker backup create
157
+ *
158
+ * Example output (all OK):
159
+ * ✔ Sistema saludable
160
+ */
161
+ export async function healthCommand(_argv) {
162
+ const spinner = ora('Verificando sistema...').start()
163
+ let client = null
164
+
165
+ try {
166
+ const { checks, client: c } = await fetchChecks()
167
+ client = c
168
+
169
+ spinner.stop()
170
+
171
+ const problems = checks.filter((ch) => ch.status !== 'ok')
172
+
173
+ if (problems.length === 0) {
174
+ console.log()
175
+ console.log(` ${chalk.green('✔')} ${chalk.bold.green('Sistema saludable')}`)
176
+ console.log()
177
+ return
178
+ }
179
+
180
+ console.log()
181
+
182
+ for (const check of problems) {
183
+ const icon = statusIcon(check.status)
184
+ const name = chalk.bold(check.name.padEnd(18))
185
+ const message = check.message || ''
186
+ const fix = check.fix_cmd ? ` ${chalk.dim(check.fix_cmd)}` : ''
187
+
188
+ console.log(` ${icon} ${name} ${message}${fix}`)
189
+ }
190
+
191
+ console.log()
192
+
193
+ // Exit code 1 if any critical
194
+ const hasCritical = problems.some((ch) => ch.status === 'critical')
195
+ if (hasCritical) {
196
+ process.exitCode = 1
197
+ }
198
+ } catch (err) {
199
+ spinner.fail(chalk.red('Error al verificar sistema'))
200
+ printError(err.message, err)
201
+ process.exitCode = 1
202
+ } finally {
203
+ if (client) client.close()
204
+ }
205
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * packages/cli/src/commands/login.js
3
+ *
4
+ * tracker login — browser-based auth (desktop / laptop)
5
+ * tracker login --key <k> — direct key auth (VPS / headless servers)
6
+ *
7
+ * Browser flow:
8
+ * 1. POST /api/v1/cli/auth/init/ → { session_id, auth_url }
9
+ * 2. Open auth_url in browser
10
+ * 3. Poll GET /api/v1/cli/auth/poll/{session_id}/ every 2s (timeout 5 min)
11
+ * 4. On complete: save ~/.logtrace/auth.json + write LOGTRACE_LICENSE_KEY to daemon .env
12
+ *
13
+ * Key flow:
14
+ * 1. POST /api/v1/cli/auth/key-login/ { license_key }
15
+ * 2. On success: same save/write as browser flow
16
+ */
17
+
18
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import open from 'open'
22
+ import ora from 'ora'
23
+ import chalk from 'chalk'
24
+ import { LOGTRACE_DIR } from '@logtrace/shared/constants'
25
+
26
+ // -------------------------------------------------------------------
27
+ // Config
28
+ // -------------------------------------------------------------------
29
+ const AUTH_FILE = join(homedir(), '.logtrace', 'auth.json')
30
+ const POLL_INTERVAL = 2_000 // 2 seconds
31
+ const POLL_TIMEOUT = 5 * 60 * 1_000 // 5 minutes
32
+
33
+ /**
34
+ * Resolve the central API URL.
35
+ * Priority: process.env > daemon .env > production default.
36
+ */
37
+ function _getCentralApiUrl() {
38
+ if (process.env.CENTRAL_API_URL) return process.env.CENTRAL_API_URL
39
+
40
+ const envPath = join(LOGTRACE_DIR, '.env')
41
+ if (existsSync(envPath)) {
42
+ const lines = readFileSync(envPath, 'utf8').split('\n')
43
+ const line = lines.find((l) => l.startsWith('CENTRAL_API_URL='))
44
+ if (line) return line.slice('CENTRAL_API_URL='.length).trim()
45
+ }
46
+
47
+ return 'https://api.logtrace.cloud'
48
+ }
49
+
50
+ // -------------------------------------------------------------------
51
+ // Main command
52
+ // -------------------------------------------------------------------
53
+ export async function loginCommand(argv) {
54
+ const key = argv.key || argv.k || null
55
+
56
+ if (key) {
57
+ await _loginWithKey(key)
58
+ } else {
59
+ await _loginWithBrowser()
60
+ }
61
+ }
62
+
63
+ // -------------------------------------------------------------------
64
+ // Key-based login (for VPS / headless)
65
+ // -------------------------------------------------------------------
66
+ async function _loginWithKey(licenseKey) {
67
+ const CENTRAL_API_URL = _getCentralApiUrl()
68
+ const spinner = ora('Verifying license key...').start()
69
+
70
+ let data
71
+ try {
72
+ const res = await fetch(`${CENTRAL_API_URL}/api/v1/cli/auth/key-login/`, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ license_key: licenseKey }),
76
+ })
77
+
78
+ data = await res.json()
79
+
80
+ if (!res.ok) {
81
+ const msg = data.message || data.detail || 'Invalid or inactive license key.'
82
+ spinner.fail(msg)
83
+ return
84
+ }
85
+ } catch (err) {
86
+ spinner.fail(`Could not connect to server: ${err.message}`)
87
+ return
88
+ }
89
+
90
+ spinner.stop()
91
+
92
+ _saveAuth(data)
93
+ _writeKeyToEnv(data.license_key)
94
+
95
+ console.log(chalk.green(`\n Logged in as ${data.user.email}`))
96
+ console.log(
97
+ chalk.dim(` Plan: ${data.user.plan} • License: ${data.license_key.slice(0, 12)}...`),
98
+ )
99
+ }
100
+
101
+ // -------------------------------------------------------------------
102
+ // Browser-based login (for desktop)
103
+ // -------------------------------------------------------------------
104
+ async function _loginWithBrowser() {
105
+ const CENTRAL_API_URL = _getCentralApiUrl()
106
+
107
+ // ── Step 1: Init session ──────────────────────────────────────────
108
+ const initSpinner = ora('Connecting to LogTrace...').start()
109
+
110
+ let session
111
+ try {
112
+ const res = await fetch(`${CENTRAL_API_URL}/api/v1/cli/auth/init/`, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ })
116
+ if (!res.ok) throw new Error(`Server returned HTTP ${res.status}`)
117
+ session = await res.json()
118
+ } catch (err) {
119
+ initSpinner.fail(`Could not connect to server: ${err.message}`)
120
+ return
121
+ }
122
+
123
+ initSpinner.stop()
124
+
125
+ // ── Step 2: Open browser ─────────────────────────────────────────
126
+ console.log(chalk.cyan('\nOpening browser to log in...'))
127
+ console.log(chalk.dim(` If the browser doesn't open automatically:\n ${session.auth_url}\n`))
128
+
129
+ try {
130
+ await open(session.auth_url)
131
+ } catch {
132
+ // Graceful degradation — the user already has the manual URL above
133
+ }
134
+
135
+ // ── Step 3: Poll for completion ───────────────────────────────────
136
+ const pollSpinner = ora('Waiting for browser authentication...').start()
137
+ const deadline = Date.now() + POLL_TIMEOUT
138
+
139
+ while (Date.now() < deadline) {
140
+ await _sleep(POLL_INTERVAL)
141
+
142
+ let res, data
143
+ try {
144
+ res = await fetch(`${CENTRAL_API_URL}/api/v1/cli/auth/poll/${session.session_id}/`)
145
+ data = await res.json()
146
+ } catch {
147
+ // Network hiccup — keep polling
148
+ continue
149
+ }
150
+
151
+ if (res.status === 404) {
152
+ pollSpinner.fail('Session expired. Run tracker login to try again.')
153
+ return
154
+ }
155
+
156
+ if (!res.ok) {
157
+ // Unexpected error — log and keep polling rather than silently looping
158
+ pollSpinner.text = `Waiting for browser authentication... (server: ${res.status})`
159
+ continue
160
+ }
161
+
162
+ if (data.status === 'complete') {
163
+ pollSpinner.stop()
164
+
165
+ // ── Step 4: Persist credentials ───────────────────────────────
166
+ _saveAuth(data)
167
+ _writeKeyToEnv(data.license_key)
168
+
169
+ console.log(chalk.green(`\n Logged in as ${data.user.email}`))
170
+ console.log(
171
+ chalk.dim(` Plan: ${data.user.plan} • License: ${data.license_key.slice(0, 12)}...`),
172
+ )
173
+ return
174
+ }
175
+
176
+ // status === 'pending' — keep waiting
177
+ }
178
+
179
+ pollSpinner.fail('Timed out waiting for login. Run tracker login to try again.')
180
+ }
181
+
182
+ // -------------------------------------------------------------------
183
+ // Helpers
184
+ // -------------------------------------------------------------------
185
+
186
+ export function _saveAuthData(data) {
187
+ const dir = join(homedir(), '.logtrace')
188
+ mkdirSync(dir, { recursive: true })
189
+
190
+ const payload = {
191
+ email: data.user.email,
192
+ name: data.user.name,
193
+ plan: data.user.plan,
194
+ license_key: data.license_key,
195
+ saved_at: new Date().toISOString(),
196
+ }
197
+
198
+ writeFileSync(AUTH_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 })
199
+ }
200
+
201
+ // keep the old private name pointing to the export so internal calls still work
202
+ const _saveAuth = _saveAuthData
203
+
204
+ export function _writeKeyToEnv(licenseKey) {
205
+ const envPath = join(LOGTRACE_DIR, '.env')
206
+ if (!existsSync(envPath)) return
207
+
208
+ const content = readFileSync(envPath, 'utf8')
209
+ const lines = content.split('\n')
210
+ const idx = lines.findIndex((l) => l.startsWith('LOGTRACE_LICENSE_KEY='))
211
+
212
+ if (idx !== -1) {
213
+ lines[idx] = `LOGTRACE_LICENSE_KEY=${licenseKey}`
214
+ } else {
215
+ if (lines[lines.length - 1] !== '') {
216
+ lines.push(`LOGTRACE_LICENSE_KEY=${licenseKey}`)
217
+ } else {
218
+ lines.splice(lines.length - 1, 0, `LOGTRACE_LICENSE_KEY=${licenseKey}`)
219
+ }
220
+ }
221
+
222
+ writeFileSync(envPath, lines.join('\n'), { mode: 0o640 })
223
+ }
224
+
225
+ function _sleep(ms) {
226
+ return new Promise((resolve) => setTimeout(resolve, ms))
227
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * packages/cli/src/commands/logs.js
3
+ *
4
+ * tracker logs list — query stored logs with optional filters
5
+ * tracker logs search <query> — full-text search over message + service
6
+ *
7
+ * The --from flag accepts human-friendly durations (1h, 30m, 2d).
8
+ * Parsing is intentionally simple: no external library needed for the
9
+ * values we expect in practice.
10
+ */
11
+
12
+ import chalk from 'chalk'
13
+ import { createClient } from '../client.js'
14
+ import { printError } from '../ui/renderer.js'
15
+ import { formatDate } from '@logtrace/shared/utils'
16
+
17
+ // ── Level colors ──────────────────────────────────────────────────────────────
18
+
19
+ const LEVEL_COLOR = {
20
+ debug: (s) => chalk.gray(s),
21
+ info: (s) => chalk.cyan(s),
22
+ warn: (s) => chalk.yellow(s),
23
+ error: (s) => chalk.red(s),
24
+ critical: (s) => chalk.bgRed.white(s),
25
+ }
26
+
27
+ function colorLevel(level) {
28
+ const fn = LEVEL_COLOR[level] ?? ((s) => s)
29
+ return fn(`[${level.toUpperCase().padEnd(8)}]`)
30
+ }
31
+
32
+ // ── Duration parser ───────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * parseDuration(str) — convert "1h", "30m", "2d" to milliseconds.
36
+ * Returns null if the string doesn't match any known pattern.
37
+ *
38
+ * @param {string} str
39
+ * @returns {number|null}
40
+ */
41
+ function parseDuration(str) {
42
+ if (!str || typeof str !== 'string') return null
43
+ const match = str.trim().match(/^(\d+)(s|m|h|d)$/)
44
+ if (!match) return null
45
+ const value = parseInt(match[1], 10)
46
+ const unit = match[2]
47
+ const multipliers = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
48
+ return value * multipliers[unit]
49
+ }
50
+
51
+ // ── Output renderer ───────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * printLogs(logs) — render a list of log entries as a plain text table.
55
+ *
56
+ * Columns: timestamp level service message
57
+ * Widths are fixed so the output is scannable without a table library.
58
+ *
59
+ * @param {object[]} logs
60
+ */
61
+ function printLogs(logs) {
62
+ if (!logs || logs.length === 0) {
63
+ console.log(chalk.dim(' No hay logs que coincidan con los filtros.'))
64
+ console.log()
65
+ return
66
+ }
67
+
68
+ console.log()
69
+ for (const log of logs) {
70
+ const ts = formatDate(log.timestamp)
71
+ const level = colorLevel(log.level)
72
+ const service = chalk.dim((log.service || '-').padEnd(12).slice(0, 12))
73
+ const message = log.message
74
+
75
+ console.log(`${chalk.dim(ts)} ${level} ${service} ${message}`)
76
+ }
77
+ console.log()
78
+ }
79
+
80
+ // ── Command handlers ──────────────────────────────────────────────────────────
81
+
82
+ export async function logsListCommand(argv) {
83
+ const args = {}
84
+
85
+ if (argv.level) args.level = argv.level
86
+ if (argv.service) args.service = argv.service
87
+ if (argv.limit) args.limit = Number(argv.limit)
88
+
89
+ if (argv.from) {
90
+ const ms = parseDuration(String(argv.from))
91
+ if (ms === null) {
92
+ printError(`Formato de --from inválido: "${argv.from}". Usar: 1h, 30m, 1d`)
93
+ process.exitCode = 1
94
+ return
95
+ }
96
+ args.from = Date.now() - ms
97
+ }
98
+
99
+ const client = createClient()
100
+ try {
101
+ await client.connect()
102
+ const logs = await client.send('logs.query', args)
103
+ printLogs(logs)
104
+ } catch (err) {
105
+ printError(`Error al consultar logs: ${err.message}`)
106
+ process.exitCode = 1
107
+ } finally {
108
+ client.close()
109
+ }
110
+ }
111
+
112
+ export async function logsSearchCommand(argv) {
113
+ const args = {
114
+ q: String(argv.query || ''),
115
+ limit: argv.limit ? Number(argv.limit) : 20,
116
+ }
117
+
118
+ if (!args.q.trim()) {
119
+ printError('El argumento <query> no puede estar vacío.')
120
+ process.exitCode = 1
121
+ return
122
+ }
123
+
124
+ const client = createClient()
125
+ try {
126
+ await client.connect()
127
+ const logs = await client.send('logs.search', args)
128
+ printLogs(logs)
129
+ } catch (err) {
130
+ printError(`Error al buscar logs: ${err.message}`)
131
+ process.exitCode = 1
132
+ } finally {
133
+ client.close()
134
+ }
135
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * packages/cli/src/commands/status.js
3
+ *
4
+ * tracker status — show daemon version, uptime, and active modules.
5
+ *
6
+ * Example output:
7
+ *
8
+ * LogTrace v0.1.0 | uptime: 3m 12s | modules: 0
9
+ */
10
+
11
+ import { createClient } from '../client.js'
12
+ import { printStatus, printError } from '../ui/renderer.js'
13
+
14
+ export async function statusCommand(_argv) {
15
+ const client = createClient()
16
+
17
+ try {
18
+ await client.connect()
19
+ const data = await client.send('status', {})
20
+ printStatus(data)
21
+ } catch (err) {
22
+ printError(`Cannot reach daemon: ${err.message}`)
23
+ printError('Run "npm run dev:daemon" to start the daemon first.')
24
+ process.exitCode = 1
25
+ } finally {
26
+ client.close()
27
+ }
28
+ }