@dotrino/vaultd 0.156.0 → 0.158.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/tui/gui.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * gui.js — el PUENTE entre la ventana de escritorio y la pantalla de control.
3
+ *
4
+ * La ventana (`desktop/`, Rust + iced) no sabe nada de la bóveda: lanza
5
+ * `dotrino-vault gui-bridge` (el mismo binario) y le habla por stdin/stdout, una línea JSON
6
+ * por mensaje. Este proceso corre LA MISMA sesión que la TUI —`newSession`, `handleKey`,
7
+ * `viewModel` de `app.js`— y le manda a la ventana lo que tiene que pintar. Así la ventana
8
+ * tiene exactamente las pantallas, los textos y las acciones de la TUI, y una acción nueva
9
+ * aparece en las dos sin tocar la otra.
10
+ *
11
+ * No abre ningún puerto: el canal son las tuberías del proceso hijo, que solo ve quien lo
12
+ * lanzó. Y como la TUI, no toca la identidad ni la red: le habla al daemon por su carpeta.
13
+ *
14
+ * Mensajes de la ventana (uno por línea):
15
+ * {"key":{"name":"char","ch":"p"}} una tecla, con los mismos nombres que `term.js`
16
+ * {"select":3} señalar la fila 3 (entre las seleccionables)
17
+ * {"activate":3} señalarla y pulsar Enter
18
+ * {"tab":"secrets"} ir a una pestaña de la bóveda entrada
19
+ * {"submit":"texto"} responder la pregunta abierta con ese texto
20
+ * {"confirm":true|false} responder la confirmación abierta
21
+ * {"quit":true} salir (cerrando lo que se abrió aquí)
22
+ *
23
+ * Mensajes a la ventana:
24
+ * {"t":"view","v":{…}} qué pintar (ver `viewModel`/`downModel`)
25
+ * {"t":"bye","relaunch":bool} se terminó; con `relaunch`, volver a lanzar el puente
26
+ * (se instaló una versión nueva)
27
+ *
28
+ * Los textos llevan sus estilos como tramos: `[{"t":"…","c":"muted","b":true}]`.
29
+ */
30
+ import * as vc from '../vaultControl.js'
31
+ import { makeTheme } from './term.js'
32
+ import {
33
+ newSession, startSession, beforeRender, tickFor, handleKey, endSession,
34
+ viewModel, downModel, selectRow, openTab, onKeyDown
35
+ } from './app.js'
36
+
37
+ // stdout es el canal: cualquier `console.log` suelto lo rompería. Va a stderr.
38
+ console.log = (...a) => console.error(...a)
39
+
40
+ // ----------------------------- estilos → tramos ------------------------------
41
+
42
+ /** Color del tema de la TUI (`makeTheme`) → nombre que entiende la ventana. */
43
+ const COLORS = { 44: 'accent', 114: 'ok', 214: 'warn', 203: 'danger', 244: 'muted', 81: 'title' }
44
+
45
+ /** Un texto con escapes SGR → tramos `{t, c?, b?}`. */
46
+ export function spans (str) {
47
+ const out = []
48
+ let bold = false
49
+ let color = null
50
+ let buf = ''
51
+ const flush = () => {
52
+ if (!buf) return
53
+ const sp = { t: buf }
54
+ if (color) sp.c = color
55
+ if (bold) sp.b = true
56
+ out.push(sp)
57
+ buf = ''
58
+ }
59
+ const re = /\x1b\[([0-9;]*)m/g
60
+ const s = String(str ?? '')
61
+ let last = 0
62
+ let m
63
+ while ((m = re.exec(s))) {
64
+ buf += s.slice(last, m.index)
65
+ last = re.lastIndex
66
+ flush()
67
+ const codes = m[1] === '' ? [0] : m[1].split(';').map(Number)
68
+ for (let k = 0; k < codes.length; k++) {
69
+ const c = codes[k]
70
+ if (c === 0) { bold = false; color = null } else if (c === 1) bold = true
71
+ else if (c === 38 && codes[k + 1] === 5) { color = COLORS[codes[k + 2]] || null; k += 2 }
72
+ }
73
+ }
74
+ buf += s.slice(last)
75
+ flush()
76
+ return out
77
+ }
78
+
79
+ /** Pasa a tramos todos los textos que el modelo trae con estilos. */
80
+ function toWire (vm) {
81
+ const v = { ...vm }
82
+ if (v.rows) v.rows = v.rows.map((r) => ({ ...r, text: spans(r.text) }))
83
+ if (v.body) v.body = v.body.map(spans)
84
+ for (const k of ['title', 'notice', 'status']) {
85
+ if (v[k] && typeof v[k] === 'object' && 'text' in v[k]) v[k] = { ...v[k], text: spans(v[k].text) }
86
+ }
87
+ if (v.confirm) v.confirm = { ...v.confirm, text: spans(v.confirm.text) }
88
+ // «p emparejar» → { key: 'p', label: 'emparejar' }. La tecla es el mnemónico de la TUI, y
89
+ // la ventana la usa como atajo de teclado y la manda al pulsar el botón.
90
+ v.help = (v.help || []).map((seg) => {
91
+ const sp = String(seg).indexOf(' ')
92
+ return sp < 0 ? { key: seg, label: '' } : { key: seg.slice(0, sp), label: seg.slice(sp + 1) }
93
+ })
94
+ return v
95
+ }
96
+
97
+ // --------------------------------- el bucle ---------------------------------
98
+
99
+ export async function runGuiBridge ({ input = process.stdin, output = process.stdout } = {}) {
100
+ const t = makeTheme({ color: true })
101
+ const st = newSession()
102
+ const queue = []
103
+ let waiter = null
104
+
105
+ const push = (ev) => { if (waiter) { const w = waiter; waiter = null; w(ev) } else queue.push(ev) }
106
+
107
+ let pending = ''
108
+ input.setEncoding('utf8')
109
+ input.on('data', (chunk) => {
110
+ pending += chunk
111
+ let nl
112
+ while ((nl = pending.indexOf('\n')) >= 0) {
113
+ const line = pending.slice(0, nl).trim()
114
+ pending = pending.slice(nl + 1)
115
+ if (!line) continue
116
+ try { push(JSON.parse(line)) } catch (e) { console.error('[gui-bridge] bad message:', e.message) }
117
+ }
118
+ })
119
+ // La ventana se cerró (o murió): se sale igual que con `q`, cerrando lo abierto aquí.
120
+ input.on('end', () => push({ quit: true }))
121
+ input.on('error', () => push({ quit: true }))
122
+
123
+ const next = (ms) => {
124
+ if (queue.length) return Promise.resolve(queue.shift())
125
+ return new Promise((resolve) => {
126
+ let timer = null
127
+ waiter = (ev) => { if (timer) clearTimeout(timer); resolve(ev) }
128
+ if (ms) timer = setTimeout(() => { waiter = null; resolve({ key: { name: 'tick' } }) }, ms)
129
+ })
130
+ }
131
+
132
+ // Si la ventana ya no está, escribir da EPIPE: no es un fallo de la sesión, y no puede
133
+ // tumbar el cierre ordenado (que es el que vuelve a cerrar las bóvedas).
134
+ let gone = false
135
+ output.on?.('error', () => { gone = true })
136
+ let lastSent = ''
137
+ const send = (msg) => {
138
+ if (gone) return
139
+ const line = JSON.stringify(msg)
140
+ if (msg.t === 'view' && line === lastSent) return
141
+ if (msg.t === 'view') lastSent = line
142
+ output.write(line + '\n')
143
+ }
144
+ const sendView = () => send({ t: 'view', v: toWire(st.down ? downModel(st) : viewModel(st, t)) })
145
+
146
+ // La sesión pinta también a mitad de una operación (`guard` pone «Guardando…»): el
147
+ // «terminal» de la ventana solo avisa de que hay algo nuevo.
148
+ const term = {
149
+ t,
150
+ gui: () => sendView(),
151
+ size: () => ({ cols: 200, rows: 60 }),
152
+ render: () => sendView(),
153
+ readKey: async () => ({ name: 'tick' }),
154
+ close: () => {}
155
+ }
156
+
157
+ /** Un mensaje de la ventana → la tecla (o las teclas) que equivale. */
158
+ const toKeys = (ev) => {
159
+ if (ev.key) return [ev.key]
160
+ if (typeof ev.select === 'number') { selectRow(st, ev.select); return [] }
161
+ if (typeof ev.activate === 'number') { selectRow(st, ev.activate); return [{ name: 'enter' }] }
162
+ if (typeof ev.submit === 'string') {
163
+ if (st.input) st.input.value = ev.submit
164
+ return [{ name: 'enter' }]
165
+ }
166
+ if (typeof ev.confirm === 'boolean') return [{ name: 'char', ch: ev.confirm ? 'y' : 'n' }]
167
+ return []
168
+ }
169
+
170
+ let relaunch = false
171
+ let inSession = false
172
+ try {
173
+ while (true) {
174
+ if (!vc.daemonAlive()) {
175
+ // SIN SERVICIO: la pantalla de arrancarlo. Si se cae a mitad de sesión, se vuelve
176
+ // aquí en vez de dejar una ventana que contesta errores a todo.
177
+ st.down = true
178
+ sendView()
179
+ const ev = await next(3000)
180
+ if (ev.quit) break
181
+ if (ev.key?.name === 'tick') continue
182
+ const keys = toKeys(ev)
183
+ let r = null
184
+ for (const k of keys) r = await onKeyDown(st, k, sendView)
185
+ if (r === false) break
186
+ continue
187
+ }
188
+ if (st.down || !inSession) {
189
+ st.down = false
190
+ await startSession(term, st)
191
+ inSession = true
192
+ }
193
+ await beforeRender(term, st)
194
+ sendView()
195
+ const ev = await next(tickFor(st) || 2000)
196
+ if (ev.quit) break
197
+ if (ev.tab) { await openTab(term, st, ev.tab); continue }
198
+ let running = true
199
+ for (const k of toKeys(ev)) {
200
+ // Un tick sin pantalla que lo espere no hace nada; solo sirve para repintar.
201
+ running = await handleKey(term, st, k)
202
+ if (!running) break
203
+ }
204
+ if (!running) { relaunch = !!st.relaunch; break }
205
+ }
206
+ } finally {
207
+ await endSession(st)
208
+ send({ t: 'bye', relaunch })
209
+ // La entrada sigue abierta si quien salió fue la propia sesión (`q`, o tras actualizar):
210
+ // sin soltarla, el proceso se quedaba vivo esperando líneas que ya nadie iba a leer.
211
+ input.pause()
212
+ input.destroy?.()
213
+ }
214
+ }
package/src/tui/i18n.js CHANGED
@@ -15,6 +15,9 @@
15
15
  import path from 'node:path'
16
16
  import { dataDir, readJson, writeJson } from '../paths.js'
17
17
 
18
+ // Solo Linux tiene la unidad de systemd: en Windows y macOS nombrarla manda a un comando que no existe.
19
+ const LINUX = process.platform === 'linux'
20
+
18
21
  // --------------------------------- español ---------------------------------
19
22
 
20
23
  const es = {
@@ -33,11 +36,12 @@ const es = {
33
36
  updateInstalling: (version) => `Bajando la ${version} y comprobando la firma…`,
34
37
  updateFailed: (reason) => `No se instaló nada: ${reason}`,
35
38
  updateNeedsCommand: 'Esta instalación no se puede cambiar desde aquí. Sal y corre: dotrino-vault update',
39
+ updateWindowsInstalling: (v) => `Instalando la ${v}: esta pantalla se cierra un momento y la bóveda vuelve sola.`,
36
40
  updateNoteFailed: (reason) => `Instalada, pero no se pudo dejar el aviso para tus aprobadores: ${reason}`,
37
41
  updateRestartOffer: (version) => `Instalada la ${version}. ¿Reiniciar ahora la bóveda y esta pantalla? Cierra las bóvedas abiertas`,
38
42
  updateRestarting: 'Reiniciando la bóveda…',
39
43
  updateRestartLater: (version) => `La ${version} queda instalada: la bóveda se reinicia sola en un par de minutos`,
40
- daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
44
+ daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo${LINUX ? ' (systemctl --user restart dotrino-vault)' : ''}`,
41
45
  activeVault: 'Bóveda activa: ',
42
46
  noName: '(sin nombre)',
43
47
  tooSmall: 'Terminal muy pequeño',
@@ -190,6 +194,14 @@ const es = {
190
194
  settingUpdateNowAsks: 'Hecho: pide aprobación antes de actualizarse.',
191
195
  settingUpdateNowAlone: 'Hecho: se actualiza sola.',
192
196
  settingSaveFailed: (m) => `No se pudo guardar el ajuste: ${m}`,
197
+ settingAutostartOn: 'La bóveda arranca sola al iniciar sesión en esta computadora.',
198
+ settingAutostartOff: 'La bóveda no arranca sola: hay que abrirla cada vez.',
199
+ settingAutostartUnknown: 'No se sabe si la bóveda arranca sola al iniciar sesión.',
200
+ settingAutostartHint: 'Sin ella encendida, tus aparatos no pueden pedirle nada a esta bóveda.',
201
+ settingAutostartNoUnit: 'Para que arranque sola, instálala con el paquete de tu sistema (.deb o .rpm).',
202
+ settingAutostartSaving: 'Guardando…',
203
+ settingAutostartNowOn: 'Hecho: arranca sola al iniciar sesión.',
204
+ settingAutostartNowOff: 'Hecho: ya no arranca sola.',
193
205
  doorsIntro: (name) => `Con qué se abre «${name}». Abre cualquiera de estas.`,
194
206
  doorsNone: 'Sin candado: se abre sola en esta máquina.',
195
207
  doorPassword: 'contraseña',
@@ -480,7 +492,7 @@ const es = {
480
492
 
481
493
  // pantalla "daemon caído"
482
494
  downTitle: 'El daemon del vault no está corriendo.',
483
- downBody1: 'La TUI le da órdenes al daemon (custodio de tu clave). Sin él no puede',
495
+ downBody1: 'Esta pantalla le da órdenes al servicio de la bóveda (el que guarda tu llave). Sin él no puede',
484
496
  downBody2: 'crear bóvedas, listar dispositivos ni tocar secretos.',
485
497
  downStart: ' intentar arrancarlo: ',
486
498
  downRecheck: ' volver a comprobar',
@@ -496,7 +508,7 @@ const es = {
496
508
  startFailed: (err) => `No se pudo arrancar: ${err}`,
497
509
 
498
510
  // errores
499
- errDaemonDown: 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).',
511
+ errDaemonDown: LINUX ? 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).' : 'El servicio de la bóveda no está corriendo. Cierra esta pantalla y vuelve a abrirla para arrancarlo.',
500
512
  errNoReply: 'El daemon no respondió.',
501
513
  errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
502
514
  errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
@@ -530,11 +542,12 @@ const en = {
530
542
  updateInstalling: (version) => `Downloading ${version} and checking its signature…`,
531
543
  updateFailed: (reason) => `Nothing was installed: ${reason}`,
532
544
  updateNeedsCommand: 'This install cannot be changed from here. Quit and run: dotrino-vault update',
545
+ updateWindowsInstalling: (v) => `Installing ${v}: this screen closes for a moment and the vault comes back on its own.`,
533
546
  updateNoteFailed: (reason) => `Installed, but the note for your approvers could not be written: ${reason}`,
534
547
  updateRestartOffer: (version) => `${version} is installed. Restart the vault and this screen now? It locks the open vaults`,
535
548
  updateRestarting: 'Restarting the vault…',
536
549
  updateRestartLater: (version) => `${version} is installed: the vault restarts by itself in a couple of minutes`,
537
- daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
550
+ daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it${LINUX ? ' (systemctl --user restart dotrino-vault)' : ''}`,
538
551
  activeVault: 'Active vault: ',
539
552
  tooSmall: 'Terminal too small',
540
553
  tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
@@ -667,6 +680,14 @@ const en = {
667
680
  settingUpdateNowAsks: 'Done: it asks for approval before updating.',
668
681
  settingUpdateNowAlone: 'Done: it updates on its own.',
669
682
  settingSaveFailed: (m) => `The setting could not be saved: ${m}`,
683
+ settingAutostartOn: 'The vault starts on its own when you sign in to this computer.',
684
+ settingAutostartOff: 'The vault does not start on its own: you have to open it every time.',
685
+ settingAutostartUnknown: 'It is not known whether the vault starts on its own when you sign in.',
686
+ settingAutostartHint: 'While it is off, your devices cannot ask this vault for anything.',
687
+ settingAutostartNoUnit: 'To have it start on its own, install it with your system package (.deb or .rpm).',
688
+ settingAutostartSaving: 'Saving…',
689
+ settingAutostartNowOn: 'Done: it starts on its own when you sign in.',
690
+ settingAutostartNowOff: 'Done: it no longer starts on its own.',
670
691
  doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
671
692
  doorsNone: 'No lock: it opens by itself on this machine.',
672
693
  doorPassword: 'password',
@@ -926,7 +947,7 @@ const en = {
926
947
  helpJoin: ['Esc vaults', 'l Español', 'q quit'],
927
948
 
928
949
  downTitle: 'The vault daemon is not running.',
929
- downBody1: 'The TUI gives orders to the daemon (the keeper of your key). Without it',
950
+ downBody1: 'This screen gives orders to the vault service (the keeper of your key). Without it',
930
951
  downBody2: 'it cannot create vaults, list devices or touch secrets.',
931
952
  downStart: ' try to start it: ',
932
953
  downRecheck: ' check again',
@@ -941,7 +962,7 @@ const en = {
941
962
  startedNotReady: 'It started but does not answer yet; press R',
942
963
  startFailed: (err) => `Could not start it: ${err}`,
943
964
 
944
- errDaemonDown: 'The daemon is not running. Start it: systemctl --user start dotrino-vault (or restart the TUI).',
965
+ errDaemonDown: LINUX ? 'The daemon is not running. Start it: systemctl --user start dotrino-vault (or restart the TUI).' : 'The vault service is not running. Close this screen and open it again to start it.',
945
966
  errNoReply: 'The daemon did not answer.',
946
967
  errNotApplied: 'The daemon did not apply the change (check the service logs).',
947
968
  errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
package/src/tui/term.js CHANGED
@@ -87,8 +87,13 @@ export function padEnd (s, width) {
87
87
 
88
88
  // ------------------------------- tema/estilos -------------------------------
89
89
 
90
- export function makeTheme () {
91
- const on = supportsColor()
90
+ /**
91
+ * `color: true` fuerza los estilos aunque no haya terminal: lo usa el puente de la ventana
92
+ * (`gui.js`), que convierte esos mismos escapes en tramos con estilo. Así las filas que
93
+ * pinta la TUI son, carácter a carácter, las que pinta la ventana.
94
+ */
95
+ export function makeTheme ({ color } = {}) {
96
+ const on = color ?? supportsColor()
92
97
  const sgr = (...c) => (on ? CSI + c.join(';') + 'm' : '')
93
98
  const R = on ? CSI + '0m' : ''
94
99
  const wrap = (open) => (s) => on ? open + s + R : s
package/src/update.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import fs from 'node:fs'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
- import { execFileSync } from 'node:child_process'
15
+ import { execFileSync, spawn } from 'node:child_process'
16
16
  import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
17
17
  import { pickAsset, download, verifyArtifact as verify, fetchVerified } from '@dotrino/update/fetch'
18
18
  import { VERSION } from './version.js'
@@ -124,8 +124,12 @@ async function instalarDesde (work, pick, release, { binDir, log, fetchVerifiedI
124
124
  const src = fs.readdirSync(work).map((d) => path.join(work, d)).find((d) => fs.existsSync(path.join(d, 'dotrino-vaultd')))
125
125
  if (!src) return { ok: false, code: 'BAD_TARBALL', reason: 'the tarball has no dotrino-vaultd inside' }
126
126
  fs.mkdirSync(binDir, { recursive: true })
127
+ // LA VENTANA se pone al día con la bóveda, pero solo donde ya estaba: una bóveda de
128
+ // servidor no tiene por qué recibir un programa de escritorio que nadie instaló.
129
+ const names = ['dotrino-vault', 'dotrino-vaultd']
130
+ if (fs.existsSync(path.join(binDir, 'dotrino-vault-desktop')) && fs.existsSync(path.join(src, 'dotrino-vault-desktop'))) names.unshift('dotrino-vault-desktop')
127
131
  // El CLI primero y el daemon al final: cambiar el daemon es lo que dispara el reinicio.
128
- for (const name of ['dotrino-vault', 'dotrino-vaultd']) {
132
+ for (const name of names) {
129
133
  const tmp = path.join(binDir, `.${name}.new`)
130
134
  fs.copyFileSync(path.join(src, name), tmp)
131
135
  fs.chmodSync(tmp, 0o755)
@@ -133,3 +137,143 @@ async function instalarDesde (work, pick, release, { binDir, log, fetchVerifiedI
133
137
  }
134
138
  return { ok: true, version: release.version }
135
139
  }
140
+
141
+ // ------------------------- Windows y macOS ---------------------------------
142
+
143
+ /**
144
+ * CÓMO ESTÁ INSTALADO ESTE BINARIO, y por tanto si puede actualizarse solo (CONVENCIONES §15).
145
+ *
146
+ * 'user' Linux, instalación de usuario (`~/.local/share/dotrino/bin`): cambia sus binarios.
147
+ * 'inno' Windows, el instalador por usuario (`unins000.exe` al lado): corre el instalador
148
+ * nuevo en silencio, que cierra la bóveda, la reemplaza y la vuelve a arrancar.
149
+ * 'app' macOS, dentro de `Dotrino Vault.app` en una carpeta que se puede escribir: se
150
+ * cambia la app entera (nunca un binario suelto: la app es un conjunto firmado).
151
+ * 'system' un paquete de root (.deb/.rpm), o una app en una carpeta que no es tuya.
152
+ * 'other' npm, Docker, un checkout: se actualiza por su propia vía.
153
+ */
154
+ export function installKind ({ platform = process.platform, execPath = process.execPath, home = os.homedir(), exists = fs.existsSync, writable = canWrite } = {}) {
155
+ const sea = /^dotrino-vaultd(\.exe)?$/.test(path.basename(execPath))
156
+ if (!sea) return 'other'
157
+ if (platform === 'linux') return isUserInstall(execPath, home) ? 'user' : execPath.startsWith('/usr/') ? 'system' : 'other'
158
+ if (platform === 'win32') return exists(path.join(path.dirname(execPath), 'unins000.exe')) ? 'inno' : 'other'
159
+ if (platform === 'darwin') {
160
+ const app = appBundleOf(execPath)
161
+ if (!app) return 'other'
162
+ return writable(path.dirname(app)) ? 'app' : 'system'
163
+ }
164
+ return 'other'
165
+ }
166
+
167
+ /** ¿Puede este binario instalar él mismo la versión nueva? */
168
+ export const canSelfUpdate = (opts) => ['user', 'inno', 'app'].includes(installKind(opts))
169
+
170
+ /** `/Applications/Dotrino Vault.app` a partir de su `Contents/MacOS/dotrino-vaultd`. */
171
+ export function appBundleOf (execPath) {
172
+ const m = /^(.*\.app)\/Contents\/MacOS\/dotrino-vaultd$/.exec(execPath)
173
+ return m ? m[1] : null
174
+ }
175
+
176
+ function canWrite (dir) { try { fs.accessSync(dir, fs.constants.W_OK); return true } catch { return false } }
177
+
178
+ /** Carpeta de trabajo de las descargas: la de los datos de Dotrino del usuario, no `/tmp`. */
179
+ function updateWorkDir (platform = process.platform) {
180
+ if (platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Dotrino', 'update')
181
+ return path.join(os.homedir(), '.local', 'share', 'dotrino', 'update')
182
+ }
183
+
184
+ /**
185
+ * INSTALA la versión nueva por la vía de este sistema. Lo que se baja se verifica contra la
186
+ * atestación del release antes de tocar nada; si no cuadra, no se toca nada y se dice.
187
+ *
188
+ * `then` dice qué tiene que hacer quien llamó:
189
+ * 'watch' Linux: el binario cambió en su sitio; el vigilante (o systemd) lo reinicia.
190
+ * 'exit' el proceso que corre la bóveda tiene que irse YA. En Windows el instalador lo
191
+ * cierra y lo vuelve a arrancar; en macOS lo levanta launchd o el relanzador.
192
+ *
193
+ * @returns {Promise<{ok:true, version:string, then:'watch'|'exit'} | {ok:false, code?:string, reason:string, file?:string}>}
194
+ */
195
+ export async function installRelease (release, { log = () => {}, kind = installKind(), fetchVerifiedImpl = fetchVerified, run = spawnDetached, execPath = process.execPath, pid = null } = {}) {
196
+ if (kind === 'user') {
197
+ const r = await installUserRelease(release, { log, fetchVerifiedImpl })
198
+ return r.ok ? { ...r, then: 'watch' } : r
199
+ }
200
+ if (kind === 'inno') return installWindows(release, { log, fetchVerifiedImpl, run })
201
+ if (kind === 'app') return installMac(release, { log, fetchVerifiedImpl, run, execPath, pid })
202
+ return { ok: false, code: 'CANNOT_SELF_UPDATE', reason: kind === 'system' ? 'installed as a system package (root): update it with the system installer' : 'not installed with an installer: update it the way you installed it (npm, Docker…)' }
203
+ }
204
+
205
+ function spawnDetached (cmd, args, { log } = {}) {
206
+ const child = spawn(cmd, args, { detached: true, windowsHide: true, stdio: 'ignore' })
207
+ child.on('error', (e) => log?.(`[vault] update: could not start ${cmd}: ${e.message}`))
208
+ child.unref()
209
+ }
210
+
211
+ /** Baja y verifica un archivo del release a una carpeta propia (que se vacía antes). */
212
+ async function bajarVerificado (release, re, { log, fetchVerifiedImpl, work }) {
213
+ const pick = pickAsset(release.assets, [{ kind: 'asset', re }])
214
+ if (!pick.ok) return pick
215
+ fs.rmSync(work, { recursive: true, force: true })
216
+ fs.mkdirSync(work, { recursive: true, mode: 0o700 })
217
+ const got = await fetchVerifiedImpl(pick.asset, { repo: REPO, product: 'dotrino-vault', version: VERSION, dir: work })
218
+ if (got.ok) log(`[vault] update: ${pick.asset.name} verified against its attestation`)
219
+ return got
220
+ }
221
+
222
+ /**
223
+ * WINDOWS: el instalador nuevo, en silencio. Es lo único que puede cambiar los archivos: un
224
+ * .exe en marcha no se sobrescribe. El instalador cierra lo que los usa (la bóveda, y la
225
+ * ventana si está abierta), los reemplaza y vuelve a arrancar la bóveda (`--start-daemon`).
226
+ * Las tareas elegidas al instalar (arrancar al iniciar sesión) se conservan.
227
+ */
228
+ async function installWindows (release, { log, fetchVerifiedImpl, run }) {
229
+ const work = updateWorkDir('win32')
230
+ const got = await bajarVerificado(release, /-windows-x64-setup\.exe$/, { log, fetchVerifiedImpl, work })
231
+ if (!got.ok) return got
232
+ run(got.file, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/FORCECLOSEAPPLICATIONS', `/LOG=${path.join(work, 'setup.log')}`], { log })
233
+ log(`[vault] update: installer for ${release.version} started; it restarts the vault when it finishes`)
234
+ return { ok: true, version: release.version, then: 'exit' }
235
+ }
236
+
237
+ /**
238
+ * macOS: la app entera. Se descomprime al lado de la actual (misma carpeta, así el cambio es
239
+ * un renombrado y no una copia a medias), se aparta la vieja, se pone la nueva y se borra la
240
+ * vieja. Lo que está corriendo sigue con su copia hasta que se reinicia.
241
+ *
242
+ * Quien la levanta: launchd si la bóveda corre como agente (KeepAlive); si corre suelta, un
243
+ * relanzador que espera a que este proceso se vaya y arranca el binario nuevo.
244
+ */
245
+ async function installMac (release, { log, fetchVerifiedImpl, run, execPath, pid }) {
246
+ const app = appBundleOf(execPath)
247
+ if (!app) return { ok: false, code: 'CANNOT_SELF_UPDATE', reason: 'not running from Dotrino Vault.app' }
248
+ const work = updateWorkDir('darwin')
249
+ const got = await bajarVerificado(release, /-macos-universal\.zip$/, { log, fetchVerifiedImpl, work })
250
+ if (!got.ok) return got
251
+ const parent = path.dirname(app)
252
+ const stage = path.join(parent, `.dotrino-vault-update-${process.pid}`)
253
+ fs.rmSync(stage, { recursive: true, force: true })
254
+ try {
255
+ try { execFileSync('ditto', ['-x', '-k', got.file, stage], { stdio: ['ignore', 'ignore', 'pipe'] }) } catch (e) {
256
+ return { ok: false, code: 'UNPACK_FAILED', reason: `could not unpack the release: ${String(e.stderr || e.message).trim().split('\n').slice(-2).join(' · ')}` }
257
+ }
258
+ const nueva = path.join(stage, 'Dotrino Vault.app')
259
+ if (!fs.existsSync(path.join(nueva, 'Contents', 'MacOS', 'dotrino-vaultd'))) return { ok: false, code: 'BAD_ARCHIVE', reason: 'the archive has no Dotrino Vault.app inside' }
260
+ const vieja = `${app}.old-${process.pid}`
261
+ fs.renameSync(app, vieja)
262
+ try { fs.renameSync(nueva, app) } catch (e) {
263
+ fs.renameSync(vieja, app) // se deja como estaba
264
+ return { ok: false, code: 'SWAP_FAILED', reason: `could not put the new app in place: ${e.message}` }
265
+ }
266
+ fs.rmSync(vieja, { recursive: true, force: true })
267
+ } finally {
268
+ fs.rmSync(stage, { recursive: true, force: true })
269
+ fs.rmSync(work, { recursive: true, force: true })
270
+ }
271
+ // Suelta (sin launchd): el relanzador espera a que `pid` se vaya y arranca el nuevo.
272
+ if (pid && process.env.XPC_SERVICE_NAME !== 'com.dotrino.vault') {
273
+ const bin = path.join(app, 'Contents', 'MacOS', 'dotrino-vaultd')
274
+ const logFile = path.join(os.homedir(), '.local', 'share', 'dotrino', 'vault', 'daemon.log')
275
+ run('/bin/sh', ['-c', 'while kill -0 "$1" 2>/dev/null; do sleep 0.5; done; exec "$2" >>"$3" 2>&1', 'relaunch', String(pid), bin, logFile], { log })
276
+ }
277
+ log(`[vault] update: ${release.version} installed in ${app}`)
278
+ return { ok: true, version: release.version, then: 'exit' }
279
+ }