@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 +41 -0
- package/src/client.js +181 -0
- package/src/commands/alert.js +34 -0
- package/src/commands/config.js +306 -0
- package/src/commands/docker.js +139 -0
- package/src/commands/doctor.js +205 -0
- package/src/commands/login.js +227 -0
- package/src/commands/logs.js +135 -0
- package/src/commands/status.js +28 -0
- package/src/commands/update.js +598 -0
- package/src/index.js +235 -0
- package/src/interactive.js +384 -0
- package/src/ui/renderer.js +136 -0
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
|
+
}
|