@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,598 @@
1
+ /**
2
+ * packages/cli/src/commands/update.js
3
+ *
4
+ * tracker update — interactive update flow
5
+ * tracker update --check — show available version without installing
6
+ *
7
+ * Flow:
8
+ * 1. Fetch latest release from GitHub API
9
+ * 2. Compare tag against VERSION constant
10
+ * 3. If up-to-date: inform and exit
11
+ * 4. If update available:
12
+ * --check → show version + first 3 changelog lines
13
+ * (no flag) → show full changelog → confirm → download → verify SHA256
14
+ * → backup current binary → replace → systemctl restart
15
+ * → health-check → rollback on failure
16
+ *
17
+ * Background update hint:
18
+ * Every command calls checkUpdateBackground() which:
19
+ * - Reads /tmp/.logtrace-update-check (TTL 4 hours)
20
+ * - If stale: fires a detached check in background (does NOT block)
21
+ * - If cached update available: prints a yellow hint line
22
+ *
23
+ * Security:
24
+ * - SHA256 is verified against SHA256SUMS asset in the same release
25
+ * - Binary is not executed before verification passes
26
+ * - Backup is kept as <binary>.bak so rollback is always possible
27
+ */
28
+
29
+ import fs from 'node:fs'
30
+ import fsp from 'node:fs/promises'
31
+ import path from 'node:path'
32
+ import https from 'node:https'
33
+ import crypto from 'node:crypto'
34
+ import { execFile } from 'node:child_process'
35
+ import { promisify } from 'node:util'
36
+ import chalk from 'chalk'
37
+ import ora from 'ora'
38
+ import { confirm } from '@inquirer/prompts'
39
+ import { VERSION } from '@logtrace/shared/constants'
40
+ import { createClient } from '../client.js'
41
+ import { printError, printWarning, printInfo } from '../ui/renderer.js'
42
+
43
+ const execFileAsync = promisify(execFile)
44
+
45
+ // ── Constants ─────────────────────────────────────────────────────────────────
46
+
47
+ const GITHUB_API = 'https://api.github.com/repos/logtrace-cloud/tracker/releases/latest'
48
+ const UPDATE_CACHE = '/tmp/.logtrace-update-check'
49
+ const CACHE_TTL_MS = 4 * 60 * 60 * 1000 // 4 hours
50
+
51
+ // The installed tracker binary path.
52
+ // process.execPath is the Node binary in dev; in pkg-compiled mode it is the
53
+ // actual tracker binary. We detect pkg by checking if the exec path looks like
54
+ // a tracker binary rather than a node/node26 executable.
55
+ function resolvedBinaryPath() {
56
+ const ep = process.execPath
57
+ if (path.basename(ep).startsWith('tracker')) return ep
58
+ // Fallback for dev mode: look for the binary next to the script
59
+ return ep // will be handled gracefully — installer replaces the right file
60
+ }
61
+
62
+ // ── HTTP helper ───────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * httpGet(url, options) → Promise<{ body: string, headers: object }>
66
+ *
67
+ * Simple promise-based HTTPS GET with timeout. Follows one redirect.
68
+ * Does NOT use fetch so we can control timeout precisely.
69
+ */
70
+ function httpGet(url, { timeout = 5000, headers = {} } = {}) {
71
+ return new Promise((resolve, reject) => {
72
+ const opts = new URL(url)
73
+
74
+ const req = https.request(
75
+ {
76
+ hostname: opts.hostname,
77
+ path: opts.pathname + opts.search,
78
+ method: 'GET',
79
+ headers: {
80
+ 'User-Agent': `tracker-cli/${VERSION}`,
81
+ 'Accept': 'application/vnd.github+json',
82
+ ...headers,
83
+ },
84
+ },
85
+ (res) => {
86
+ // Follow a single redirect (e.g. GitHub asset download)
87
+ if (res.statusCode >= 301 && res.statusCode <= 302 && res.headers.location) {
88
+ resolve(httpGet(res.headers.location, { timeout, headers }))
89
+ res.resume()
90
+ return
91
+ }
92
+
93
+ const chunks = []
94
+ res.on('data', (c) => chunks.push(c))
95
+ res.on('end', () => {
96
+ resolve({
97
+ status: res.statusCode,
98
+ headers: res.headers,
99
+ body: Buffer.concat(chunks).toString('utf8'),
100
+ })
101
+ })
102
+ },
103
+ )
104
+
105
+ req.setTimeout(timeout, () => {
106
+ req.destroy()
107
+ reject(new Error('timeout'))
108
+ })
109
+
110
+ req.on('error', reject)
111
+ req.end()
112
+ })
113
+ }
114
+
115
+ /**
116
+ * downloadToFile(url, destPath) → Promise<void>
117
+ *
118
+ * Stream a binary file from url to destPath.
119
+ * Follows redirects.
120
+ */
121
+ function downloadToFile(url, destPath) {
122
+ return new Promise((resolve, reject) => {
123
+ const opts = new URL(url)
124
+
125
+ const req = https.request(
126
+ {
127
+ hostname: opts.hostname,
128
+ path: opts.pathname + opts.search,
129
+ method: 'GET',
130
+ headers: { 'User-Agent': `tracker-cli/${VERSION}` },
131
+ },
132
+ (res) => {
133
+ if (res.statusCode >= 301 && res.statusCode <= 302 && res.headers.location) {
134
+ resolve(downloadToFile(res.headers.location, destPath))
135
+ res.resume()
136
+ return
137
+ }
138
+
139
+ if (res.statusCode !== 200) {
140
+ reject(new Error(`HTTP ${res.statusCode} descargando asset`))
141
+ res.resume()
142
+ return
143
+ }
144
+
145
+ const out = fs.createWriteStream(destPath)
146
+ res.pipe(out)
147
+ out.on('finish', resolve)
148
+ out.on('error', reject)
149
+ res.on('error', reject)
150
+ },
151
+ )
152
+
153
+ req.on('error', reject)
154
+ req.end()
155
+ })
156
+ }
157
+
158
+ // ── GitHub release helpers ────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * fetchLatestRelease() → Promise<release | null>
162
+ *
163
+ * Returns the parsed GitHub release object or null if network unavailable.
164
+ */
165
+ async function fetchLatestRelease() {
166
+ try {
167
+ const res = await httpGet(GITHUB_API, { timeout: 5000 })
168
+
169
+ if (res.status !== 200) return null
170
+
171
+ return JSON.parse(res.body)
172
+ } catch {
173
+ return null
174
+ }
175
+ }
176
+
177
+ /**
178
+ * compareVersions(current, latest) → boolean
179
+ *
180
+ * Returns true if latest is strictly newer than current.
181
+ * Uses simple numeric semver comparison (major.minor.patch).
182
+ */
183
+ function isNewer(current, latest) {
184
+ // Strip leading 'v'
185
+ const parse = (v) => v.replace(/^v/, '').split('.').map(Number)
186
+ const [cMaj, cMin, cPat] = parse(current)
187
+ const [lMaj, lMin, lPat] = parse(latest)
188
+
189
+ if (lMaj !== cMaj) return lMaj > cMaj
190
+ if (lMin !== cMin) return lMin > cMin
191
+ return lPat > cPat
192
+ }
193
+
194
+ /**
195
+ * firstNLines(text, n) — return first n non-blank lines.
196
+ */
197
+ function firstNLines(text, n) {
198
+ return (text || '')
199
+ .split('\n')
200
+ .filter((l) => l.trim() !== '')
201
+ .slice(0, n)
202
+ .join('\n')
203
+ }
204
+
205
+ // ── OS/arch detection ─────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * detectAssetName(assets) → string | null
209
+ *
210
+ * Matches the correct binary asset for the current platform.
211
+ * Supported targets: linux-x64, linux-arm64.
212
+ */
213
+ function detectAssetName(assets) {
214
+ const { platform, arch } = process
215
+
216
+ let suffix = null
217
+
218
+ if (platform === 'linux' && arch === 'x64') suffix = 'linux-x64'
219
+ if (platform === 'linux' && arch === 'arm64') suffix = 'linux-arm64'
220
+ if (platform === 'darwin' && arch === 'x64') suffix = 'darwin-x64'
221
+ if (platform === 'darwin' && arch === 'arm64') suffix = 'darwin-arm64'
222
+
223
+ if (!suffix) return null
224
+
225
+ // Asset naming convention: tracker-linux-x64, tracker-linux-arm64, etc.
226
+ return assets.find((a) => a.name === `tracker-${suffix}`) || null
227
+ }
228
+
229
+ // ── SHA256 verification ───────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * verifySha256(filePath, sumsContent, assetName) → boolean
233
+ *
234
+ * Reads SHA256SUMS content (one line per asset: <hash> <name>)
235
+ * and verifies the downloaded file matches.
236
+ */
237
+ async function verifySha256(filePath, sumsContent, assetName) {
238
+ const line = sumsContent
239
+ .split('\n')
240
+ .find((l) => l.trim().endsWith(assetName))
241
+
242
+ if (!line) return false // asset not in SUMS file
243
+
244
+ const expectedHash = line.trim().split(/\s+/)[0]
245
+
246
+ const fileBuffer = await fsp.readFile(filePath)
247
+ const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex')
248
+
249
+ return actualHash === expectedHash
250
+ }
251
+
252
+ // ── Update cache (background check) ──────────────────────────────────────────
253
+
254
+ /**
255
+ * readUpdateCache() → { checkedAt, latestVersion } | null
256
+ */
257
+ function readUpdateCache() {
258
+ try {
259
+ const raw = fs.readFileSync(UPDATE_CACHE, 'utf8')
260
+ return JSON.parse(raw)
261
+ } catch {
262
+ return null
263
+ }
264
+ }
265
+
266
+ /**
267
+ * writeUpdateCache(data)
268
+ */
269
+ function writeUpdateCache(data) {
270
+ try {
271
+ fs.writeFileSync(UPDATE_CACHE, JSON.stringify(data), 'utf8')
272
+ } catch {
273
+ // /tmp write failures are non-fatal — silently ignore
274
+ }
275
+ }
276
+
277
+ /**
278
+ * checkUpdateBackground()
279
+ *
280
+ * Called at the end of any tracker command output.
281
+ * - If cache is fresh (< 4h) and has update info → maybe print hint
282
+ * - If cache is stale → trigger a background check (detached child process)
283
+ *
284
+ * This function is synchronous from the caller's perspective — it never awaits
285
+ * network and never blocks the main process.
286
+ */
287
+ export function checkUpdateBackground() {
288
+ const cache = readUpdateCache()
289
+ const now = Date.now()
290
+
291
+ if (cache && (now - cache.checkedAt) < CACHE_TTL_MS) {
292
+ // Cache is fresh
293
+ if (cache.latestVersion && isNewer(VERSION, cache.latestVersion)) {
294
+ console.log()
295
+ console.log(
296
+ chalk.yellow(
297
+ ` Actualizacion disponible: v${cache.latestVersion} — ejecuta: tracker update`,
298
+ ),
299
+ )
300
+ }
301
+ return // Do not re-check until TTL expires
302
+ }
303
+
304
+ // Cache is stale or missing — fire a detached background check
305
+ // We use a child_process detached + unref so it outlives the CLI process
306
+ try {
307
+ const child = require('node:child_process').spawn(
308
+ process.execPath,
309
+ [
310
+ '--input-type=module',
311
+ '--eval',
312
+ `
313
+ import https from 'https'
314
+ import fs from 'fs'
315
+
316
+ const GITHUB_API = 'https://api.github.com/repos/logtrace-cloud/tracker/releases/latest'
317
+ const UPDATE_CACHE = '/tmp/.logtrace-update-check'
318
+ const UA = 'tracker-cli/${VERSION}'
319
+
320
+ const req = https.request(
321
+ { hostname: 'api.github.com', path: '/repos/logtrace-cloud/tracker/releases/latest',
322
+ headers: { 'User-Agent': UA, 'Accept': 'application/vnd.github+json' } },
323
+ (res) => {
324
+ const chunks = []
325
+ res.on('data', c => chunks.push(c))
326
+ res.on('end', () => {
327
+ try {
328
+ const data = JSON.parse(Buffer.concat(chunks).toString())
329
+ fs.writeFileSync(UPDATE_CACHE, JSON.stringify({ checkedAt: Date.now(), latestVersion: data.tag_name?.replace(/^v/, '') }))
330
+ } catch {}
331
+ })
332
+ }
333
+ )
334
+ req.setTimeout(5000, () => req.destroy())
335
+ req.on('error', () => {})
336
+ req.end()
337
+ `,
338
+ ],
339
+ { detached: true, stdio: 'ignore' },
340
+ )
341
+ child.unref()
342
+ } catch {
343
+ // Spawning the background check is optional — never crash on failure
344
+ }
345
+ }
346
+
347
+ // ── tracker update --check ────────────────────────────────────────────────────
348
+
349
+ export async function updateCheckCommand(_argv) {
350
+ const spinner = ora('Verificando actualizaciones...').start()
351
+
352
+ const release = await fetchLatestRelease()
353
+
354
+ if (!release) {
355
+ spinner.warn(chalk.yellow('No se pudo verificar actualizaciones (sin conexion o GitHub no responde).'))
356
+ return
357
+ }
358
+
359
+ const latestVersion = (release.tag_name || '').replace(/^v/, '')
360
+
361
+ // Update cache while we have fresh data
362
+ writeUpdateCache({ checkedAt: Date.now(), latestVersion })
363
+
364
+ if (!isNewer(VERSION, latestVersion)) {
365
+ spinner.succeed(chalk.green(`Ya tienes la ultima version (v${VERSION}).`))
366
+ return
367
+ }
368
+
369
+ spinner.succeed(chalk.cyan(`Actualizacion disponible: v${latestVersion}`))
370
+
371
+ const shortChangelog = firstNLines(release.body, 3)
372
+ if (shortChangelog) {
373
+ console.log()
374
+ console.log(chalk.bold(' Changelog:'))
375
+ for (const line of shortChangelog.split('\n')) {
376
+ console.log(chalk.dim(' ' + line))
377
+ }
378
+ console.log()
379
+ }
380
+
381
+ console.log(chalk.dim(` Ejecuta: ${chalk.white('tracker update')} para actualizar.`))
382
+ console.log()
383
+ }
384
+
385
+ // ── tracker update (full install) ─────────────────────────────────────────────
386
+
387
+ export async function updateCommand(_argv) {
388
+ // ── Step 1: Check for update ──────────────────────────────────────────────
389
+ const checkSpinner = ora('Verificando actualizaciones...').start()
390
+
391
+ const release = await fetchLatestRelease()
392
+
393
+ if (!release) {
394
+ checkSpinner.warn(chalk.yellow('No se pudo verificar actualizaciones (sin conexion o GitHub no responde).'))
395
+ return
396
+ }
397
+
398
+ const latestVersion = (release.tag_name || '').replace(/^v/, '')
399
+ writeUpdateCache({ checkedAt: Date.now(), latestVersion })
400
+
401
+ if (!isNewer(VERSION, latestVersion)) {
402
+ checkSpinner.succeed(chalk.green(`Ya tienes la ultima version (v${VERSION}).`))
403
+ return
404
+ }
405
+
406
+ checkSpinner.succeed(chalk.cyan(`Actualizacion disponible: v${latestVersion} (actual: v${VERSION})`))
407
+
408
+ // ── Step 2: Show full changelog ───────────────────────────────────────────
409
+ if (release.body && release.body.trim()) {
410
+ console.log()
411
+ console.log(chalk.bold(' Changelog:'))
412
+ for (const line of release.body.split('\n')) {
413
+ console.log(chalk.dim(' ' + line))
414
+ }
415
+ console.log()
416
+ }
417
+
418
+ // ── Step 3: Confirm ───────────────────────────────────────────────────────
419
+ let confirmed
420
+ try {
421
+ confirmed = await confirm({
422
+ message: `Actualizar tracker a v${latestVersion}?`,
423
+ default: true,
424
+ })
425
+ } catch {
426
+ // inquirer throws if stdin is not a TTY (e.g. piped input)
427
+ printError('No se pudo confirmar la actualización en modo no interactivo. Usa: tracker update --check')
428
+ process.exitCode = 1
429
+ return
430
+ }
431
+
432
+ if (!confirmed) {
433
+ printInfo('Actualización cancelada.')
434
+ return
435
+ }
436
+
437
+ // ── Step 4: Detect asset ─────────────────────────────────────────────────
438
+ const asset = detectAssetName(release.assets || [])
439
+
440
+ if (!asset) {
441
+ printError(`No hay un binario precompilado para ${process.platform}/${process.arch} en esta release.`)
442
+ process.exitCode = 1
443
+ return
444
+ }
445
+
446
+ // SHA256SUMS asset
447
+ const sumsAsset = (release.assets || []).find((a) => a.name === 'SHA256SUMS')
448
+
449
+ // ── Step 5: Download ──────────────────────────────────────────────────────
450
+ const tmpBinary = path.join('/tmp', `tracker-update-${latestVersion}`)
451
+
452
+ const downloadSpinner = ora(`Descargando ${asset.name}...`).start()
453
+
454
+ try {
455
+ await downloadToFile(asset.browser_download_url, tmpBinary)
456
+ downloadSpinner.succeed(`Descargado: ${asset.name}`)
457
+ } catch (err) {
458
+ downloadSpinner.fail(chalk.red(`Error al descargar: ${err.message}`))
459
+ process.exitCode = 1
460
+ return
461
+ }
462
+
463
+ // ── Step 6: Verify SHA256 ─────────────────────────────────────────────────
464
+ if (sumsAsset) {
465
+ const verifySpinner = ora('Verificando SHA256...').start()
466
+
467
+ try {
468
+ const sumsRes = await httpGet(sumsAsset.browser_download_url, { timeout: 5000 })
469
+ const valid = await verifySha256(tmpBinary, sumsRes.body, asset.name)
470
+
471
+ if (!valid) {
472
+ verifySpinner.fail(chalk.red('Verificacion SHA256 fallida — el archivo puede estar corrupto o comprometido.'))
473
+ await fsp.unlink(tmpBinary).catch(() => {})
474
+ process.exitCode = 1
475
+ return
476
+ }
477
+
478
+ verifySpinner.succeed('SHA256 verificado.')
479
+ } catch (err) {
480
+ verifySpinner.warn(chalk.yellow(`No se pudo verificar SHA256: ${err.message}. Continuando...`))
481
+ }
482
+ } else {
483
+ printWarning('SHA256SUMS no encontrado en la release. Continuando sin verificación.')
484
+ }
485
+
486
+ // Make the downloaded binary executable
487
+ try {
488
+ await fsp.chmod(tmpBinary, 0o755)
489
+ } catch (err) {
490
+ printError(`No se pudo marcar el binario como ejecutable: ${err.message}`)
491
+ await fsp.unlink(tmpBinary).catch(() => {})
492
+ process.exitCode = 1
493
+ return
494
+ }
495
+
496
+ // ── Step 7: Backup current binary ────────────────────────────────────────
497
+ const currentBinary = resolvedBinaryPath()
498
+ const backupBinary = currentBinary + '.bak'
499
+
500
+ const installSpinner = ora('Instalando actualización...').start()
501
+
502
+ try {
503
+ // Copy current → .bak (using copy so permissions are preserved on dest)
504
+ await fsp.copyFile(currentBinary, backupBinary)
505
+ } catch (err) {
506
+ installSpinner.warn(chalk.yellow(`No se pudo crear backup: ${err.message}. Continuando sin backup.`))
507
+ }
508
+
509
+ // ── Step 8: Replace binary ────────────────────────────────────────────────
510
+ try {
511
+ await fsp.copyFile(tmpBinary, currentBinary)
512
+ await fsp.chmod(currentBinary, 0o755)
513
+ await fsp.unlink(tmpBinary).catch(() => {})
514
+ installSpinner.succeed('Binario reemplazado.')
515
+ } catch (err) {
516
+ installSpinner.fail(chalk.red(`Error al reemplazar binario: ${err.message}`))
517
+ await rollback(currentBinary, backupBinary)
518
+ process.exitCode = 1
519
+ return
520
+ }
521
+
522
+ // ── Step 9: Restart daemon ────────────────────────────────────────────────
523
+ const restartSpinner = ora('Reiniciando daemon (systemctl)...').start()
524
+
525
+ try {
526
+ await execFileAsync('systemctl', ['restart', 'logtrace'], { timeout: 10000 })
527
+ restartSpinner.succeed('Daemon reiniciado.')
528
+ } catch {
529
+ restartSpinner.warn(chalk.yellow('No se pudo reiniciar el daemon via systemctl (puede no estar instalado como servicio).'))
530
+ }
531
+
532
+ // ── Step 10: Health check ─────────────────────────────────────────────────
533
+ await new Promise((r) => setTimeout(r, 3000)) // wait 3s for daemon to boot
534
+
535
+ const healthSpinner = ora('Verificando salud del daemon...').start()
536
+
537
+ const healthy = await checkDaemonHealth()
538
+
539
+ if (healthy) {
540
+ healthSpinner.succeed(chalk.green(`LogTrace actualizado a v${latestVersion}`))
541
+ printInfo(`Backup disponible en: ${backupBinary}`)
542
+ } else {
543
+ healthSpinner.fail(chalk.red('El daemon no respondio tras la actualización.'))
544
+ printWarning('Restaurando versión anterior...')
545
+
546
+ const restored = await rollback(currentBinary, backupBinary)
547
+
548
+ if (restored) {
549
+ printInfo('Versión anterior restaurada.')
550
+ try {
551
+ await execFileAsync('systemctl', ['restart', 'logtrace'], { timeout: 10000 })
552
+ } catch {
553
+ // Best effort
554
+ }
555
+ } else {
556
+ printError('No se pudo restaurar la versión anterior. Backup en: ' + backupBinary)
557
+ }
558
+
559
+ process.exitCode = 1
560
+ }
561
+ }
562
+
563
+ // ── Helpers ───────────────────────────────────────────────────────────────────
564
+
565
+ /**
566
+ * rollback(currentBinary, backupBinary) → Promise<boolean>
567
+ *
568
+ * Restores the .bak file over the current binary.
569
+ */
570
+ async function rollback(currentBinary, backupBinary) {
571
+ try {
572
+ await fsp.access(backupBinary)
573
+ await fsp.copyFile(backupBinary, currentBinary)
574
+ await fsp.chmod(currentBinary, 0o755)
575
+ return true
576
+ } catch {
577
+ return false
578
+ }
579
+ }
580
+
581
+ /**
582
+ * checkDaemonHealth() → Promise<boolean>
583
+ *
584
+ * Sends a status request over the Unix socket.
585
+ * Returns true if the daemon responds within ~3 seconds.
586
+ */
587
+ async function checkDaemonHealth() {
588
+ const client = createClient()
589
+ try {
590
+ await client.connect()
591
+ await client.send('status', {})
592
+ return true
593
+ } catch {
594
+ return false
595
+ } finally {
596
+ client.close()
597
+ }
598
+ }