@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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@logtrace/tracker",
3
+ "version": "0.1.0",
4
+ "description": "CLI oficial de LogTrace — gestiona el agente de observabilidad en tu VPS (status, logs, docker, alertas).",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "tracker": "src/index.js"
9
+ },
10
+ "files": ["src"],
11
+ "engines": {
12
+ "node": ">=20.0.0"
13
+ },
14
+ "keywords": ["logtrace", "observability", "logs", "monitoring", "vps", "cli", "tracker"],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/logtrace/tracker-cli.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "homepage": "https://logtrace.cloud",
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "start": "node src/index.js",
27
+ "test": "node --test test/**/*.test.js"
28
+ },
29
+ "dependencies": {
30
+ "@logtrace/shared": "^0.1.0",
31
+ "yargs": "^17.7.2",
32
+ "chalk": "^5.3.0",
33
+ "ora": "^8.0.1",
34
+ "cli-table3": "^0.6.5",
35
+ "inquirer": "^10.1.8",
36
+ "open": "^10.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "pkg": "^5.8.1"
40
+ }
41
+ }
package/src/client.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * packages/cli/src/client.js
3
+ *
4
+ * Unix socket client — the CLI's connection to the daemon.
5
+ *
6
+ * Usage:
7
+ * const client = createClient()
8
+ * await client.connect()
9
+ * const data = await client.send('status', {})
10
+ * client.close()
11
+ *
12
+ * Auto-start behavior:
13
+ * If the daemon is not running (ENOENT or ECONNREFUSED on the socket),
14
+ * the client will attempt to spawn the daemon process, wait 2 seconds
15
+ * for it to become ready, then retry the connection once.
16
+ *
17
+ * This means `tracker status` always works — even if the user hasn't
18
+ * explicitly started the daemon first.
19
+ */
20
+
21
+ import net from 'node:net'
22
+ import { spawn } from 'node:child_process'
23
+ import { existsSync } from 'node:fs'
24
+ import { resolve, dirname } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { SOCKET_PATH } from '@logtrace/shared/constants'
27
+
28
+ const __filename = fileURLToPath(import.meta.url)
29
+ const __dirname = dirname(__filename)
30
+
31
+ /** Path to the daemon entry point (relative to this file in the monorepo) */
32
+ const DAEMON_ENTRY = resolve(__dirname, '../../daemon/src/core/index.js')
33
+
34
+ // ── Client factory ────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * @returns {{ connect: Function, send: Function, close: Function }}
38
+ */
39
+ export function createClient() {
40
+ /** @type {net.Socket|null} */
41
+ let socket = null
42
+
43
+ /**
44
+ * connect() — connect to the daemon Unix socket.
45
+ * If the daemon is not running, spawns it and retries.
46
+ */
47
+ async function connect() {
48
+ try {
49
+ socket = await connectToSocket()
50
+ } catch (err) {
51
+ if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
52
+ // Daemon not running — start it
53
+ await spawnDaemon()
54
+ // Wait for daemon to initialize
55
+ await sleep(2000)
56
+ // Retry once
57
+ socket = await connectToSocket()
58
+ } else {
59
+ throw err
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * send(cmd, args) — send a command and wait for the response.
66
+ *
67
+ * @param {string} cmd
68
+ * @param {object} [args={}]
69
+ * @returns {Promise<any>} the `data` field from the response
70
+ * @throws {Error} if ok=false or connection fails
71
+ */
72
+ async function send(cmd, args = {}) {
73
+ if (!socket || socket.destroyed) {
74
+ throw new Error('Not connected. Call connect() first.')
75
+ }
76
+
77
+ return new Promise((resolve, reject) => {
78
+ const message = JSON.stringify({ cmd, args }) + '\n'
79
+
80
+ let buffer = ''
81
+
82
+ // One-shot listener: resolve on first complete response
83
+ const onData = (chunk) => {
84
+ buffer += chunk
85
+ const idx = buffer.indexOf('\n')
86
+ if (idx === -1) return // incomplete — keep buffering
87
+
88
+ const line = buffer.slice(0, idx).trim()
89
+ // Remove the one-shot listener before resolving
90
+ socket.removeListener('data', onData)
91
+
92
+ let response
93
+ try {
94
+ response = JSON.parse(line)
95
+ } catch (e) {
96
+ return reject(new Error(`Invalid JSON response: ${line}`))
97
+ }
98
+
99
+ if (!response.ok) {
100
+ return reject(new Error(response.error || 'Daemon error'))
101
+ }
102
+
103
+ resolve(response.data)
104
+ }
105
+
106
+ socket.on('data', onData)
107
+
108
+ socket.once('error', (err) => {
109
+ socket.removeListener('data', onData)
110
+ reject(err)
111
+ })
112
+
113
+ socket.write(message)
114
+ })
115
+ }
116
+
117
+ /**
118
+ * close() — gracefully close the socket connection.
119
+ */
120
+ function close() {
121
+ if (socket && !socket.destroyed) {
122
+ socket.end()
123
+ }
124
+ socket = null
125
+ }
126
+
127
+ return { connect, send, close }
128
+ }
129
+
130
+ // ── Helpers ───────────────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * connectToSocket() — open a connection to the Unix socket.
134
+ * Rejects with a Node.js system error (ENOENT, ECONNREFUSED, etc.) on failure.
135
+ *
136
+ * @returns {Promise<net.Socket>}
137
+ */
138
+ function connectToSocket() {
139
+ return new Promise((resolve, reject) => {
140
+ const sock = net.createConnection({ path: SOCKET_PATH }, () => {
141
+ sock.setEncoding('utf8')
142
+ resolve(sock)
143
+ })
144
+ sock.on('error', reject)
145
+ })
146
+ }
147
+
148
+ /**
149
+ * spawnDaemon() — start the daemon as a detached background process.
150
+ *
151
+ * The daemon inherits the current environment so LOGTRACE_ENV,
152
+ * LOGTRACE_DIR, and SOCKET_PATH are passed through automatically.
153
+ * stdout/stderr are ignored in CLI context — the daemon writes its
154
+ * own logs to file (configured in Paso 8+).
155
+ */
156
+ function spawnDaemon() {
157
+ if (!existsSync(DAEMON_ENTRY)) {
158
+ throw new Error(
159
+ `Daemon entry not found: ${DAEMON_ENTRY}\n` +
160
+ 'Make sure the monorepo is installed correctly.'
161
+ )
162
+ }
163
+
164
+ const child = spawn(process.execPath, [DAEMON_ENTRY], {
165
+ detached: true,
166
+ stdio: 'ignore',
167
+ })
168
+
169
+ child.unref() // allow CLI to exit even if daemon is still starting
170
+
171
+ return Promise.resolve()
172
+ }
173
+
174
+ /**
175
+ * sleep(ms) — promise-based delay.
176
+ * @param {number} ms
177
+ * @returns {Promise<void>}
178
+ */
179
+ function sleep(ms) {
180
+ return new Promise((resolve) => setTimeout(resolve, ms))
181
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * packages/cli/src/commands/alert.js
3
+ *
4
+ * tracker alert test — fire a test alert through the daemon's alert system.
5
+ *
6
+ * Triggers bus.emit('alert', ...) inside the daemon and reports whether
7
+ * Telegram (or other configured channels) received the message.
8
+ */
9
+
10
+ import chalk from 'chalk'
11
+ import ora from 'ora'
12
+ import { createClient } from '../client.js'
13
+ import { printError } from '../ui/renderer.js'
14
+
15
+ export async function alertTestCommand(_argv) {
16
+ const spinner = ora('Enviando alerta de prueba...').start()
17
+ const client = createClient()
18
+
19
+ try {
20
+ await client.connect()
21
+ const result = await client.send('alert.test', {})
22
+
23
+ if (result.sent) {
24
+ spinner.succeed(chalk.green('Alerta de prueba enviada correctamente.'))
25
+ } else {
26
+ spinner.warn(chalk.yellow(`Alerta no enviada: ${result.reason || 'sin canales configurados'}`))
27
+ }
28
+ } catch (err) {
29
+ spinner.fail(chalk.red(`Error al enviar alerta de prueba: ${err.message}`))
30
+ process.exitCode = 1
31
+ } finally {
32
+ client.close()
33
+ }
34
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * packages/cli/src/commands/config.js
3
+ *
4
+ * tracker config — read and write the LogTrace .env file directly from the CLI.
5
+ *
6
+ * This command operates on the .env file on disk without going through the
7
+ * daemon socket. That lets operators inspect and change config even when the
8
+ * daemon is stopped.
9
+ *
10
+ * Subcommands:
11
+ * tracker config list — show all key/value pairs (secrets masked)
12
+ * tracker config get <key> — print exact value (no masking)
13
+ * tracker config set <key> <val> — upsert a key
14
+ * tracker config unset <key> — remove a key
15
+ *
16
+ * Masking rule: any key whose name contains KEY, TOKEN, SECRET, or PASSWORD
17
+ * (case-insensitive) is shown as **** in `list`.
18
+ *
19
+ * Permissions: EACCES is caught and surfaced with a human-readable message.
20
+ */
21
+
22
+ import fs from 'node:fs/promises'
23
+ import path from 'node:path'
24
+ import chalk from 'chalk'
25
+ import { LOGTRACE_DIR } from '@logtrace/shared/constants'
26
+ import { printError, printWarning, printInfo, printTable } from '../ui/renderer.js'
27
+
28
+ // ── Path ──────────────────────────────────────────────────────────────────────
29
+
30
+ const ENV_PATH = path.join(LOGTRACE_DIR, '.env')
31
+
32
+ // ── Sensitive key pattern (case-insensitive) ──────────────────────────────────
33
+
34
+ const SENSITIVE_RE = /KEY|TOKEN|SECRET|PASSWORD/i
35
+
36
+ function isSensitive(key) {
37
+ return SENSITIVE_RE.test(key)
38
+ }
39
+
40
+ // ── .env parser (preserves comments and blank lines) ─────────────────────────
41
+
42
+ /**
43
+ * Parse an .env file into an ordered list of entries.
44
+ * Each entry is one of:
45
+ * { type: 'pair', key, value, raw } — KEY=value line
46
+ * { type: 'comment', raw } — # comment or blank line
47
+ *
48
+ * We keep all three fields so we can reconstruct the file faithfully.
49
+ */
50
+ function parseEnv(text) {
51
+ const entries = []
52
+
53
+ for (const line of text.split('\n')) {
54
+ const trimmed = line.trim()
55
+
56
+ // Blank line or comment
57
+ if (trimmed === '' || trimmed.startsWith('#')) {
58
+ entries.push({ type: 'comment', raw: line })
59
+ continue
60
+ }
61
+
62
+ // KEY=value — only the first '=' is the separator
63
+ const eqIdx = line.indexOf('=')
64
+ if (eqIdx === -1) {
65
+ // Malformed line — keep as comment so we don't lose it
66
+ entries.push({ type: 'comment', raw: line })
67
+ continue
68
+ }
69
+
70
+ const key = line.slice(0, eqIdx).trim()
71
+ const value = line.slice(eqIdx + 1) // value may contain '=' — do NOT trim
72
+
73
+ entries.push({ type: 'pair', key, value, raw: line })
74
+ }
75
+
76
+ return entries
77
+ }
78
+
79
+ /**
80
+ * Serialize entries back to .env text.
81
+ */
82
+ function serializeEnv(entries) {
83
+ return entries.map((e) => e.type === 'pair' ? `${e.key}=${e.value}` : e.raw).join('\n')
84
+ }
85
+
86
+ // ── File I/O helpers ──────────────────────────────────────────────────────────
87
+
88
+ async function readEnv() {
89
+ try {
90
+ const text = await fs.readFile(ENV_PATH, 'utf8')
91
+ return parseEnv(text)
92
+ } catch (err) {
93
+ if (err.code === 'ENOENT') return null // file does not exist
94
+ if (err.code === 'EACCES') throw new AccessError(ENV_PATH)
95
+ throw err
96
+ }
97
+ }
98
+
99
+ async function writeEnv(entries) {
100
+ try {
101
+ await fs.writeFile(ENV_PATH, serializeEnv(entries), 'utf8')
102
+ } catch (err) {
103
+ if (err.code === 'EACCES') throw new AccessError(ENV_PATH)
104
+ throw err
105
+ }
106
+ }
107
+
108
+ class AccessError extends Error {
109
+ constructor(filePath) {
110
+ super(`sin permisos para ${filePath}. Intenta con sudo.`)
111
+ this.name = 'AccessError'
112
+ }
113
+ }
114
+
115
+ // ── Commands ──────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * tracker config list
119
+ *
120
+ * Prints a two-column table: KEY | VALOR
121
+ * Sensitive values are shown as ****
122
+ * Comments and blank lines are ignored in the output.
123
+ */
124
+ export async function configListCommand(_argv) {
125
+ let entries
126
+
127
+ try {
128
+ entries = await readEnv()
129
+ } catch (err) {
130
+ printError(err.message)
131
+ process.exitCode = 1
132
+ return
133
+ }
134
+
135
+ if (entries === null) {
136
+ printInfo(`Archivo de config no encontrado: ${ENV_PATH}`)
137
+ printInfo('Usa "tracker config set <KEY> <VALUE>" para crear una clave.')
138
+ return
139
+ }
140
+
141
+ const pairs = entries.filter((e) => e.type === 'pair')
142
+
143
+ if (pairs.length === 0) {
144
+ printInfo('El archivo de config existe pero no contiene claves.')
145
+ return
146
+ }
147
+
148
+ const rows = pairs.map(({ key, value }) => {
149
+ const displayVal = isSensitive(key)
150
+ ? chalk.dim('****')
151
+ : chalk.white(value)
152
+ return [chalk.cyan(key), displayVal]
153
+ })
154
+
155
+ console.log()
156
+ console.log(chalk.bold(` Config: ${ENV_PATH}`))
157
+ console.log()
158
+ printTable(['KEY', 'VALOR'], rows)
159
+ }
160
+
161
+ /**
162
+ * tracker config get <key>
163
+ *
164
+ * Prints the raw value for <key>, no masking.
165
+ * Exits with code 1 if the key does not exist.
166
+ */
167
+ export async function configGetCommand(argv) {
168
+ const { key } = argv
169
+
170
+ let entries
171
+
172
+ try {
173
+ entries = await readEnv()
174
+ } catch (err) {
175
+ printError(err.message)
176
+ process.exitCode = 1
177
+ return
178
+ }
179
+
180
+ if (entries === null) {
181
+ printError(`Archivo no encontrado: ${ENV_PATH}`)
182
+ process.exitCode = 1
183
+ return
184
+ }
185
+
186
+ const found = entries.find((e) => e.type === 'pair' && e.key === key)
187
+
188
+ if (!found) {
189
+ printError(`Clave no encontrada: ${key}`)
190
+ process.exitCode = 1
191
+ return
192
+ }
193
+
194
+ // Raw output — no newline decoration so it can be captured via $()
195
+ process.stdout.write(found.value + '\n')
196
+ }
197
+
198
+ /**
199
+ * tracker config set <key> <value>
200
+ *
201
+ * Upserts the key in the .env file.
202
+ * - Existing key → replaces that line in-place (preserves file order)
203
+ * - New key → appended at the end
204
+ * - Comments and blank lines are preserved exactly
205
+ *
206
+ * Special warnings:
207
+ * - TELEGRAM_BOT_TOKEN → remind user to run `tracker alert test`
208
+ * - *PASSWORD* → remind user to restart the daemon
209
+ */
210
+ export async function configSetCommand(argv) {
211
+ const { key, value } = argv
212
+
213
+ // Validate key — must be a valid env var name
214
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
215
+ printError(`Nombre de clave inválido: "${key}". Usa solo letras, números y guiones bajos.`)
216
+ process.exitCode = 1
217
+ return
218
+ }
219
+
220
+ let entries
221
+
222
+ try {
223
+ entries = await readEnv()
224
+ } catch (err) {
225
+ printError(err.message)
226
+ process.exitCode = 1
227
+ return
228
+ }
229
+
230
+ // Start with an empty file if it doesn't exist yet
231
+ if (entries === null) {
232
+ entries = []
233
+ }
234
+
235
+ const existingIdx = entries.findIndex((e) => e.type === 'pair' && e.key === key)
236
+
237
+ if (existingIdx !== -1) {
238
+ // Replace in-place — keep surrounding comments intact
239
+ entries[existingIdx] = { type: 'pair', key, value, raw: `${key}=${value}` }
240
+ printInfo(`Clave actualizada: ${key}`)
241
+ } else {
242
+ // Append at the end
243
+ entries.push({ type: 'pair', key, value, raw: `${key}=${value}` })
244
+ printInfo(`Clave agregada: ${key}`)
245
+ }
246
+
247
+ try {
248
+ await writeEnv(entries)
249
+ } catch (err) {
250
+ printError(err.message)
251
+ process.exitCode = 1
252
+ return
253
+ }
254
+
255
+ // Special contextual warnings
256
+ if (key === 'TELEGRAM_BOT_TOKEN') {
257
+ printWarning('Tip: ejecuta tracker alert test para verificar la conexión con Telegram.')
258
+ } else if (/PASSWORD/i.test(key)) {
259
+ printWarning('Tip: reinicia el daemon para aplicar el nuevo valor.')
260
+ }
261
+ }
262
+
263
+ /**
264
+ * tracker config unset <key>
265
+ *
266
+ * Removes a key from the .env file.
267
+ * Blank lines and comments around the key are NOT removed.
268
+ * If the key is not present, prints an informational message (not an error).
269
+ */
270
+ export async function configUnsetCommand(argv) {
271
+ const { key } = argv
272
+
273
+ let entries
274
+
275
+ try {
276
+ entries = await readEnv()
277
+ } catch (err) {
278
+ printError(err.message)
279
+ process.exitCode = 1
280
+ return
281
+ }
282
+
283
+ if (entries === null) {
284
+ printInfo(`Archivo no encontrado: ${ENV_PATH}. No hay nada que eliminar.`)
285
+ return
286
+ }
287
+
288
+ const existingIdx = entries.findIndex((e) => e.type === 'pair' && e.key === key)
289
+
290
+ if (existingIdx === -1) {
291
+ printInfo(`La clave "${key}" no existe en el archivo de config.`)
292
+ return
293
+ }
294
+
295
+ entries.splice(existingIdx, 1)
296
+
297
+ try {
298
+ await writeEnv(entries)
299
+ } catch (err) {
300
+ printError(err.message)
301
+ process.exitCode = 1
302
+ return
303
+ }
304
+
305
+ printInfo(`Clave eliminada: ${key}`)
306
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * packages/cli/src/commands/docker.js
3
+ *
4
+ * tracker docker list — table of all containers
5
+ * tracker docker restart <name> — restart a container
6
+ * tracker docker logs <name> — tail container log lines
7
+ *
8
+ * State colors:
9
+ * running → green | exited → red | paused → yellow | other → dim
10
+ */
11
+
12
+ import chalk from 'chalk'
13
+ import ora from 'ora'
14
+ import { createClient } from '../client.js'
15
+ import { printTable, printError, printInfo } from '../ui/renderer.js'
16
+
17
+ // ── Image display ─────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Format a Docker image string for display in the table.
21
+ *
22
+ * Rules:
23
+ * - sha256:... → keep "sha256:" prefix + up to 20 hex chars + "..."
24
+ * e.g. sha256:74225daec0e0a2a371072e... → "sha256:74225daec0e0a2a37107..."
25
+ * - normal name (nginx:alpine, postgres:16) → show as-is up to 40 chars,
26
+ * truncate with "..." if longer
27
+ */
28
+ function formatImage(image) {
29
+ if (!image || image === '-') return '-'
30
+
31
+ if (image.startsWith('sha256:')) {
32
+ const hash = image.slice('sha256:'.length)
33
+ const truncatedHash = hash.slice(0, 20)
34
+ return `sha256:${truncatedHash}...`
35
+ }
36
+
37
+ if (image.length > 40) {
38
+ return image.slice(0, 37) + '...'
39
+ }
40
+
41
+ return image
42
+ }
43
+
44
+ // ── State coloring ────────────────────────────────────────────────────────────
45
+
46
+ function colorState(state) {
47
+ switch (state) {
48
+ case 'running': return chalk.green(state)
49
+ case 'exited': return chalk.red(state)
50
+ case 'paused': return chalk.yellow(state)
51
+ default: return chalk.dim(state || '?')
52
+ }
53
+ }
54
+
55
+ // ── Handlers ──────────────────────────────────────────────────────────────────
56
+
57
+ export async function dockerListCommand(_argv) {
58
+ const client = createClient()
59
+ try {
60
+ await client.connect()
61
+ const containers = await client.send('docker.list', {})
62
+
63
+ if (!Array.isArray(containers) || containers.length === 0) {
64
+ printInfo('No hay containers en este sistema.')
65
+ return
66
+ }
67
+
68
+ const rows = containers.map((c) => [
69
+ chalk.bold(c.name || '-'),
70
+ chalk.dim(formatImage(c.image)),
71
+ colorState(c.state),
72
+ chalk.dim((c.ports || []).join(', ') || '-'),
73
+ ])
74
+
75
+ console.log()
76
+ printTable(['Nombre', 'Imagen', 'Estado', 'Puertos'], rows)
77
+ } catch (err) {
78
+ printError(`Error al listar containers: ${err.message}`)
79
+ process.exitCode = 1
80
+ } finally {
81
+ client.close()
82
+ }
83
+ }
84
+
85
+ export async function dockerRestartCommand(argv) {
86
+ const name = argv.name
87
+ if (!name) {
88
+ printError('Especificá el nombre del container.')
89
+ process.exitCode = 1
90
+ return
91
+ }
92
+
93
+ const spinner = ora(`Reiniciando ${chalk.bold(name)}...`).start()
94
+ const client = createClient()
95
+
96
+ try {
97
+ await client.connect()
98
+ await client.send('docker.restart', { name })
99
+ spinner.succeed(chalk.green(`Container ${chalk.bold(name)} reiniciado`))
100
+ } catch (err) {
101
+ spinner.fail(chalk.red(`Error al reiniciar ${name}: ${err.message}`))
102
+ process.exitCode = 1
103
+ } finally {
104
+ client.close()
105
+ }
106
+ }
107
+
108
+ export async function dockerLogsCommand(argv) {
109
+ const name = argv.name
110
+ const tail = argv.tail ?? 50
111
+
112
+ if (!name) {
113
+ printError('Especificá el nombre del container.')
114
+ process.exitCode = 1
115
+ return
116
+ }
117
+
118
+ const client = createClient()
119
+ try {
120
+ await client.connect()
121
+ const result = await client.send('docker.logs', { name, tail: Number(tail) })
122
+
123
+ if (!result.lines || result.lines.length === 0) {
124
+ printInfo(`No hay logs para el container "${name}".`)
125
+ return
126
+ }
127
+
128
+ console.log()
129
+ for (const line of result.lines) {
130
+ console.log(chalk.gray(line))
131
+ }
132
+ console.log()
133
+ } catch (err) {
134
+ printError(`Error al obtener logs de ${name}: ${err.message}`)
135
+ process.exitCode = 1
136
+ } finally {
137
+ client.close()
138
+ }
139
+ }