@dotrino/vaultd 0.145.0 → 0.147.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,37 @@
1
+ /**
2
+ * AJUSTES DE ESTA BÓVEDA: lo que el dueño elige distinto del comportamiento por defecto.
3
+ *
4
+ * Hoy es uno: `updateApproval`. Por defecto la bóveda se actualiza sola (lo que baja se
5
+ * verifica contra la atestación del release, se pida permiso o no); con `updateApproval:
6
+ * true` antes le pide permiso a los aparatos con `approve` (dueño, 2026-10-08).
7
+ *
8
+ * Va por el canal local (`ipc.js`): lo escribe la CLI (`dotrino-vault update --approval
9
+ * on|off`) y lo lee el daemon cada vez que mira si hay versión nueva, sin señal ni reinicio.
10
+ *
11
+ * UN ARCHIVO QUE EXISTE Y NO SE PUEDE LEER NO ES «EL VALOR POR DEFECTO»: el por defecto es
12
+ * actualizarse sin preguntar, así que tragarse el fallo sería saltarse la aprobación que el
13
+ * dueño pidió justo cuando algo se rompió. Se lanza.
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import { ipcRead, ipcWrite } from './ipc.js'
18
+
19
+ export const SETTINGS_FILE = 'settings.json'
20
+
21
+ const fail = (code, message) => Object.assign(new Error(message), { code })
22
+
23
+ /** @returns {{ updateApproval: boolean }} */
24
+ export function readSettings (dir) {
25
+ const file = path.join(dir, SETTINGS_FILE)
26
+ if (!fs.existsSync(file)) return { updateApproval: false }
27
+ const raw = ipcRead(file, null)
28
+ if (!raw || typeof raw !== 'object' || typeof raw.updateApproval !== 'boolean') {
29
+ throw fail('settings-unreadable', `${SETTINGS_FILE} exists but cannot be read`)
30
+ }
31
+ return { updateApproval: raw.updateApproval }
32
+ }
33
+
34
+ export function writeSettings (dir, { updateApproval }) {
35
+ if (typeof updateApproval !== 'boolean') throw fail('bad-setting', 'updateApproval must be true or false')
36
+ ipcWrite(path.join(dir, SETTINGS_FILE), { v: 1, updateApproval })
37
+ }
package/src/tui/app.js CHANGED
@@ -36,6 +36,7 @@ import { createTerm, widthOf } from './term.js'
36
36
  import { qrToString } from '../qr.js'
37
37
  import { dict, otherLang, loadLang, saveLang } from './i18n.js'
38
38
  import * as vc from '../vaultControl.js'
39
+ import { readSettings, writeSettings } from '../settings.js'
39
40
  import * as hw from '../hwkeys.js'
40
41
  import { VERSION } from '../version.js'
41
42
  import { DEVICE_CAPS } from '@dotrino/identity/acta'
@@ -155,7 +156,9 @@ function mergeMembersAndCerts (members, issued) {
155
156
  noAccess: !cert && !m.isMaster && !m.cn,
156
157
  // QUÉ CORRE ESE APARATO (CONVENCIONES §14). Lo apunta la bóveda cuando le habla, así
157
158
  // que aquí solo hay que dejarlo pasar — que es justo lo que le faltaba a `addedAt`.
158
- running: m.running || null
159
+ running: m.running || null,
160
+ // SI LE LLEGAN LOS AVISOS, solo en quien aprueba (`undefined` = no aprueba, no se pinta).
161
+ notifiedAt: (m.caps || []).includes('approve') && 'notifiedAt' in m ? m.notifiedAt : undefined
159
162
  }
160
163
  })
161
164
  }
@@ -336,6 +339,50 @@ function doorRows (st, t) {
336
339
  return rows
337
340
  }
338
341
 
342
+ /**
343
+ * AJUSTES DE LA BÓVEDA (no de un perfil). Cada regla se lee como una frase con su estado, y
344
+ * Enter la cambia. Hoy hay una: pedir aprobación para actualizarse.
345
+ *
346
+ * `st.settings` es lo leído al entrar: `{ updateApproval }`, o `{ error }` si el ajuste no se
347
+ * pudo leer — que se DICE, no se pinta como apagado: apagado es «se actualiza sola».
348
+ */
349
+ const settingsDir = (st) => st.settingsDir || vc.vaultDir()
350
+ function loadSettings (st) {
351
+ try { st.settings = readSettings(settingsDir(st)) } catch (e) { st.settings = { error: e?.message || String(e) } }
352
+ }
353
+ function settingsRows (st, t) {
354
+ const i = L(st)
355
+ const cfg = st.settings
356
+ if (!cfg) return [{ text: t.muted(' —'), sel: false }]
357
+ const rows = []
358
+ if (cfg.error) {
359
+ rows.push({ text: ' ' + t.warn(i.settingsUnreadable), sel: false })
360
+ rows.push({ text: t.muted(' ' + i.settingsUnreadableHint), sel: false })
361
+ rows.push({ text: '', sel: false })
362
+ }
363
+ const frase = cfg.error ? i.settingUpdateUnknown : cfg.updateApproval ? i.settingUpdateAsks : i.settingUpdateAlone
364
+ rows.push({ text: ` ${t.bold(frase)} ${t.accent(i.settingChange)}`, sel: true, meta: { setting: 'updateApproval' } })
365
+ rows.push({ text: t.muted(' ' + i.settingUpdateHint1), sel: false })
366
+ rows.push({ text: t.muted(' ' + i.settingUpdateHint2), sel: false })
367
+ return rows
368
+ }
369
+ async function onKeySettings (term, st, key) {
370
+ const i = L(st)
371
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
372
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'profiles'; return true }
373
+ if (key.name === 'enter' || (key.name === 'char' && key.ch === ' ')) {
374
+ // Con el ajuste ilegible, Enter lo ESCRIBE de nuevo (encendido: es lo prudente cuando
375
+ // no se sabe qué había); así la pantalla también es la salida del atasco.
376
+ const next = st.settings?.error ? true : !st.settings?.updateApproval
377
+ try {
378
+ writeSettings(settingsDir(st), { updateApproval: next })
379
+ loadSettings(st)
380
+ flash(st, next ? i.settingUpdateNowAsks : i.settingUpdateNowAlone)
381
+ } catch (e) { flash(st, i.settingSaveFailed(e?.message || String(e)), 'danger') }
382
+ }
383
+ return true
384
+ }
385
+
339
386
  function deviceRows (st, t) {
340
387
  const i = L(st)
341
388
  const rows = []
@@ -386,6 +433,9 @@ function deviceRows (st, t) {
386
433
  const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
387
434
  (vars ? t.muted(` vars:${vars}`) : '') +
388
435
  corre +
436
+ // En quien aprueba: si dijo que le llegan los avisos. No cambia a quién se le pide
437
+ // (a todos); deja ver a cuál no le suena el pedido. Es un dato, va en gris.
438
+ (d.notifiedAt === undefined ? '' : t.muted(` ${d.notifiedAt ? i.notifyYes : i.notifyUnknown}`)) +
389
439
  // EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
390
440
  // sus variables, que es donde se mira cuando algo no arranca.
391
441
  (debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
@@ -1199,6 +1249,11 @@ async function onKeyProfiles (term, st, key) {
1199
1249
  },
1200
1250
  onCancel: () => { st.input = null }
1201
1251
  }))
1252
+ } else if (ch === 's') { // ajustes de la bóveda (no de un perfil: no hace falta elegir uno)
1253
+ loadSettings(st)
1254
+ st.sel.settings = 0
1255
+ st.scroll.settings = { value: 0 }
1256
+ st.screen = 'settings'
1202
1257
  } else if (ch === 'y' && cur) { // las llaves: con qué se abre la bóveda
1203
1258
  await ensureUnlocked(term, st, cur, async (p = cur) => {
1204
1259
  st.doorsFor = p.id
@@ -2303,6 +2358,7 @@ const helpSegs = (i, screen, st = {}) => {
2303
2358
  pairing: i.helpPairing,
2304
2359
  pairmode: i.helpPairMode,
2305
2360
  doors: i.helpDoors,
2361
+ settings: i.helpSettings,
2306
2362
  paircaps: i.helpPairCaps,
2307
2363
  join: i.helpJoin,
2308
2364
  me: i.helpMe,
@@ -2338,6 +2394,7 @@ const title = (i, screen) => ({
2338
2394
  pairing: i.titlePairing,
2339
2395
  pairmode: i.titlePairMode,
2340
2396
  doors: i.titleDoors,
2397
+ settings: i.titleSettings,
2341
2398
  paircaps: i.titlePairCaps,
2342
2399
  join: i.titleJoin,
2343
2400
  caps: i.titleCaps,
@@ -2438,10 +2495,15 @@ function render (term, st) {
2438
2495
  // instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
2439
2496
  // en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
2440
2497
  lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
2441
- // Una actualización que lleva tiempo pidiendo permiso sin respuesta: la bóveda parece
2498
+ // Una actualización que pidió permiso (una vez por versión, dura un día): la bóveda parece
2442
2499
  // normal y simplemente no se actualiza, así que se dice aquí.
2443
2500
  if (!lines[2] && up && s?.updateAsk?.version) {
2444
- lines[2] = ' ' + t.warn(i.updateUnanswered(s.updateAsk.version, Math.floor((Date.now() - s.updateAsk.since) / 86400000)))
2501
+ const u = s.updateAsk
2502
+ const dia = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ')
2503
+ // Esperando es un estado normal (gris); no aprobada es lo que hay que ver (aviso).
2504
+ lines[2] = ' ' + (u.result === 'pending'
2505
+ ? t.muted(i.updateWaiting(u.version, dia(u.askedAt + 24 * 60 * 60 * 1000)))
2506
+ : t.warn(i.updateNotApproved(u.version, dia(u.askedAt))))
2445
2507
  }
2446
2508
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
2447
2509
  // el resto muestra su título simple.
@@ -2463,6 +2525,7 @@ function render (term, st) {
2463
2525
  else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
2464
2526
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
2465
2527
  else if (st.screen === 'doors') body = renderList(doorRows(st, t), st.sel.doors || 0, contentH, cols, t, scrollRef)
2528
+ else if (st.screen === 'settings') body = renderList(settingsRows(st, t), st.sel.settings || 0, contentH, cols, t, scrollRef)
2466
2529
  else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
2467
2530
  else if (st.screen === 'pairing') {
2468
2531
  const pb = pairingBody(st, t, cols, contentH)
@@ -2546,7 +2609,7 @@ export async function runTui () {
2546
2609
  const st = {
2547
2610
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
2548
2611
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
2549
- sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0 },
2612
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0, settings: 0 },
2550
2613
  // Permisos tocados y sin guardar, por llave del aparato (ver `setCapsDraft`).
2551
2614
  capsDrafts: {},
2552
2615
  // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
@@ -2653,6 +2716,7 @@ export async function runTui () {
2653
2716
  else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
2654
2717
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
2655
2718
  else if (st.screen === 'doors') running = await onKeyDoors(term, st, key)
2719
+ else if (st.screen === 'settings') running = await onKeySettings(term, st, key)
2656
2720
  else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
2657
2721
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
2658
2722
  else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
@@ -2670,4 +2734,4 @@ export async function runTui () {
2670
2734
  }
2671
2735
 
2672
2736
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
2673
- export const __test = { render, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
2737
+ export const __test = { render, settingsRows, onKeySettings, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
package/src/tui/i18n.js CHANGED
@@ -26,7 +26,8 @@ const es = {
26
26
  // encabezado / estado
27
27
  daemonRunning: 'corriendo',
28
28
  daemonStopped: 'DETENIDO',
29
- updateUnanswered: (version, days) => `⬆ la ${version} espera tu aprobación desde hace ${days < 1 ? 'menos de un día' : days === 1 ? '1 día' : days + ' días'}: mira Pedidos en tu teléfono`,
29
+ updateWaiting: (version, until) => `⬆ la ${version} espera tu aprobación hasta ${until} UTC: mira Pedidos en tu teléfono`,
30
+ updateNotApproved: (version, when) => `⬆ se pidió permiso para la ${version} el ${when} UTC y no se aprobó: no se vuelve a pedir · dotrino-vault update`,
30
31
  daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
31
32
  activeVault: 'Bóveda activa: ',
32
33
  noName: '(sin nombre)',
@@ -169,6 +170,18 @@ const es = {
169
170
  titlePairing: 'Emparejar un dispositivo',
170
171
  titlePairMode: 'Emparejar: ¿a qué cuenta entra?',
171
172
  titleDoors: 'Con qué se abre la bóveda',
173
+ titleSettings: 'Ajustes de esta bóveda',
174
+ settingUpdateAlone: 'Esta bóveda se actualiza sola, sin pedir aprobación.',
175
+ settingUpdateAsks: 'Esta bóveda pide aprobación antes de actualizarse.',
176
+ settingUpdateUnknown: 'No se sabe si esta bóveda pide aprobación para actualizarse.',
177
+ settingChange: 'Enter cambiar',
178
+ settingUpdateHint1: 'Vale para todos los perfiles: se pide a los aparatos con «aprueba» de cualquiera de ellos, una vez por versión.',
179
+ settingUpdateHint2: 'Si ningún perfil tiene un aparato con «aprueba», la bóveda se actualiza sola.',
180
+ settingsUnreadable: 'El ajuste no se pudo leer: mientras siga así, la bóveda no se actualiza.',
181
+ settingsUnreadableHint: 'Pulsa Enter para volver a guardarlo (queda pidiendo aprobación; Enter otra vez lo cambia).',
182
+ settingUpdateNowAsks: 'Hecho: pide aprobación antes de actualizarse.',
183
+ settingUpdateNowAlone: 'Hecho: se actualiza sola.',
184
+ settingSaveFailed: (m) => `No se pudo guardar el ajuste: ${m}`,
172
185
  doorsIntro: (name) => `Con qué se abre «${name}». Abre cualquiera de estas.`,
173
186
  doorsNone: 'Sin candado: se abre sola en esta máquina.',
174
187
  doorPassword: 'contraseña',
@@ -272,6 +285,8 @@ const es = {
272
285
  revokedCount: (n) => ` Revocados: ${n}`,
273
286
  startingPairing: 'Iniciando emparejamiento…',
274
287
  deviceSince: 'desde',
288
+ notifyYes: 'le llegan avisos',
289
+ notifyUnknown: 'no ha dicho si le llegan avisos',
275
290
  nameDeviceLabel: '¿Cómo se va a llamar este aparato?',
276
291
  nameDeviceHint: 'Enter para dejar que lo diga él (usará tu apodo, y así no se distinguen)',
277
292
  noPending: 'No hay ningún dispositivo pendiente',
@@ -414,7 +429,7 @@ const es = {
414
429
  // INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
415
430
  // language/quit); lo único que se traduce es la palabra que las explica.
416
431
  // Segmentos, no una línea: si no caben en una, el render los reparte en varias.
417
- helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
432
+ helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 's ajustes', 'l English', 'q salir'],
418
433
  // La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
419
434
  // valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
420
435
  // Anunciar teclas muertas confunde y además las quema para otros usos.
@@ -452,6 +467,7 @@ const es = {
452
467
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
453
468
  helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
454
469
  helpDoors: ['↑↓', 'Enter añadir', 'd quitar', 'Esc atrás', 'l English', 'q salir'],
470
+ helpSettings: ['Enter cambiar', 'Esc atrás', 'l English', 'q salir'],
455
471
  helpJoin: ['Esc bóvedas', 'l English', 'q salir'],
456
472
 
457
473
  // pantalla "daemon caído"
@@ -499,7 +515,8 @@ const en = {
499
515
 
500
516
  daemonRunning: 'running',
501
517
  daemonStopped: 'STOPPED',
502
- updateUnanswered: (version, days) => `⬆ ${version} has been waiting for your approval for ${days < 1 ? 'less than a day' : days === 1 ? '1 day' : days + ' days'}: check Requests on your phone`,
518
+ updateWaiting: (version, until) => `⬆ ${version} is waiting for your approval until ${until} UTC: check Requests on your phone`,
519
+ updateNotApproved: (version, when) => `⬆ approval for ${version} was asked on ${when} UTC and not given: it will not ask again · dotrino-vault update`,
503
520
  daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
504
521
  activeVault: 'Active vault: ',
505
522
  tooSmall: 'Terminal too small',
@@ -622,6 +639,18 @@ const en = {
622
639
  titlePairing: 'Pair a device',
623
640
  titlePairMode: 'Pairing: which account does it join?',
624
641
  titleDoors: 'What opens this vault',
642
+ titleSettings: 'Settings of this vault',
643
+ settingUpdateAlone: 'This vault updates on its own, without asking for approval.',
644
+ settingUpdateAsks: 'This vault asks for approval before updating.',
645
+ settingUpdateUnknown: 'It is not known whether this vault asks for approval to update.',
646
+ settingChange: 'Enter change',
647
+ settingUpdateHint1: 'It applies to every profile: devices with “approve” in any of them are asked, once per version.',
648
+ settingUpdateHint2: 'If no profile has a device with “approve”, the vault updates on its own.',
649
+ settingsUnreadable: 'The setting could not be read: while it stays like this, the vault does not update.',
650
+ settingsUnreadableHint: 'Press Enter to save it again (it will ask for approval; Enter again changes it).',
651
+ settingUpdateNowAsks: 'Done: it asks for approval before updating.',
652
+ settingUpdateNowAlone: 'Done: it updates on its own.',
653
+ settingSaveFailed: (m) => `The setting could not be saved: ${m}`,
625
654
  doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
626
655
  doorsNone: 'No lock: it opens by itself on this machine.',
627
656
  doorPassword: 'password',
@@ -719,6 +748,8 @@ const en = {
719
748
  revokedCount: (n) => ` Revoked: ${n}`,
720
749
  startingPairing: 'Starting pairing…',
721
750
  deviceSince: 'since',
751
+ notifyYes: 'gets notifications',
752
+ notifyUnknown: 'has not said if notifications reach it',
722
753
  nameDeviceLabel: 'What will this device be called?',
723
754
  nameDeviceHint: 'Enter to let it say (it will use your nickname, and then they all look alike)',
724
755
  noPending: 'No device is waiting',
@@ -843,7 +874,7 @@ const en = {
843
874
  helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
844
875
  helpConfirm: 'y confirm · n/Esc cancel',
845
876
 
846
- helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 'l Español', 'q quit'],
877
+ helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 's settings', 'l Español', 'q quit'],
847
878
  helpDevices: ({ pending, hasDevices, isService, drafts, curBlocked } = {}) => [
848
879
  '←→ tab', '↑↓', 'p pair',
849
880
  ...(drafts ? ['g save permissions'] : []),
@@ -875,6 +906,7 @@ const en = {
875
906
  helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
876
907
  helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
877
908
  helpDoors: ['↑↓', 'Enter add', 'd remove', 'Esc back', 'l Español', 'q quit'],
909
+ helpSettings: ['Enter change', 'Esc back', 'l Español', 'q quit'],
878
910
  helpJoin: ['Esc vaults', 'l Español', 'q quit'],
879
911
 
880
912
  downTitle: 'The vault daemon is not running.',
@@ -0,0 +1,110 @@
1
+ /**
2
+ * PEDIR PERMISO PARA ACTUALIZARSE, EN TODOS LOS PERFILES A LA VEZ.
3
+ *
4
+ * El ajuste es de la BÓVEDA, no de un perfil (dueño, 2026-10-08): «si se activa funciona si
5
+ * cualquier perfil tiene aprobador, afecta a todos los perfiles y se envía notificación a
6
+ * todos los perfiles». El binario es uno solo, así que quien aprueba en cualquiera de las
7
+ * cuentas que viven aquí decide por la máquina.
8
+ *
9
+ * Sin estado y sin red: recibe los perfiles en marcha y lo que cada uno sabe hacer, para
10
+ * poder probarse sin daemon (`test/actualizar-todos-los-perfiles.test.mjs`).
11
+ */
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { ipcRead, ipcWrite } from './ipc.js'
15
+
16
+ /**
17
+ * Quién aprueba, en todos los perfiles. SI UNO NO SE PUEDE LEER, SE LANZA: una lista a medias
18
+ * sería decidir con menos aprobadores de los que hay, que es el repliegue que abre la puerta
19
+ * justo cuando algo se rompió.
20
+ * @param {{ id: string, name?: string, vault: { approvers: () => Promise<any[]> } }[]} profiles
21
+ * @returns {Promise<{ profile: string, profileId: string, id: string, label: string, pub: string, notifiedAt: number|null }[]>}
22
+ */
23
+ export async function approversOfAll (profiles) {
24
+ const out = []
25
+ for (const p of profiles) {
26
+ let list
27
+ try { list = await p.vault.approvers() } catch (e) {
28
+ throw Object.assign(new Error(`profile ${p.name || p.id}: ${e?.message || e}`), { code: 'approvers-unreadable' })
29
+ }
30
+ for (const a of list) out.push({ ...a, profile: p.name || p.id, profileId: p.id })
31
+ }
32
+ return out
33
+ }
34
+
35
+ /**
36
+ * Pide en cada perfil y espera: EL PRIMER «SÍ» BASTA; es «no» cuando todos dijeron que no o
37
+ * vencieron. Al resolverse se RETIRAN los pedidos que quedaron vivos en los demás, para que
38
+ * no se queden un día en la lista de quien aprueba pidiendo algo que ya se decidió.
39
+ * @param {{ answer: Promise<boolean>, withdraw: () => void }[]} asks
40
+ * @returns {Promise<boolean>}
41
+ */
42
+ export function firstYes (asks) {
43
+ if (!asks.length) return Promise.resolve(false)
44
+ return new Promise((resolve) => {
45
+ let left = asks.length
46
+ let done = false
47
+ const finish = (ok, winner) => {
48
+ if (done) return
49
+ done = true
50
+ for (const a of asks) { if (a !== winner) { try { a.withdraw() } catch (_) { /* retirar es limpieza: no cambia la respuesta */ } } }
51
+ resolve(ok)
52
+ }
53
+ for (const a of asks) {
54
+ Promise.resolve(a.answer).then((ok) => {
55
+ if (ok === true) return finish(true, a)
56
+ if (--left === 0) finish(false, null)
57
+ }, () => { if (--left === 0) finish(false, null) })
58
+ }
59
+ })
60
+ }
61
+
62
+ /**
63
+ * EL PEDIDO SE HACE UNA VEZ POR VERSIÓN (dueño, 2026-10-08): «dura un día, pero se hace una
64
+ * sola vez; no se reintenta al siguiente día; se asume negado si no se hizo en 24 horas; se
65
+ * dispara nuevamente en la siguiente actualización».
66
+ *
67
+ * Así que se apunta QUÉ versión se preguntó, AL PEDIR, y sobrevive a un reinicio: una bóveda
68
+ * que se reinicia con el pedido a medias no vuelve a timbrar por lo mismo. Va por el canal
69
+ * local (cifrado en reposo), junto al resto del estado del daemon.
70
+ *
71
+ * UN ARCHIVO QUE EXISTE Y NO SE PUEDE LEER NO ES «NUNCA SE PREGUNTÓ» —eso volvería a pedir— ni
72
+ * tampoco «ya se aprobó»: se lanza, y quien llama no actualiza y lo dice.
73
+ */
74
+
75
+ export const ASKED_FILE = 'update-asked.json'
76
+ const RESULTS = ['pending', 'denied', 'expired']
77
+ const fail = (code, message) => Object.assign(new Error(message), { code })
78
+
79
+ /** @returns {{ version: string, askedAt: number, result: 'pending'|'denied'|'expired' } | null} */
80
+ export function readAsked (dir) {
81
+ const file = path.join(dir, ASKED_FILE)
82
+ if (!fs.existsSync(file)) return null
83
+ const d = ipcRead(file, null)
84
+ if (!d || typeof d.version !== 'string' || !d.version || typeof d.askedAt !== 'number' || !RESULTS.includes(d.result)) {
85
+ throw fail('asked-unreadable', `${ASKED_FILE} exists but cannot be read`)
86
+ }
87
+ return { version: d.version, askedAt: d.askedAt, result: d.result }
88
+ }
89
+
90
+ export function writeAsked (dir, { version, askedAt, result }) {
91
+ if (typeof version !== 'string' || !version || typeof askedAt !== 'number' || !RESULTS.includes(result)) throw fail('bad-asked', 'writeAsked: version, askedAt and a known result are required')
92
+ ipcWrite(path.join(dir, ASKED_FILE), { v: 1, version, askedAt, result })
93
+ }
94
+
95
+ /** Ya se instaló (o ya no hace falta preguntar): se olvida lo preguntado. */
96
+ export function clearAsked (dir) { fs.rmSync(path.join(dir, ASKED_FILE), { force: true }) }
97
+
98
+ /**
99
+ * ¿Hay que pedir permiso por `version`? Solo si nunca se preguntó, o si es MÁS NUEVA que la
100
+ * que se preguntó. La misma (o una anterior) ya tuvo su pedido: no se repite.
101
+ * @param {(a: string, b: string) => boolean} isNewer `isNewer(a, b)`: a es más nueva que b
102
+ */
103
+ export function shouldAsk (asked, version, isNewer) {
104
+ return !asked || isNewer(version, asked.version)
105
+ }
106
+
107
+ /** Un «no» que llega cuando el pedido ya cumplió su día es un vencimiento; antes, alguien lo denegó. */
108
+ export function resultOfNo (askedAt, ttlMs, now = Date.now()) {
109
+ return now - askedAt >= ttlMs - 60_000 ? 'expired' : 'denied'
110
+ }