@dotrino/vaultd 0.157.0 → 0.158.1

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/README.md CHANGED
@@ -159,12 +159,18 @@ con `dotrino-vault service autostart on|off`. Desinstalar no borra tus datos.
159
159
  Aplicaciones. Dentro van la ventana y la bóveda. Para que arranque al iniciar sesión, en
160
160
  Ajustes (un LaunchAgent de tu usuario, `~/Library/LaunchAgents/com.dotrino.vault.plist`).
161
161
 
162
- > Ni el instalador de Windows ni la app de macOS llevan la firma de un desarrollador
163
- > registrado: Windows enseña el aviso de SmartScreen («editor desconocido») y macOS el de
164
- > Gatekeeper la primera vez (clic derecho → Abrir). Lo que prueba de dónde salieron es la
165
- > atestación de sigstore: `gh attestation verify <archivo> --repo imdotrino/dotrino-vault`.
166
- > En Windows y macOS la bóveda todavía **no se actualiza sola**: se instala la versión nueva
167
- > encima.
162
+ **Se actualizan solas, como en Linux** (CONVENCIONES §15): una vez al día miran si hay versión
163
+ nueva, bajan el instalador (Windows) o la app (macOS), lo **verifican contra la atestación de
164
+ sigstore** del release —dentro del propio binario, sin `gh`— y lo instalan. En Windows el
165
+ instalador cierra la bóveda, la cambia y la vuelve a arrancar (y vuelve a abrir la ventana si
166
+ estaba abierta); en macOS se cambia la app entera y la levanta launchd o, suelta, un
167
+ relanzador. La aprobación opcional (`update --approval on`) vale igual.
168
+
169
+ > El instalador de Windows **no lleva firma de Microsoft, y no la va a llevar** (decisión del
170
+ > dueño): Windows enseña el aviso de SmartScreen («editor desconocido»). La app de macOS
171
+ > todavía no lleva la firma de Apple: Gatekeeper avisa la primera vez (clic derecho → Abrir).
172
+ > Lo que prueba de dónde salieron es la atestación de sigstore:
173
+ > `gh attestation verify <archivo> --repo imdotrino/dotrino-vault`.
168
174
 
169
175
  **Ubuntu / Debian — `.deb`:** descarga el `.deb` (versionado) desde
170
176
  [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest) y haz doble
@@ -893,9 +899,8 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
893
899
  actividad. Contraseña opcional por perfil, **para editarlo** (verificador PBKDF2).
894
900
  - **v2:** cifrado en reposo **con la contraseña del perfil** (hoy la clave sale solo de
895
901
  la máquina) y atado a tu cuenta del sistema —DPAPI en Windows, Keychain en macOS— o
896
- al TPM; cifrar también los secretos y el store; que la bóveda de Windows y macOS se
897
- actualice sola, como la de Linux; firma de Apple y de Microsoft; firma de documentos
898
- con sellado de tiempo (`dotrino-signer`).
902
+ al TPM; cifrar también los secretos y el store; firma y notarización de Apple para la app de
903
+ macOS; firma de documentos con sellado de tiempo (`dotrino-signer`).
899
904
 
900
905
  ## Estructura
901
906
 
@@ -23,6 +23,6 @@ if (!process.stdout.isTTY) {
23
23
 
24
24
  runTui().catch((e) => {
25
25
  // El finally de runTui ya restauró el terminal; aquí solo reportamos.
26
- console.error('error en la TUI:', e?.stack || e?.message || e)
26
+ console.error('TUI error:', e?.stack || e?.message || e)
27
27
  process.exit(1)
28
28
  })
package/bin/sea-entry.js CHANGED
@@ -16,11 +16,11 @@ if (argv[0] === '--tui') {
16
16
  // ni el proxy: habla con el daemon por el dir de datos + señales.
17
17
  if (!process.stdout.isTTY) { console.error('la TUI necesita un terminal interactivo (TTY).'); process.exit(2) }
18
18
  import('../src/tui/app.js').then(({ runTui }) => runTui())
19
- .catch((e) => { console.error('error en la TUI:', e.message); process.exit(1) })
19
+ .catch((e) => { console.error('TUI error:', e.message); process.exit(1) })
20
20
  } else if (argv[0] === '--gui-bridge') {
21
21
  // El canal de la ventana de escritorio (JSON por stdin/stdout, ver src/tui/gui.js).
22
22
  import('../src/tui/gui.js').then(({ runGuiBridge }) => runGuiBridge()).then(() => process.exit(0))
23
- .catch((e) => { console.error('error en el puente de la ventana:', e.message); process.exit(1) })
23
+ .catch((e) => { console.error('window bridge error:', e.message); process.exit(1) })
24
24
  } else if (argv[0] === '--ctl') {
25
25
  // CLI de control: NO toca la identidad ni el proxy; habla con el daemon vía
26
26
  // el dir de datos (state.json) y señales (SIGUSR1 para emparejar).
@@ -29,5 +29,5 @@ if (argv[0] === '--tui') {
29
29
  } else {
30
30
  // Modo daemon (servicio).
31
31
  import('../src/daemon.js').then(({ runDaemon }) => runDaemon(argv))
32
- .catch((e) => { console.error('error fatal del daemon:', e.message); process.exit(1) })
32
+ .catch((e) => { console.error('fatal daemon error:', e.message); process.exit(1) })
33
33
  }
package/lib/src/atrest.js CHANGED
@@ -132,7 +132,7 @@ export function encryptText (plaintext, key) {
132
132
 
133
133
  export function decryptText (blob, key) {
134
134
  const [magic, iv, ct, tag] = String(blob).split('.')
135
- if (magic !== MAGIC) throw new Error('at-rest: formato desconocido')
135
+ if (magic !== MAGIC) throw new Error('at-rest: unknown format')
136
136
  const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'))
137
137
  d.setAuthTag(Buffer.from(tag, 'base64'))
138
138
  return Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8')
@@ -176,7 +176,7 @@ export function migrateFile (file, key) {
176
176
  try { text = fs.readFileSync(file, 'utf8') } catch (_) { return 'sin-archivo' }
177
177
  if (isEncrypted(text)) return 'ya-cifrado'
178
178
  const blob = encryptText(text, key)
179
- if (decryptText(blob, key) !== text) throw new Error('at-rest: la comprobación falló, no se toca el original')
179
+ if (decryptText(blob, key) !== text) throw new Error('at-rest: verification failed, the original is untouched')
180
180
  const tmp = file + '.atrest.tmp'
181
181
  fs.writeFileSync(tmp, blob, { mode: 0o600 })
182
182
  fs.renameSync(tmp, file)
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.86.0'
8
+ export const LIB_VERSION = '0.86.1'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.157.0",
3
+ "version": "0.158.1",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -28,7 +28,8 @@
28
28
  "@dotrino/proxy-client": "^0.32.2",
29
29
  "@dotrino/roadmap": "0.4.13",
30
30
  "@dotrino/store": "0.11.0",
31
- "@dotrino/update": "0.5.1",
31
+ "@dotrino/update": "0.9.0",
32
+ "sigstore": "5.0.0",
32
33
  "ws": "^8.18.0"
33
34
  },
34
35
  "license": "MIT",
package/src/ctl.js CHANGED
@@ -1855,7 +1855,7 @@ async function cmdUpdate (args = []) {
1855
1855
  : 'Esta bóveda se actualiza sola, sin pedir aprobación.')
1856
1856
  return
1857
1857
  }
1858
- const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1858
+ const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease, installKind, installRelease } = await import('./update.js')
1859
1859
  const soloMirar = args.includes('--check')
1860
1860
 
1861
1861
  process.stdout.write('Mirando qué hay publicado… ')
@@ -1874,6 +1874,38 @@ async function cmdUpdate (args = []) {
1874
1874
  console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
1875
1875
  if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
1876
1876
 
1877
+ // WINDOWS Y macOS: el instalador nuevo (Windows) o la app entera (macOS), verificados.
1878
+ const kind = installKind()
1879
+ if (kind === 'inno' || kind === 'app') {
1880
+ process.stdout.write('Bajando y comprobando la firma… ')
1881
+ const res = await installRelease(r, { kind })
1882
+ if (!res.ok) {
1883
+ console.log('')
1884
+ console.error('%sNO se instala nada.%s %s', R, Z, res.reason)
1885
+ if (res.file) console.error('El archivo quedó en %s, sin tocar nada.', res.file)
1886
+ process.exit(1)
1887
+ }
1888
+ console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1889
+ await noteUpdated(res.version)
1890
+ if (kind === 'inno') {
1891
+ // El instalador cierra la bóveda (y este mismo comando: es el mismo .exe), la cambia y
1892
+ // la vuelve a arrancar. Aquí no queda nada que esperar.
1893
+ console.log('Instalando la %s: la bóveda se cierra un momento y vuelve sola.', res.version)
1894
+ return
1895
+ }
1896
+ // macOS: la app nueva ya está en su sitio; falta reiniciar la que corre.
1897
+ const s = ipcRead(stateFile, null)
1898
+ if (s && alive(s.pid)) {
1899
+ const { restartDaemonService } = await import('./service.js')
1900
+ try { await restartDaemonService({ pid: s.pid, alive: () => alive(s.pid) }) } catch (e) {
1901
+ console.error('Instalada, pero no se pudo reiniciar: %s', e.message)
1902
+ process.exit(1)
1903
+ }
1904
+ await waitForService(res.version)
1905
+ } else console.log('Instalada la %s.', res.version)
1906
+ return
1907
+ }
1908
+
1877
1909
  // Instalación de usuario: se verifica y se instala aquí mismo, sin sudo.
1878
1910
  if (isUserInstall()) {
1879
1911
  process.stdout.write('Bajando y comprobando la firma… ')
package/src/daemon.js CHANGED
@@ -25,7 +25,7 @@ import { parseInvite } from '../lib/src/invite.js'
25
25
  import { scopeToCaps, scopeToCn } from '../lib/src/enroll.js'
26
26
  import { capScope } from '@dotrino/identity/acta'
27
27
  import { watchBinary } from './selfupdate.js'
28
- import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
28
+ import { latestRelease, isNewer, CHECK_EVERY_MS, installKind, installRelease } from './update.js'
29
29
  import { VERSION } from './version.js'
30
30
  import { readSettings } from './settings.js'
31
31
  import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo, readRootTold, writeRootTold, shouldTellRoot } from './updateApproval.js'
@@ -1157,9 +1157,18 @@ export async function runDaemon () {
1157
1157
  }
1158
1158
  const conAvisos = quienAprueba.filter((a) => a.notifiedAt).length
1159
1159
 
1160
- // INSTALADA COMO PAQUETE DEL SISTEMA (.deb o .rpm en /usr/bin): el binario es de root y este
1161
- // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
1162
- if (!isUserInstall()) {
1160
+ // CÓMO SE INSTALÓ decide si puede instalar ella misma (update.js, `installKind`): la de
1161
+ // usuario de Linux, el instalador de Windows y la app de macOS, sí. Por npm, Docker o un
1162
+ // checkout se actualiza por su propia vía: se dice en el log y nada más (no es «hace
1163
+ // falta root», y avisar eso al teléfono sería mentirle).
1164
+ const kind = installKind()
1165
+ if (kind === 'other') {
1166
+ if (avisadoOtra !== r.version) { avisadoOtra = r.version; console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · update it the way you installed it (npm, Docker, git)`) }
1167
+ return
1168
+ }
1169
+ // INSTALADA COMO PAQUETE DEL SISTEMA (.deb o .rpm en /usr/bin, o una app en una carpeta
1170
+ // que no es tuya): este proceso no puede tocarla. Se dice, con las salidas, y no se intenta nada.
1171
+ if (kind === 'system') {
1163
1172
  console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · installed as a system package, so it cannot update itself: \`dotrino-vault update\` (needs sudo), or move to a user install with the tarball's install.sh`)
1164
1173
  // …Y SE LE DICE A QUIEN APRUEBA, en todos los perfiles (dueño, 2026-10-08: «si se
1165
1174
  // requiere root por algún motivo, simplemente se notifica al aprobador que hay
@@ -1235,7 +1244,7 @@ export async function runDaemon () {
1235
1244
  }
1236
1245
  clearAsked(dir)
1237
1246
  if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
1238
- const res = await installUserRelease(r, { log: (m) => console.log(m) })
1247
+ const res = await installRelease(r, { log: (m) => console.log(m), kind, pid: process.pid })
1239
1248
  if (!res.ok) {
1240
1249
  // Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
1241
1250
  console.log(`[vault] update to ${r.version} NOT installed: ${res.reason}${res.file ? ` (file left at ${res.file})` : ''}`)
@@ -1252,7 +1261,12 @@ export async function runDaemon () {
1252
1261
  try { ipcWrite(updatedFile, { v: 1, from: daemonVersion, to: r.version, at: Date.now() }) } catch (e) {
1253
1262
  console.log(`[vault] update: ${r.version} installed, but the note to tell the approvers could not be written: ${e.message}`)
1254
1263
  }
1255
- if (vigia) {
1264
+ // Windows y macOS: hay que irse YA. En Windows el instalador cierra la bóveda y la vuelve
1265
+ // a arrancar; en macOS la levanta launchd o el relanzador que dejó la instalación.
1266
+ if (res.then === 'exit') {
1267
+ console.log(`[vault] update: ${r.version} installed · leaving so the new one starts`)
1268
+ shutdown(`updated to ${r.version}`)
1269
+ } else if (vigia) {
1256
1270
  console.log(`[vault] update: ${r.version} installed · restarting now to run it`)
1257
1271
  shutdown(`updated to ${r.version}`)
1258
1272
  } else {
@@ -1263,6 +1277,7 @@ export async function runDaemon () {
1263
1277
  } finally { actualizando = false }
1264
1278
  }
1265
1279
  let actualizando = false
1280
+ let avisadoOtra = null
1266
1281
  let aprobada = null
1267
1282
  let avisandoRoot = null
1268
1283
 
package/src/service.js CHANGED
@@ -93,7 +93,11 @@ const guiDomain = () => `gui/${process.getuid?.() ?? 501}`
93
93
 
94
94
  const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
95
95
 
96
- /** El LaunchAgent: lo arranca al entrar y lo relanza si se cae (no si se para a propósito). */
96
+ /**
97
+ * El LaunchAgent: lo arranca al entrar y lo relanza siempre que se va, como `Restart=always`
98
+ * de systemd. Hace falta también cuando sale bien: tras instalar la versión nueva, la bóveda
99
+ * se va para que el agente arranque la nueva. Para pararla de verdad, `autostart off`.
100
+ */
97
101
  export function launchAgentPlist ({ cmd, args }, log = daemonLogFile()) {
98
102
  const argv = [cmd, ...args].map((a) => ` <string>${xml(a)}</string>`).join('\n')
99
103
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -109,16 +113,16 @@ ${argv}
109
113
  <key>RunAtLoad</key>
110
114
  <true/>
111
115
  <key>KeepAlive</key>
112
- <dict>
113
- <key>SuccessfulExit</key>
114
- <false/>
115
- </dict>
116
+ <true/>
116
117
  <key>StandardOutPath</key>
117
118
  <string>${xml(log)}</string>
118
119
  <key>StandardErrorPath</key>
119
120
  <string>${xml(log)}</string>
121
+ <!-- Standard y no Background: en Background macOS frena la red y la CPU, y la bóveda
122
+ atiende al teléfono (firmas, aprobaciones). Con Background una actualización tardaba
123
+ más de 5 minutos en bajar y la verificación se caía por tiempo. -->
120
124
  <key>ProcessType</key>
121
- <string>Background</string>
125
+ <string>Standard</string>
122
126
  </dict>
123
127
  </plist>
124
128
  `
package/src/tui/app.js CHANGED
@@ -216,10 +216,11 @@ function offerUpdate (term, st) {
216
216
  setConfirm(st, { text: i.updateOffer(r.version, VERSION), onYes: () => installUpdate(term, st, r) })
217
217
  }
218
218
 
219
- async function installUpdate (term, st, release, { install = up.installUserRelease, userInstall = up.isUserInstall } = {}) {
219
+ async function installUpdate (term, st, release, { install = (rel) => up.installRelease(rel), userInstall = up.canSelfUpdate } = {}) {
220
220
  const i = L(st)
221
- // Solo la instalación de usuario se puede cambiar desde aquí. Un paquete del sistema es
222
- // de root: no se pide la contraseña por dentro, se dice el comando.
221
+ // Solo una instalación que puede cambiarse sola (la de usuario de Linux, el instalador de
222
+ // Windows, la app de macOS) se cambia desde aquí. Un paquete del sistema es de root: no se
223
+ // pide la contraseña por dentro, se dice el comando.
223
224
  if (!userInstall()) { flash(st, i.updateNeedsCommand, 'warn'); return }
224
225
  const r = await guard(term, st, i.updateInstalling(release.version), () => install(release))
225
226
  if (!r.ok) return
@@ -228,6 +229,9 @@ async function installUpdate (term, st, release, { install = up.installUserRelea
228
229
  flash(st, i.updateNoteFailed(e.message), 'warn')
229
230
  }
230
231
  st.update = { installed: r.v.version }
232
+ // WINDOWS: el instalador ya corre; cierra la bóveda y esta pantalla (son el mismo .exe), las
233
+ // cambia y vuelve a arrancar la bóveda. La ventana vuelve sola. No hay nada que preguntar.
234
+ if (r.v.then === 'exit' && process.platform === 'win32') { flash(st, i.updateWindowsInstalling(r.v.version), 'warn'); return }
231
235
  // Reiniciar se PREGUNTA: cierra las bóvedas abiertas y corta lo que estén haciendo los
232
236
  // aparatos. Si dices que no, el servicio se reinicia solo en un par de minutos.
233
237
  setConfirm(st, {
package/src/tui/i18n.js CHANGED
@@ -36,6 +36,7 @@ const es = {
36
36
  updateInstalling: (version) => `Bajando la ${version} y comprobando la firma…`,
37
37
  updateFailed: (reason) => `No se instaló nada: ${reason}`,
38
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.`,
39
40
  updateNoteFailed: (reason) => `Instalada, pero no se pudo dejar el aviso para tus aprobadores: ${reason}`,
40
41
  updateRestartOffer: (version) => `Instalada la ${version}. ¿Reiniciar ahora la bóveda y esta pantalla? Cierra las bóvedas abiertas`,
41
42
  updateRestarting: 'Reiniciando la bóveda…',
@@ -541,6 +542,7 @@ const en = {
541
542
  updateInstalling: (version) => `Downloading ${version} and checking its signature…`,
542
543
  updateFailed: (reason) => `Nothing was installed: ${reason}`,
543
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.`,
544
546
  updateNoteFailed: (reason) => `Installed, but the note for your approvers could not be written: ${reason}`,
545
547
  updateRestartOffer: (version) => `${version} is installed. Restart the vault and this screen now? It locks the open vaults`,
546
548
  updateRestarting: 'Restarting the vault…',
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'
@@ -137,3 +137,143 @@ async function instalarDesde (work, pick, release, { binDir, log, fetchVerifiedI
137
137
  }
138
138
  return { ok: true, version: release.version }
139
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
+ }