@dotrino/vaultd 0.151.0 → 0.153.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/lib/src/enroll.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * `docs/pairing-protocol.md`).
31
31
  */
32
32
  import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
33
- import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
33
+ import { verifyContinuity, canSeal, DEVICE_CAPS, VAULT_CN } from '@dotrino/identity/acta'
34
34
 
35
35
  /** Un token de emparejamiento vale 5 min. */
36
36
  export const PAIRING_TTL_MS = 5 * 60 * 1000
@@ -169,12 +169,16 @@ export function createEnrollDesk ({
169
169
  * el código, igual que hacía `caps` después.
170
170
  */
171
171
  async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
172
+ // El nombre de la propia bóveda no es un servicio: su llave firma con el perfil cerrado
173
+ // y no puede abrir ningún cajón. Un servicio que entrara con ese nombre tampoco podría.
174
+ if (scopeToCn(scope) === VAULT_CN) throw Object.assign(new Error(`"${VAULT_CN}" is the vault's own name: a service cannot use it`), { code: 'reserved-cn' })
172
175
  if (caps != null) {
173
176
  if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
174
177
  const cn = scopeToCn(scope)
175
- const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
178
+ const bad = caps.filter((c) => !DEVICE_CAPS.includes(c))
176
179
  if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
177
- if (!caps.length) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
180
+ // Un servicio puede entrar sin ningún permiso: su cajón lo abre el CN.
181
+ if (!caps.length && !cn) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
178
182
  caps = [...new Set(caps)]
179
183
  }
180
184
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
@@ -378,13 +382,14 @@ export function createEnrollDesk ({
378
382
  throw Object.assign(new Error('this vault cannot add devices to the account record: no certificate was issued'), { code: 'admit-unavailable' })
379
383
  }
380
384
  // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
381
- // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
382
- // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
385
+ // cajón (`secrets:<ns>`) fija el CN, que es lo que lo abre —no hay permiso aparte
386
+ // (dueño, 2026-10-13)—, y no borra lo demás: un bot con `sign,secrets:eco` firma como
387
+ // aparato del acta Y lee solo su cajón.
383
388
  // Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
384
389
  // esta misma acta; si no, los que da el scope.
385
390
  const cn = scopeToCn(pend.scope)
386
- const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
387
- if (!caps.length) {
391
+ const caps = [...new Set(pend.caps || scopeToCaps(pend.scope))]
392
+ if (!caps.length && !cn) {
388
393
  throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
389
394
  }
390
395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.151.0",
3
+ "version": "0.153.0",
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": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "^0.107.1",
25
+ "@dotrino/identity": "^0.111.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
28
  "@dotrino/proxy-client": "0.30.1",
package/src/ctl.js CHANGED
@@ -39,6 +39,7 @@ import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, en
39
39
  import { VERSION } from './version.js'
40
40
  import * as hw from './hwkeys.js'
41
41
  import { isNewer } from './update.js'
42
+ import { COMM_CN } from './commKey.js'
42
43
 
43
44
  const dir = dataDir()
44
45
  const stateFile = path.join(dir, 'state.json')
@@ -614,7 +615,7 @@ async function cmdMembers () {
614
615
  // español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
615
616
  // puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
616
617
  const CAP = {
617
- sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
618
+ sign: 'firma', store: 'guarda', read: 'lee',
618
619
  admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
619
620
  sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`,
620
621
  // Resaltado como los otros cuatro: dice si esa máquina se lleva tus claves privadas
@@ -644,7 +645,8 @@ async function cmdMembers () {
644
645
  m.isMe ? 'este dispositivo' : null,
645
646
  m.cn ? `servicio «${m.cn}»` : null
646
647
  ].filter(Boolean)
647
- const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
648
+ // Un servicio sin permisos no está «sin nada»: su cajón lo abre el nombre, no un permiso.
649
+ const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : (m.cn && m.cn !== COMM_CN ? '(solo su cajón)' : '(sin permisos)')
648
650
  // CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
649
651
  // distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
650
652
  // es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
@@ -656,13 +658,13 @@ async function cmdMembers () {
656
658
  // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
657
659
  // se mira quién es quién — y en la lista de variables ya seria tarde.
658
660
  //
659
- // Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
660
- // `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
661
- // en rojo «NO puede leer sus variables» cuando no tiene ninguna que leer.
661
+ // Menos la llave de comunicación de la bóveda: entra con `cn: 'vault'` y solo `firma`,
662
+ // y no tiene llave de cifrado a propósito: salía marcada en rojo «NO puede leer sus
663
+ // variables» cuando no tiene ninguna que leer.
662
664
  // SI LE LLEGAN LOS AVISOS, en quien aprueba. No cambia a quién se le pide —se le pide a
663
665
  // todos—: es para ver a cuál no le suena el pedido.
664
666
  if (m.caps.includes('approve') && 'notifiedAt' in m) console.log(' %s%s%s', D, avisosTxt(m.notifiedAt), Z)
665
- if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
667
+ if (m.cn && m.cn !== COMM_CN && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
666
668
  // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
667
669
  // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
668
670
  // fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
@@ -1830,8 +1832,8 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
1830
1832
  * marcador que deja el servicio cuando se actualiza solo. Si no se puede escribir se dice:
1831
1833
  * la actualización está hecha, lo que se pierde es el aviso.
1832
1834
  */
1833
- function noteUpdated (to) {
1834
- try { ipcWrite(path.join(dir, 'updated.json'), { v: 1, from: readState().version || VERSION, to, at: Date.now() }) } catch (e) {
1835
+ async function noteUpdated (to) {
1836
+ try { (await import('./update.js')).noteUpdated(dir, { from: readState().version || VERSION, to }) } catch (e) {
1835
1837
  console.error('No se pudo dejar el aviso de actualización para tus aprobadores: %s', e.message)
1836
1838
  }
1837
1839
  }
@@ -1881,8 +1883,23 @@ async function cmdUpdate (args = []) {
1881
1883
  }
1882
1884
  console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1883
1885
  console.log('Instalada la %s.', res.version)
1884
- noteUpdated(res.version)
1885
- if (!args.includes('--no-wait')) await waitForService(res.version)
1886
+ await noteUpdated(res.version)
1887
+ if (args.includes('--no-wait')) return
1888
+ // AL TERMINAR SE OFRECE REINICIAR (dueño, 2026-10-13). El servicio se reinicia solo al
1889
+ // ver el binario nuevo, pero espera a verlo quieto dos minutos: quien está delante puede
1890
+ // no querer esperar. Solo se pregunta con alguien delante y con systemd; si no, se espera.
1891
+ const s = ipcRead(stateFile, null)
1892
+ if (s && alive(s.pid) && s.version !== res.version && process.stdin.isTTY && process.platform === 'linux' && !IN_DOCKER) {
1893
+ const resp = (await askText('¿Reiniciar la bóveda ahora para usarla? Cierra las bóvedas abiertas. [s/N] ')).trim().toLowerCase()
1894
+ if (resp === 's' || resp === 'y') {
1895
+ try { execFileSync('systemctl', ['--user', 'restart', 'dotrino-vault'], { stdio: 'inherit' }) } catch (e) {
1896
+ console.error('No se pudo reiniciar: %s', e.message)
1897
+ console.error('Hazlo a mano: %s', RESTART_HINT)
1898
+ process.exit(1)
1899
+ }
1900
+ }
1901
+ }
1902
+ await waitForService(res.version)
1886
1903
  return
1887
1904
  }
1888
1905
 
@@ -1922,7 +1939,7 @@ async function cmdUpdate (args = []) {
1922
1939
  B, file, r.version, Z)
1923
1940
  return
1924
1941
  }
1925
- noteUpdated(r.version)
1942
+ await noteUpdated(r.version)
1926
1943
  console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
1927
1944
  }
1928
1945
 
package/src/daemon.js CHANGED
@@ -245,7 +245,7 @@ export async function runDaemon () {
245
245
  if (caps) {
246
246
  caps = [...new Set(caps)]
247
247
  const cn = scopeToCn(base)
248
- const fromCaps = caps.map((c) => capScope(c, cn)).filter((x) => x && ALLOWED(x))
248
+ const fromCaps = caps.map((c) => capScope(c)).filter((x) => x && ALLOWED(x))
249
249
  // Sin `--scope` el papel sale de los permisos; con él, se suman.
250
250
  scope = [...new Set([...(asked?.length || isService ? base : []), ...fromCaps])]
251
251
  if (!scope.length) {
package/src/tui/app.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
31
31
  */
32
32
  import fs from 'node:fs'
33
- import { execFile } from 'node:child_process'
33
+ import { execFile, spawnSync } from 'node:child_process'
34
34
  import { parseEnvInput } from '../../lib/src/envtext.js'
35
35
  import { createTerm, widthOf } from './term.js'
36
36
  import { qrToString } from '../qr.js'
@@ -39,6 +39,7 @@ import * as vc from '../vaultControl.js'
39
39
  import { readSettings, writeSettings } from '../settings.js'
40
40
  import * as hw from '../hwkeys.js'
41
41
  import { VERSION } from '../version.js'
42
+ import * as up from '../update.js'
42
43
  import { DEVICE_CAPS } from '@dotrino/identity/acta'
43
44
 
44
45
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
@@ -184,6 +185,65 @@ function lockGlyph (p) {
184
185
  return p.locked ? '🔒' : '🔓'
185
186
  }
186
187
 
188
+ function restartDaemonService () {
189
+ return new Promise((res, rej) => {
190
+ execFile('systemctl', ['--user', 'restart', 'dotrino-vault'], { timeout: 20000 }, (err, so, se) => {
191
+ if (err) rej(new Error(String(se || '').trim() || err.message)); else res()
192
+ })
193
+ })
194
+ }
195
+
196
+ /**
197
+ * AL ENTRAR SE DICE SI HAY VERSIÓN NUEVA, Y SE OFRECE INSTALARLA (dueño, 2026-10-13: como
198
+ * hace la app de Terminal). El servicio ya se actualiza solo; esto es para quien está
199
+ * mirando: enterarse ahí mismo, instalarla sin salir y, al terminar, reiniciar.
200
+ *
201
+ * Se mira EN SEGUNDO PLANO: la pantalla sale al momento y el aviso llega cuando el registro
202
+ * contesta. «No se pudo mirar» no es «estás al día»: no se dice nada y queda en `st.update`.
203
+ */
204
+ function checkForUpdate (st, { latest = up.latestRelease } = {}) {
205
+ if (VERSION === 'dev') return
206
+ st.update = { checking: true }
207
+ Promise.resolve().then(() => latest()).then((r) => {
208
+ if (!r?.ok) { st.update = { failed: r?.reason || 'unknown' }; return }
209
+ st.update = up.isNewer(r.version, VERSION) ? { release: r, offer: true } : { current: true }
210
+ }).catch((e) => { st.update = { failed: e?.message || String(e) } })
211
+ }
212
+
213
+ /** Pregunta UNA vez por sesión. Decir que no deja el aviso arriba, sin volver a preguntar. */
214
+ function offerUpdate (term, st) {
215
+ const i = L(st)
216
+ const r = st.update.release
217
+ st.update = { release: r }
218
+ setConfirm(st, { text: i.updateOffer(r.version, VERSION), onYes: () => installUpdate(term, st, r) })
219
+ }
220
+
221
+ async function installUpdate (term, st, release, { install = up.installUserRelease, userInstall = up.isUserInstall } = {}) {
222
+ const i = L(st)
223
+ // Solo la instalación de usuario se puede cambiar desde aquí. Un paquete del sistema es
224
+ // de root: no se pide la contraseña por dentro, se dice el comando.
225
+ if (!userInstall()) { flash(st, i.updateNeedsCommand, 'warn'); return }
226
+ const r = await guard(term, st, i.updateInstalling(release.version), () => install(release))
227
+ if (!r.ok) return
228
+ if (!r.v.ok) { flash(st, i.updateFailed(r.v.reason), 'danger'); return }
229
+ try { up.noteUpdated(vc.vaultDir(), { from: st.state?.version || VERSION, to: r.v.version }) } catch (e) {
230
+ flash(st, i.updateNoteFailed(e.message), 'warn')
231
+ }
232
+ st.update = { installed: r.v.version }
233
+ // Reiniciar se PREGUNTA: cierra las bóvedas abiertas y corta lo que estén haciendo los
234
+ // aparatos. Si dices que no, el servicio se reinicia solo en un par de minutos.
235
+ setConfirm(st, {
236
+ text: i.updateRestartOffer(r.v.version),
237
+ onYes: async () => {
238
+ const rr = await guard(term, st, i.updateRestarting, () => restartDaemonService())
239
+ if (!rr.ok) return
240
+ st.relaunch = true
241
+ st.quit = true
242
+ },
243
+ onNo: () => { flash(st, i.updateRestartLater(r.v.version)) }
244
+ })
245
+ }
246
+
187
247
  function startDaemonService () {
188
248
  return new Promise((res) => {
189
249
  execFile('systemctl', ['--user', 'start', 'dotrino-vault'], { timeout: 8000 }, (err, so, se) => {
@@ -562,17 +622,12 @@ const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
562
622
  ...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
563
623
 
564
624
  /**
565
- * Los permisos que se le pueden dar A ESTE miembro, en orden.
566
- *
567
- * `secrets` solo existe para un SERVICIO (los que tienen `cn`), y por eso faltaba: la
568
- * lista era fija y no cabía algo que depende de quién sea. Pero para un servicio es EL
569
- * permiso —el que decide si abre su cajón— y no verlo aquí hacía creer que no existe,
570
- * igual que pasaba con `unattended`. Se enseña primero, que es su importancia real.
571
- *
572
- * A un aparato SIN `cn` no se le ofrece: no hay cajón que abrir, el acta lo filtra
573
- * (`allowedCaps`) y enseñar una casilla que no hace nada es peor que no enseñarla.
625
+ * EL CAJÓN NO ES UNA CASILLA (dueño, 2026-10-13: «no entiendo por qué es un permiso el
626
+ * acceso a un cajón, el cn ya garantiza eso»). Antes un servicio traía aquí «secretos
627
+ * «passmanager»» al lado de «contrasenas», y parecían dos permisos sobre lo mismo. Un
628
+ * servicio abre el cajón de su nombre por ser ese servicio: se DICE arriba de la lista
629
+ * (`capsDrawer`), y no se marca ni se desmarca.
574
630
  */
575
- const capsForMember = (member) => (member?.cn ? ['secrets', ...CAPS_ORDER] : CAPS_ORDER)
576
631
 
577
632
  /** Con lo que entra un aparato si no eliges otra cosa: lo mismo que `pair` sin `--scope`. */
578
633
  const PAIR_DEFAULT_CAPS = ['sign', 'read', 'store']
@@ -586,11 +641,10 @@ const PAIR_DEFAULT_CAPS = ['sign', 'read', 'store']
586
641
  function capChecklist (i, t, { cn = null, has, real = has }) {
587
642
  const rows = []
588
643
  let tocados = 0
589
- for (const cap of capsForMember({ cn })) {
644
+ if (cn) rows.push({ text: t.muted(' ' + i.capsDrawer(cn)), sel: false }, { text: '', sel: false })
645
+ for (const cap of CAPS_ORDER) {
590
646
  const mark = has.has(cap) ? '[x]' : '[ ]'
591
- // El nombre de `secrets` lleva el cajón dentro: «las claves de proxy» dice qué abre,
592
- // «lee sus claves» te deja preguntándote cuáles.
593
- const name = typeof i.capName[cap] === 'function' ? i.capName[cap](cn) : i.capName[cap]
647
+ const name = i.capName[cap]
594
648
  // LO QUE CAMBIA SE SEÑALA. Sin esto el borrador es indistinguible del acta y no sabes
595
649
  // qué vas a firmar — que es justo lo que hace peligroso acumular cambios.
596
650
  const cambia = has.has(cap) !== real.has(cap)
@@ -599,8 +653,7 @@ function capChecklist (i, t, { cn = null, has, real = has }) {
599
653
  // así que no hay que repetirlo al lado. Lo que hace va debajo.
600
654
  const line = ` ${mark}${cambia ? t.bold('*') : ' '} ${cap === 'admin' ? t.bold(name) : name}`
601
655
  rows.push({ text: line, sel: true, meta: { cap } })
602
- const hint = typeof i.capHint[cap] === 'function' ? i.capHint[cap](cn) : i.capHint[cap]
603
- rows.push({ text: t.muted(' ' + hint), sel: false })
656
+ rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
604
657
  }
605
658
  return { rows, tocados }
606
659
  }
@@ -1344,7 +1397,8 @@ async function onKeyDevices (term, st, key) {
1344
1397
  value: cur.label || '',
1345
1398
  onSubmit: async (raw) => {
1346
1399
  const name = String(raw || '').trim()
1347
- if (!name) return
1400
+ st.input = null
1401
+ if (!name) { flash(st, i.nameEmpty, 'danger'); return }
1348
1402
  const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, name, activeId(st)))
1349
1403
  if (r.ok) { applyDump(st, r.v); flash(st, i.deviceRenamed(name)) }
1350
1404
  }
@@ -1415,6 +1469,7 @@ async function onKeyLogins (term, st, key, api = vc) {
1415
1469
  mask: true,
1416
1470
  onSubmit: async (pw2) => {
1417
1471
  if (pw1 !== pw2) { flash(st, i.loginPassMismatch, 'warn'); return }
1472
+ st.input = null
1418
1473
  const r = await guard(term, st, i.loginCreating,
1419
1474
  () => api.addLogin({ user, password: pw1, label }))
1420
1475
  if (r.ok) {
@@ -1442,6 +1497,7 @@ async function onKeyLogins (term, st, key, api = vc) {
1442
1497
  mask: true,
1443
1498
  onSubmit: async (nueva) => {
1444
1499
  if (String(nueva || '').length < MIN_LOGIN_PASSWORD) { flash(st, i.loginPassShort, 'warn'); return }
1500
+ st.input = null
1445
1501
  const r = await guard(term, st, i.loginChanging,
1446
1502
  () => api.passwdLogin({ user: cur.user, oldPassword: vieja, newPassword: nueva }))
1447
1503
  if (r.ok) { flash(st, i.loginChanged); await refreshLogins(term, st, api) }
@@ -1593,7 +1649,8 @@ async function onKeyPairCaps (term, st, key) {
1593
1649
  return true
1594
1650
  }
1595
1651
  if (ch === 'g') {
1596
- if (!pc.caps.length) { flash(st, i.pairCapsNone, 'warn'); return true }
1652
+ // Un servicio puede entrar sin ningún permiso: su cajón lo abre el nombre.
1653
+ if (!pc.caps.length && !pc.service) { flash(st, i.pairCapsNone, 'warn'); return true }
1597
1654
  const go = async () => {
1598
1655
  st.pairCaps = null
1599
1656
  const ok = await beginPairing(term, st, pc.profile, pc.service, pc.label, pc.caps)
@@ -1614,7 +1671,7 @@ async function onKeyPairCaps (term, st, key) {
1614
1671
 
1615
1672
  /** Abre la pantalla de permisos del emparejamiento; el QR sale al pulsar G. */
1616
1673
  function choosePairCaps (st, { profile, service = null, label = null, after = null }) {
1617
- st.pairCaps = { profile, service, label, after, caps: service ? ['secrets'] : [...PAIR_DEFAULT_CAPS] }
1674
+ st.pairCaps = { profile, service, label, after, caps: service ? [] : [...PAIR_DEFAULT_CAPS] }
1618
1675
  st.sel.paircaps = 0
1619
1676
  st.scroll.paircaps = { value: 0 }
1620
1677
  st.screen = 'paircaps'
@@ -2505,6 +2562,8 @@ function render (term, st) {
2505
2562
  ? t.muted(i.updateWaiting(u.version, dia(u.askedAt + 24 * 60 * 60 * 1000)))
2506
2563
  : t.warn(i.updateNotApproved(u.version, dia(u.askedAt))))
2507
2564
  }
2565
+ // Hay versión nueva y se dijo que no (o todavía no se preguntó): se queda dicho aquí.
2566
+ if (!lines[2] && st.update?.release) lines[2] = ' ' + t.warn(i.updateAvailable(st.update.release.version))
2508
2567
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
2509
2568
  // el resto muestra su título simple.
2510
2569
  lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
@@ -2645,11 +2704,14 @@ export async function runTui () {
2645
2704
  st.state = vc.readState()
2646
2705
  st.daemonUp = true
2647
2706
  await refreshAll(term, st)
2707
+ checkForUpdate(st)
2648
2708
 
2649
2709
  let running = true
2650
2710
  while (running) {
2651
2711
  st.state = vc.readState()
2652
2712
  st.daemonUp = vc.daemonAlive()
2713
+ // El aviso de versión nueva sale cuando no hay otra cosa preguntando.
2714
+ if (st.update?.offer && !st.input && !st.confirm) offerUpdate(term, st)
2653
2715
  // ¿Se cerró sola mientras nadie miraba? Olvida su contraseña y sal de su contenido.
2654
2716
  await forgetAutoLocked(term, st)
2655
2717
  // caducar el flash a los ~4 s
@@ -2659,7 +2721,10 @@ export async function runTui () {
2659
2721
  const base = (st.screen === 'pairing' || st.screen === 'join' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
2660
2722
  // Despertar a tiempo del bloqueo automático, además del sondeo de la pantalla.
2661
2723
  const wake = autoLockWakeIn(st)
2662
- const tick = base && wake ? Math.min(base, wake) : (base || wake)
2724
+ let tick = base && wake ? Math.min(base, wake) : (base || wake)
2725
+ // Mientras se mira si hay versión nueva hay que despertar sin tecla, o el aviso no
2726
+ // saldría hasta que tocaras algo.
2727
+ if (st.update?.checking) tick = tick ? Math.min(tick, 500) : 500
2663
2728
  const key = await term.readKey(tick)
2664
2729
 
2665
2730
  if (key.name === 'resize') continue
@@ -2731,7 +2796,13 @@ export async function runTui () {
2731
2796
  for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
2732
2797
  term.close()
2733
2798
  }
2799
+ // TRAS ACTUALIZAR Y REINICIAR, esta pantalla también se vuelve a abrir: es el binario
2800
+ // anterior el que sigue en memoria. Se lanza el nuevo en esta misma terminal.
2801
+ if (st.relaunch) {
2802
+ const r = spawnSync(process.execPath, process.argv.slice(1), { stdio: 'inherit' })
2803
+ process.exit(r.status ?? 0)
2804
+ }
2734
2805
  }
2735
2806
 
2736
2807
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
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 } }
2808
+ export const __test = { checkForUpdate, offerUpdate, installUpdate, 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
@@ -28,6 +28,15 @@ const es = {
28
28
  daemonStopped: 'DETENIDO',
29
29
  updateWaiting: (version, until) => `⬆ la ${version} espera tu aprobación hasta ${until} UTC: mira Pedidos en tu teléfono`,
30
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`,
31
+ updateAvailable: (version) => `⬆ hay la versión ${version}: instálala con dotrino-vault update`,
32
+ updateOffer: (version, current) => `Hay la versión ${version} y esta es la ${current}. ¿Instalarla ahora?`,
33
+ updateInstalling: (version) => `Bajando la ${version} y comprobando la firma…`,
34
+ updateFailed: (reason) => `No se instaló nada: ${reason}`,
35
+ updateNeedsCommand: 'Esta instalación no se puede cambiar desde aquí. Sal y corre: dotrino-vault update',
36
+ updateNoteFailed: (reason) => `Instalada, pero no se pudo dejar el aviso para tus aprobadores: ${reason}`,
37
+ updateRestartOffer: (version) => `Instalada la ${version}. ¿Reiniciar ahora la bóveda y esta pantalla? Cierra las bóvedas abiertas`,
38
+ updateRestarting: 'Reiniciando la bóveda…',
39
+ updateRestartLater: (version) => `La ${version} queda instalada: la bóveda se reinicia sola en un par de minutos`,
31
40
  daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
32
41
  activeVault: 'Bóveda activa: ',
33
42
  noName: '(sin nombre)',
@@ -79,6 +88,7 @@ const es = {
79
88
  // PERMISOS de un dispositivo (§9.1: se dice el beneficio, no el scope del cert).
80
89
  titleCaps: 'Permisos del dispositivo',
81
90
  capsFor: (id, name) => `Permisos de ${id}${name ? ' · ' + name : ''}`,
91
+ capsDrawer: (cn) => `Es el servicio «${cn}»: abre el cajón «${cn}» y ningún otro.`,
82
92
  capsNoMember: ' (este dispositivo ya no está en el perfil)',
83
93
  capsApplyHint: 'Enter marca o desmarca · G guarda · Esc vuelve a la lista sin perder lo marcado',
84
94
  capsPending: (n) => `${n} cambio(s) sin guardar en este aparato (*) · G guarda todo en una sola acta`,
@@ -104,7 +114,6 @@ const es = {
104
114
  // resultado fue una lista donde no se sabía qué permiso era cuál ni cómo nombrarlo para
105
115
  // el CLI. Lo que hace cada uno va DEBAJO, que es su sitio.
106
116
  capName: {
107
- secrets: (cn) => `secretos «${cn}»`,
108
117
  sign: 'firma',
109
118
  store: 'guarda',
110
119
  read: 'lee',
@@ -120,7 +129,6 @@ const es = {
120
129
  // continuaba a «Conectar y quitar dispositivos»—; ahora el título es la palabra que se
121
130
  // teclea, así que la pista tiene que decir la frase entera.
122
131
  capHint: {
123
- secrets: (cn) => `abrir el cajón de «${cn}» y ningún otro`,
124
132
  sign: 'usar tu identidad en las apps del ecosistema',
125
133
  store: 'escribir en tu bóveda: perfil, contenido, datos',
126
134
  read: 'ver lo que guardaste',
@@ -517,6 +525,15 @@ const en = {
517
525
  daemonStopped: 'STOPPED',
518
526
  updateWaiting: (version, until) => `⬆ ${version} is waiting for your approval until ${until} UTC: check Requests on your phone`,
519
527
  updateNotApproved: (version, when) => `⬆ approval for ${version} was asked on ${when} UTC and not given: it will not ask again · dotrino-vault update`,
528
+ updateAvailable: (version) => `⬆ version ${version} is out: install it with dotrino-vault update`,
529
+ updateOffer: (version, current) => `Version ${version} is out and this is ${current}. Install it now?`,
530
+ updateInstalling: (version) => `Downloading ${version} and checking its signature…`,
531
+ updateFailed: (reason) => `Nothing was installed: ${reason}`,
532
+ updateNeedsCommand: 'This install cannot be changed from here. Quit and run: dotrino-vault update',
533
+ updateNoteFailed: (reason) => `Installed, but the note for your approvers could not be written: ${reason}`,
534
+ updateRestartOffer: (version) => `${version} is installed. Restart the vault and this screen now? It locks the open vaults`,
535
+ updateRestarting: 'Restarting the vault…',
536
+ updateRestartLater: (version) => `${version} is installed: the vault restarts by itself in a couple of minutes`,
520
537
  daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
521
538
  activeVault: 'Active vault: ',
522
539
  tooSmall: 'Terminal too small',
@@ -564,6 +581,7 @@ const en = {
564
581
  tabMe: 'Profile',
565
582
  titleCaps: 'Device permissions',
566
583
  capsFor: (id, name) => `Permissions for ${id}${name ? ' · ' + name : ''}`,
584
+ capsDrawer: (cn) => `It is the “${cn}” service: it opens the “${cn}” drawer and no other.`,
567
585
  capsNoMember: ' (this device is no longer in the record)',
568
586
  capsApplyHint: 'Enter ticks or unticks · G saves · Esc goes back to the list keeping your ticks',
569
587
  capsPending: (n) => `${n} unsaved change(s) on this device (*) · G saves everything in ONE record`,
@@ -583,7 +601,6 @@ const en = {
583
601
  // El título es el NOMBRE del permiso, el mismo que se teclea. Ver la nota en el bloque
584
602
  // español: describirlo dejaba una lista donde no se sabía qué permiso era cuál.
585
603
  capName: {
586
- secrets: (cn) => `secrets «${cn}»`,
587
604
  sign: 'sign',
588
605
  store: 'store',
589
606
  read: 'read',
@@ -597,7 +614,6 @@ const en = {
597
614
  },
598
615
  // Cada pista se sostiene sola: el título es la palabra, no una frase que continuar.
599
616
  capHint: {
600
- secrets: (cn) => `open the «${cn}» drawer and no other`,
601
617
  sign: 'use your identity across the ecosystem apps',
602
618
  store: 'write to your vault: profile, content, data',
603
619
  read: 'see what you saved',
package/src/update.js CHANGED
@@ -16,11 +16,22 @@ import { execFileSync } 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'
19
+ import { ipcWrite } from './ipc.js'
19
20
 
20
21
  export const REPO = 'imdotrino/dotrino-vault'
21
22
  export { isNewer, CHECK_EVERY_MS, download }
22
23
 
23
24
  /** La última release del vault. `{ ok:false, reason }` si no se pudo mirar, que NO es «al día». */
25
+ /**
26
+ * DEJA DICHO QUE SE INSTALÓ una versión nueva, para que el servicio se lo cuente a quien
27
+ * aprueba cuando arranque con ella (`anunciarActualizacion` en daemon.js). Es el mismo
28
+ * marcador que deja el servicio cuando se actualiza solo, y lo dejan igual el comando
29
+ * `update` y la TUI.
30
+ */
31
+ export function noteUpdated (dir, { from, to }) {
32
+ ipcWrite(path.join(dir, 'updated.json'), { v: 1, from, to, at: Date.now() })
33
+ }
34
+
24
35
  export const latestRelease = (opts = {}) =>
25
36
  latestVersion({ source: 'github', repo: REPO, product: 'dotrino-vault', version: VERSION, ...opts })
26
37