@dotrino/vaultd 0.6.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/app.js ADDED
@@ -0,0 +1,722 @@
1
+ /**
2
+ * app.js — TUI del vault (pantalla completa, sin dependencias).
3
+ *
4
+ * Le habla al daemon por `vaultControl.js` (archivos + señales); NO abre la
5
+ * identidad ni la red. Cubre lo que pidió el dueño:
6
+ *
7
+ * · Bóvedas (perfiles): crear · cambiar activa · renombrar · borrar · candado
8
+ * · Dispositivos (pares): ver · emparejar · aprobar/rechazar · revocar
9
+ * · Scopes y variables (secretos): ver · agregar · quitar
10
+ *
11
+ * Cada "bóveda" es un PERFIL (maestra propia, dir propio, dispositivos y secretos
12
+ * propios). Las acciones operan sobre la bóveda ACTIVA; para operar otra, cámbiala
13
+ * en la pantalla de bóvedas.
14
+ */
15
+ import { execFile } from 'node:child_process'
16
+ import { createTerm } from './term.js'
17
+ import { qrToString } from '../qr.js'
18
+ import * as vc from '../vaultControl.js'
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
21
+
22
+ // Regex de validación (mismas que el store de secretos, protocol.js).
23
+ const NS_RE = /^[a-z0-9-]{1,32}$/
24
+ const KEY_RE = /^[A-Z0-9_]{1,64}$/
25
+
26
+ // ------------------------------- utilidades --------------------------------
27
+
28
+ function humanErr (e) {
29
+ if (e?.code === 'DAEMON_DOWN') return 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).'
30
+ return e?.message || String(e)
31
+ }
32
+
33
+ function flash (st, text, kind = 'ok') { st.flash = { text, kind, at: Date.now() } }
34
+
35
+ function fmtExp (exp) {
36
+ if (!exp) return '—'
37
+ const d = new Date(exp)
38
+ return isNaN(d) ? String(exp) : d.toISOString().slice(0, 10)
39
+ }
40
+
41
+ const shortScope = (scope) => {
42
+ const arr = Array.isArray(scope) ? scope : (scope ? [scope] : [])
43
+ return arr.map((s) => String(s).replace(/^vault:/, '')).join(',') || '—'
44
+ }
45
+
46
+ function activeProfile (st) {
47
+ const list = st.profiles?.profiles || []
48
+ return list.find((p) => p.current) || list[0] || null
49
+ }
50
+ const activeId = (st) => activeProfile(st)?.id || undefined
51
+
52
+ function lockGlyph (p) {
53
+ if (!p?.protected) return ''
54
+ return p.locked ? '🔒' : '🔓'
55
+ }
56
+
57
+ function startDaemonService () {
58
+ return new Promise((res) => {
59
+ execFile('systemctl', ['--user', 'start', 'dotrino-vault'], { timeout: 8000 }, (err, so, se) => {
60
+ res({ ok: !err, err: err ? (String(se || '').trim() || err.message) : '' })
61
+ })
62
+ })
63
+ }
64
+
65
+ // -------------------- render: modelo de filas + listas ---------------------
66
+
67
+ /**
68
+ * Dibuja una lista con scroll. `rows`: [{ text, sel?, meta? }]. `selIdx` indexa el
69
+ * SUBCONJUNTO seleccionable. Devuelve exactamente `height` líneas.
70
+ */
71
+ function renderList (rows, selIdx, height, cols, t, scrollRef) {
72
+ const selectable = []
73
+ rows.forEach((r, i) => { if (r.sel) selectable.push(i) })
74
+ const curRow = selectable.length ? selectable[Math.max(0, Math.min(selIdx, selectable.length - 1))] : -1
75
+
76
+ let top = scrollRef.value || 0
77
+ if (curRow >= 0) {
78
+ if (curRow < top) top = curRow
79
+ else if (curRow >= top + height) top = curRow - height + 1
80
+ }
81
+ top = Math.max(0, Math.min(top, Math.max(0, rows.length - height)))
82
+ scrollRef.value = top
83
+
84
+ const out = []
85
+ for (let i = 0; i < height; i++) {
86
+ const r = rows[top + i]
87
+ if (!r) { out.push(''); continue }
88
+ if (top + i === curRow) out.push(t.sel(r.text, cols))
89
+ else out.push(r.text)
90
+ }
91
+ return out
92
+ }
93
+
94
+ // --------------------------------- pantallas -------------------------------
95
+
96
+ function profileRows (st, t) {
97
+ const list = st.profiles?.profiles || []
98
+ return list.map((p) => {
99
+ const mark = p.current ? t.accent('●') : ' '
100
+ const lk = !p.protected ? t.muted('sin clave') : (p.locked ? t.warn('🔒 bloqueada') : t.ok('🔓 abierta'))
101
+ const name = p.current ? t.bold(p.name || '(sin nombre)') : (p.name || '(sin nombre)')
102
+ return { text: ` ${mark} ${name} ${t.muted(p.id)} ${t.muted(p.fingerprint || '—')} ${lk}`, sel: true, meta: p }
103
+ })
104
+ }
105
+
106
+ function deviceRows (st, t) {
107
+ const rows = []
108
+ const pend = st.pending
109
+ if (pend) {
110
+ rows.push({ text: t.warn(` ⧗ PENDIENTE: ${pend.deviceId}`) + t.muted(' — pulsa A para aprobar, X para rechazar'), sel: false })
111
+ rows.push({ text: '', sel: false })
112
+ }
113
+ const issued = st.devices?.issued || []
114
+ if (!issued.length) {
115
+ rows.push({ text: t.muted(' (sin dispositivos enrolados — pulsa E para emparejar uno)'), sel: false })
116
+ }
117
+ for (const d of issued) {
118
+ const label = d.label || t.muted('(sin etiqueta)')
119
+ const line = ` ${t.bold(d.deviceId)} ${label} ${t.muted('scope:' + shortScope(d.scope))} ${t.muted('exp:' + fmtExp(d.exp))} ${t.muted('nonce:' + (d.nonce ?? '—'))}`
120
+ rows.push({ text: line, sel: true, meta: d })
121
+ }
122
+ const revoked = st.devices?.revoked || []
123
+ if (revoked.length) {
124
+ rows.push({ text: '', sel: false })
125
+ rows.push({ text: t.muted(` Revocados: ${revoked.length}`), sel: false })
126
+ }
127
+ return rows
128
+ }
129
+
130
+ function secretRows (st, t) {
131
+ const ns = st.secrets || {}
132
+ const names = Object.keys(ns).sort()
133
+ const rows = []
134
+ if (!names.length) {
135
+ rows.push({ text: t.muted(' (sin scopes — pulsa N para agregar la primera variable)'), sel: false })
136
+ return rows
137
+ }
138
+ for (const n of names) {
139
+ rows.push({ text: t.accent(` ▸ ${n}`) + t.muted(` (scope vault:secrets:${n})`), sel: true, meta: { ns: n, key: null } })
140
+ for (const k of ns[n].slice().sort()) {
141
+ rows.push({ text: ` ${k} ${t.muted('••••••')}`, sel: true, meta: { ns: n, key: k } })
142
+ }
143
+ }
144
+ return rows
145
+ }
146
+
147
+ // --------------------------------- entrada ---------------------------------
148
+
149
+ function setInput (st, opts) {
150
+ st.input = { value: '', mask: false, hint: '', ...opts }
151
+ }
152
+ function setConfirm (st, opts) { st.confirm = { ...opts } }
153
+
154
+ // --------------------------------- refresco --------------------------------
155
+
156
+ async function guard (term, st, msg, fn) {
157
+ st.busy = msg
158
+ render(term, st)
159
+ try { const v = await fn(); st.busy = null; return { ok: true, v } } catch (e) { st.busy = null; flash(st, humanErr(e), 'danger'); return { ok: false, e } }
160
+ }
161
+
162
+ async function refreshAll (term, st) {
163
+ const r = await guard(term, st, 'Cargando…', () => vc.snapshot(activeId(st)))
164
+ if (!r.ok) return
165
+ const { devices, secrets, profiles } = r.v
166
+ if (profiles) st.profiles = profiles
167
+ if (secrets) st.secrets = secrets.ns || {}
168
+ if (devices) {
169
+ const issued = (devices.issued || devices.active || devices.delegations || [])
170
+ st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await vc.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
171
+ }
172
+ }
173
+
174
+ async function refreshDevices (term, st) {
175
+ const r = await guard(term, st, 'Cargando dispositivos…', () => vc.listDevices(activeId(st)))
176
+ if (r.ok) st.devices = r.v
177
+ }
178
+ async function refreshSecrets (term, st) {
179
+ const r = await guard(term, st, 'Cargando secretos…', () => vc.listSecrets(activeId(st)))
180
+ if (r.ok) st.secrets = r.v
181
+ }
182
+ async function refreshProfiles (term, st) {
183
+ const r = await guard(term, st, 'Cargando bóvedas…', () => vc.listProfiles())
184
+ if (r.ok) st.profiles = r.v
185
+ }
186
+
187
+ /** Asegura la bóveda desbloqueada antes de EDITARLA (rename/rm/password). */
188
+ async function ensureUnlocked (term, st, p, thenFn) {
189
+ if (!p.protected || !p.locked) return thenFn()
190
+ setInput(st, {
191
+ label: `Contraseña de "${p.name || p.id}"`,
192
+ mask: true,
193
+ hint: 'necesaria para editar la bóveda',
194
+ onSubmit: async (pwd) => {
195
+ st.input = null
196
+ const r = await guard(term, st, 'Desbloqueando…', () => vc.unlockProfile(p.id, pwd))
197
+ if (!r.ok) return
198
+ await refreshProfiles(term, st)
199
+ const fresh = (st.profiles.profiles || []).find((x) => x.id === p.id) || p
200
+ await thenFn(fresh)
201
+ },
202
+ onCancel: () => { st.input = null }
203
+ })
204
+ }
205
+
206
+ // --------------------------------- teclas ----------------------------------
207
+
208
+ function moveSel (st, key, screen, count) {
209
+ if (count <= 0) { st.sel[screen] = 0; return }
210
+ // Clampa el índice guardado ANTES de aplicar el delta: si la lista encogió, la
211
+ // primera flecha debe moverse desde la posición visible, no desde un índice viejo.
212
+ st.sel[screen] = Math.max(0, Math.min(st.sel[screen], count - 1))
213
+ if (key.name === 'up') st.sel[screen] = Math.max(0, st.sel[screen] - 1)
214
+ else if (key.name === 'down') st.sel[screen] = Math.min(count - 1, st.sel[screen] + 1)
215
+ else if (key.name === 'pageup') st.sel[screen] = Math.max(0, st.sel[screen] - 5)
216
+ else if (key.name === 'pagedown') st.sel[screen] = Math.min(count - 1, st.sel[screen] + 5)
217
+ else if (key.name === 'home') st.sel[screen] = 0
218
+ else if (key.name === 'end') st.sel[screen] = count - 1
219
+ }
220
+
221
+ async function onKeyProfiles (term, st, key) {
222
+ const rows = profileRows(st, term.t)
223
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
224
+ moveSel(st, key, 'profiles', sels.length)
225
+ const cur = sels[Math.min(st.sel.profiles, sels.length - 1)]
226
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
227
+
228
+ if (key.name === 'enter' && cur) {
229
+ // Entrar a la bóveda: la activa (si no lo estaba ya) y pasa a sus pestañas
230
+ // (Dispositivos/Scopes) — así siempre es explícito de qué bóveda son los ítems.
231
+ if (!cur.current) {
232
+ const r = await guard(term, st, 'Cambiando de bóveda…', () => vc.useProfile(cur.id))
233
+ if (!r.ok) return true
234
+ flash(st, `Bóveda activa: ${cur.name || cur.id}`)
235
+ await refreshAll(term, st)
236
+ }
237
+ st.screen = 'devices'
238
+ await refreshDevices(term, st)
239
+ } else if (ch === 'n') {
240
+ setInput(st, {
241
+ label: 'Nombre de la nueva bóveda',
242
+ hint: 'crea una identidad nueva y vacía',
243
+ onSubmit: async (name) => {
244
+ st.input = null
245
+ if (!name.trim()) { flash(st, 'El nombre no puede estar vacío', 'danger'); return }
246
+ const r = await guard(term, st, 'Creando bóveda…', () => vc.addProfile(name.trim()))
247
+ if (r.ok) { flash(st, `Bóveda creada: ${name.trim()}`); await refreshProfiles(term, st) }
248
+ },
249
+ onCancel: () => { st.input = null }
250
+ })
251
+ } else if (ch === 'r' && cur) {
252
+ await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
253
+ label: `Nuevo nombre para "${p.name || p.id}"`,
254
+ value: p.name || '',
255
+ onSubmit: async (name) => {
256
+ st.input = null
257
+ if (!name.trim()) { flash(st, 'El nombre no puede estar vacío', 'danger'); return }
258
+ const r = await guard(term, st, 'Renombrando…', () => vc.renameProfile(p.id, name.trim()))
259
+ if (r.ok) { flash(st, 'Bóveda renombrada'); await refreshProfiles(term, st) }
260
+ },
261
+ onCancel: () => { st.input = null }
262
+ }))
263
+ } else if ((key.name === 'delete' || ch === 'd') && cur) {
264
+ if ((st.profiles.profiles || []).length <= 1) { flash(st, 'No se puede borrar la única bóveda', 'danger'); return true }
265
+ await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
266
+ label: `Escribe "${p.name || p.id}" para BORRARLA (irreversible)`,
267
+ hint: 'se pierde su clave; sus dispositivos dejan de funcionar',
268
+ onSubmit: async (typed) => {
269
+ st.input = null
270
+ if (typed.trim() !== (p.name || p.id)) { flash(st, 'Cancelado (el nombre no coincide)', 'warn'); return }
271
+ const r = await guard(term, st, 'Borrando bóveda…', () => vc.removeProfile(p.id))
272
+ if (r.ok) { flash(st, 'Bóveda borrada'); st.sel.profiles = 0; await refreshAll(term, st) }
273
+ },
274
+ onCancel: () => { st.input = null }
275
+ }))
276
+ } else if (ch === 'k' && cur) {
277
+ await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
278
+ label: `Contraseña nueva para "${p.name || p.id}" (mín. 4)`,
279
+ mask: true,
280
+ onSubmit: async (pwd) => {
281
+ st.input = null
282
+ if (pwd.length < 4) { flash(st, 'La contraseña debe tener al menos 4 caracteres', 'danger'); return }
283
+ setInput(st, {
284
+ label: 'Repite la contraseña',
285
+ mask: true,
286
+ onSubmit: async (again) => {
287
+ st.input = null
288
+ if (again !== pwd) { flash(st, 'Las contraseñas no coinciden', 'danger'); return }
289
+ const r = await guard(term, st, 'Guardando contraseña…', () => vc.setProfilePassword(p.id, pwd))
290
+ if (r.ok) { flash(st, 'Contraseña guardada'); await refreshProfiles(term, st) }
291
+ },
292
+ onCancel: () => { st.input = null }
293
+ })
294
+ },
295
+ onCancel: () => { st.input = null }
296
+ }))
297
+ } else if (ch === 'x' && cur) { // quitar contraseña
298
+ if (!cur.protected) { flash(st, 'Esta bóveda no tiene contraseña', 'warn'); return true }
299
+ await ensureUnlocked(term, st, cur, async (p = cur) => {
300
+ const r = await guard(term, st, 'Quitando contraseña…', () => vc.removeProfilePassword(p.id))
301
+ if (r.ok) { flash(st, 'Contraseña quitada'); await refreshProfiles(term, st) }
302
+ })
303
+ } else if (ch === 'u' && cur) {
304
+ if (!cur.protected) { flash(st, 'Esta bóveda no tiene contraseña', 'warn'); return true }
305
+ if (!cur.locked) { flash(st, 'Ya está desbloqueada', 'warn'); return true }
306
+ await ensureUnlocked(term, st, cur, async () => { flash(st, 'Bóveda desbloqueada'); await refreshProfiles(term, st) })
307
+ } else if (ch === 'l' && cur) {
308
+ if (!cur.protected) { flash(st, 'Esta bóveda no tiene contraseña', 'warn'); return true }
309
+ const r = await guard(term, st, 'Bloqueando…', () => vc.lockProfile(cur.id))
310
+ if (r.ok) { flash(st, 'Bóveda bloqueada'); await refreshProfiles(term, st) }
311
+ }
312
+ return true
313
+ }
314
+
315
+ async function onKeyDevices (term, st, key) {
316
+ // Sondea el dispositivo pendiente en cada tick (uno puede conectarse mientras
317
+ // estás en esta pantalla, no solo en la de emparejamiento).
318
+ if (key.name === 'tick') { st.pending = vc.pendingEnroll(); return true }
319
+
320
+ const rows = deviceRows(st, term.t)
321
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
322
+ moveSel(st, key, 'devices', sels.length)
323
+ const cur = sels[Math.min(st.sel.devices, sels.length - 1)]
324
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
325
+
326
+ if (ch === 'e') {
327
+ const r = await guard(term, st, 'Iniciando emparejamiento…', () => vc.startPairing({ profile: activeId(st) }))
328
+ if (r.ok) { st.pairing = r.v; st.pending = null; st.screen = 'pairing' }
329
+ } else if (ch === 'a') { // aprobar el pendiente
330
+ if (!st.pending) { flash(st, 'No hay ningún dispositivo pendiente', 'warn'); return true }
331
+ promptApprove(term, st)
332
+ } else if (ch === 'x') { // rechazar el pendiente
333
+ if (!st.pending) { flash(st, 'No hay ningún dispositivo pendiente para rechazar', 'warn'); return true }
334
+ const r = await guard(term, st, 'Rechazando…', () => vc.rejectPending(st.pending.deviceId, activeId(st)))
335
+ if (r.ok) { flash(st, 'Dispositivo rechazado'); st.pending = null }
336
+ } else if ((ch === 'v' || key.name === 'delete') && cur?.nonce != null) { // revocar el enrolado seleccionado
337
+ setConfirm(st, {
338
+ text: `¿Revocar ${cur.deviceId}? Se le ordena autoborrarse al reconectar.`,
339
+ onYes: async () => {
340
+ st.confirm = null
341
+ const r = await guard(term, st, 'Revocando…', () => vc.revokeDevice(cur.nonce, activeId(st)))
342
+ if (r.ok) { flash(st, `Revocado ${cur.deviceId}`); st.devices = r.v; st.sel.devices = 0 }
343
+ },
344
+ onNo: () => { st.confirm = null }
345
+ })
346
+ } else if (ch === 'r') {
347
+ await refreshDevices(term, st)
348
+ }
349
+ return true
350
+ }
351
+
352
+ function promptApprove (term, st) {
353
+ setInput(st, {
354
+ label: `Código que MUESTRA el dispositivo ${st.pending?.deviceId || ''}`,
355
+ hint: 'el vault no lo conoce: compáralo en la otra pantalla',
356
+ onSubmit: async (code) => {
357
+ st.input = null
358
+ if (!code.trim()) { flash(st, 'Falta el código', 'danger'); return }
359
+ const r = await guard(term, st, 'Aprobando…', () => vc.approvePending(code.trim(), activeId(st)))
360
+ if (r.ok) { flash(st, 'Dispositivo aprobado'); st.devices = r.v; st.pending = null; st.screen = 'devices' }
361
+ },
362
+ onCancel: () => { st.input = null }
363
+ })
364
+ }
365
+
366
+ async function onKeyPairing (term, st, key) {
367
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
368
+ if (key.name === 'tick') {
369
+ const pend = vc.pendingEnroll()
370
+ if (pend) st.pending = pend
371
+ return true
372
+ }
373
+ if (ch === 'a' && st.pending) { promptApprove(term, st); return true }
374
+ if (ch === 'x' && st.pending) {
375
+ const r = await guard(term, st, 'Rechazando…', () => vc.rejectPending(st.pending.deviceId, activeId(st)))
376
+ if (r.ok) { flash(st, 'Dispositivo rechazado'); st.pending = null }
377
+ return true
378
+ }
379
+ if (ch === 'e') { // reiniciar emparejamiento
380
+ const r = await guard(term, st, 'Reiniciando emparejamiento…', () => vc.startPairing({ profile: activeId(st) }))
381
+ if (r.ok) { st.pairing = r.v; st.pending = null }
382
+ return true
383
+ }
384
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.pairing = null; await refreshDevices(term, st) }
385
+ return true
386
+ }
387
+
388
+ async function onKeySecrets (term, st, key) {
389
+ const rows = secretRows(st, term.t)
390
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
391
+ moveSel(st, key, 'secrets', sels.length)
392
+ const cur = sels[Math.min(st.sel.secrets, sels.length - 1)]
393
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
394
+
395
+ if (ch === 'n') {
396
+ promptNewVariable(term, st)
397
+ } else if ((ch === 'x' || key.name === 'delete') && cur) {
398
+ if (cur.key) {
399
+ setConfirm(st, {
400
+ text: `¿Quitar la variable ${cur.ns}/${cur.key}?`,
401
+ onYes: async () => {
402
+ st.confirm = null
403
+ const r = await guard(term, st, 'Quitando variable…', () => vc.deleteSecret(cur.ns, cur.key, activeId(st)))
404
+ if (r.ok) { flash(st, 'Variable quitada'); st.secrets = r.v; st.sel.secrets = Math.max(0, st.sel.secrets - 1) }
405
+ },
406
+ onNo: () => { st.confirm = null }
407
+ })
408
+ } else {
409
+ const count = (st.secrets?.[cur.ns] || []).length
410
+ setConfirm(st, {
411
+ text: `¿Quitar el scope "${cur.ns}" ENTERO (${count} variable(s))?`,
412
+ onYes: async () => {
413
+ st.confirm = null
414
+ const r = await guard(term, st, 'Quitando scope…', () => vc.deleteScope(cur.ns, activeId(st)))
415
+ if (r.ok) { flash(st, `Scope "${cur.ns}" quitado`); st.secrets = r.v; st.sel.secrets = 0 }
416
+ },
417
+ onNo: () => { st.confirm = null }
418
+ })
419
+ }
420
+ } else if (ch === 'r') {
421
+ await refreshSecrets(term, st)
422
+ }
423
+ return true
424
+ }
425
+
426
+ function promptNewVariable (term, st) {
427
+ const existing = Object.keys(st.secrets || {})
428
+ setInput(st, {
429
+ label: 'Scope (namespace del servicio)',
430
+ hint: existing.length ? `[a-z0-9-] · existen: ${existing.join(', ')}` : '[a-z0-9-], p. ej. proxy',
431
+ onSubmit: (ns) => {
432
+ const nsv = ns.trim()
433
+ if (!NS_RE.test(nsv)) { flash(st, 'Scope inválido: usa [a-z0-9-]{1,32}', 'danger'); promptNewVariable(term, st); return }
434
+ st.input = null
435
+ setInput(st, {
436
+ label: `Variable en "${nsv}" (MAYUSCULAS_CON_GUION_BAJO)`,
437
+ hint: '[A-Z0-9_], p. ej. TURN_KEY_ID',
438
+ onSubmit: (key) => {
439
+ const kv = key.trim()
440
+ if (!KEY_RE.test(kv)) { flash(st, 'Clave inválida: usa [A-Z0-9_]{1,64}', 'danger'); return }
441
+ st.input = null
442
+ setInput(st, {
443
+ label: `Valor de ${nsv}/${kv}`,
444
+ mask: true,
445
+ hint: 'el valor nunca se muestra; se guarda en la bóveda',
446
+ onSubmit: async (value) => {
447
+ st.input = null
448
+ if (!value) { flash(st, 'El valor no puede estar vacío', 'danger'); return }
449
+ const r = await guard(term, st, 'Guardando variable…', () => vc.setSecret(nsv, kv, value, activeId(st)))
450
+ if (r.ok) { flash(st, `Guardado ${nsv}/${kv}`); st.secrets = r.v }
451
+ },
452
+ onCancel: () => { st.input = null }
453
+ })
454
+ },
455
+ onCancel: () => { st.input = null }
456
+ })
457
+ },
458
+ onCancel: () => { st.input = null }
459
+ })
460
+ }
461
+
462
+ // async + awaited desde el loop: así una operación contra el daemon (que puede
463
+ // tardar un round-trip) se SERIALIZA y no se solapa con la siguiente tecla —si no,
464
+ // dos ops corren a la vez y se pisan los archivos de respuesta compartidos.
465
+ async function onInputKey (st, key) {
466
+ const inp = st.input
467
+ if (key.name === 'escape' || key.name === 'ctrl-c') { const c = inp.onCancel; st.input = null; await c?.(); return }
468
+ if (key.name === 'enter') { const f = inp.onSubmit; const v = inp.value; await f?.(v); return }
469
+ if (key.name === 'backspace') { inp.value = inp.value.slice(0, -1); return }
470
+ if (key.name === 'ctrl-u') { inp.value = ''; return }
471
+ if (key.name === 'ctrl-w') { inp.value = inp.value.replace(/\s*\S+\s*$/, ''); return }
472
+ if (key.name === 'char') inp.value += key.ch
473
+ }
474
+
475
+ async function onConfirmKey (st, key) {
476
+ const cf = st.confirm
477
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
478
+ if (ch === 's' || ch === 'y') { const f = cf.onYes; st.confirm = null; await f?.() }
479
+ else if (ch === 'n' || key.name === 'escape' || key.name === 'enter' || key.name === 'ctrl-c') { const f = cf.onNo; st.confirm = null; await f?.() }
480
+ }
481
+
482
+ // --------------------------------- render ----------------------------------
483
+
484
+ // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
485
+ // bóvedas (profiles) es el nivel de arriba (se entra con Enter, no es una pestaña).
486
+ const INNER_TABS = ['devices', 'secrets']
487
+ const TAB_LABEL = { devices: 'Dispositivos', secrets: 'Scopes y variables' }
488
+
489
+ const HELP = {
490
+ profiles: '↑↓ · Enter entrar · n nueva · r renombrar · d borrar · k clave · x quitar-clave · u desbloq · l bloq · q salir',
491
+ devices: '←→ pestaña · ↑↓ · e emparejar · a aprobar · x rechazar · v revocar · r refrescar · Esc bóvedas · q salir',
492
+ secrets: '←→ pestaña · ↑↓ · n nueva variable · x quitar (variable/scope) · r refrescar · Esc bóvedas · q salir',
493
+ pairing: 'a aprobar · x rechazar · e reiniciar · Esc atrás'
494
+ }
495
+ const TITLE = {
496
+ profiles: 'Bóvedas',
497
+ pairing: 'Emparejar un dispositivo'
498
+ }
499
+
500
+ /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
501
+ function renderTabs (st, t) {
502
+ return INNER_TABS.map((k) => {
503
+ const active = st.screen === k
504
+ return active ? t.bold(t.accent('▐ ' + TAB_LABEL[k] + ' ▌')) : t.muted(' ' + TAB_LABEL[k] + ' ')
505
+ }).join(' ') + t.muted(' (←→ cambiar)')
506
+ }
507
+
508
+ function pairingBody (st, t, cols, height) {
509
+ const info = st.pairing
510
+ const lines = []
511
+ const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
512
+ lines.push(t.muted(`Válido ~${left} min. Escanéalo o abre la URL en el dispositivo.`))
513
+ lines.push('')
514
+ // QR solo si entra cómodo (es "alto": ~ (módulos+8)/2 filas).
515
+ let qr = ''
516
+ try { qr = qrToString(info.url) } catch (_) {}
517
+ const qrLines = qr ? qr.replace(/\n$/, '').split('\n') : []
518
+ const qrWidth = qrLines.length ? Math.max(...qrLines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').length)) : 0
519
+ const reserved = 8 // encabezado + URL + payload + aviso
520
+ if (qrLines.length && qrWidth <= cols && qrLines.length <= height - reserved) {
521
+ for (const l of qrLines) lines.push(l)
522
+ lines.push('')
523
+ }
524
+ lines.push(t.bold('URL: ') + info.url)
525
+ lines.push('')
526
+ lines.push(t.muted('O pega este código en la pestaña #vault de profile.dotrino.com:'))
527
+ lines.push(info.payload)
528
+ lines.push('')
529
+ lines.push(t.danger('⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.'))
530
+ lines.push('')
531
+ if (st.pending) lines.push(t.warn(`⧗ Se conectó: ${st.pending.deviceId} — pulsa A y escribe el código que muestra.`))
532
+ else lines.push(t.muted('Esperando a que el dispositivo se conecte…'))
533
+ return lines
534
+ }
535
+
536
+ function render (term, st) {
537
+ const t = term.t
538
+ const { cols, rows } = term.size()
539
+ // La distribución necesita: header+contexto (5) + 1 de contenido + estado + ayuda.
540
+ // En un terminal más chico, en vez de escribir en índices fuera de rango, avisamos.
541
+ if (rows < 9 || cols < 24) {
542
+ term.render([t.warn('Terminal muy pequeño'), `Agranda a ≥ 24×9 (hay ${cols}×${rows}).`])
543
+ return
544
+ }
545
+ const lines = new Array(rows).fill('')
546
+
547
+ const s = st.state
548
+ const up = st.daemonUp
549
+ const ver = s?.version || 'dev'
550
+ const daemonTxt = up ? 'corriendo' : 'DETENIDO'
551
+ lines[0] = t.bar(`dotrino-vault ${ver} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
552
+
553
+ const ap = activeProfile(st)
554
+ const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || '(sin nombre)')} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
555
+ lines[1] = ' Bóveda activa: ' + apTxt
556
+ lines[2] = ''
557
+ // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
558
+ // el resto muestra su título simple.
559
+ lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + (TITLE[st.screen] || ''))
560
+ lines[4] = ''
561
+
562
+ const top = 5
563
+ const bottom = 2 // status + help
564
+ const contentH = Math.max(1, rows - top - bottom)
565
+ const scrollRef = st.scroll[st.screen] || (st.scroll[st.screen] = { value: 0 })
566
+
567
+ let body = []
568
+ if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
569
+ else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
570
+ else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
571
+ else if (st.screen === 'pairing') {
572
+ const pb = pairingBody(st, t, cols, contentH)
573
+ body = pb.slice(0, contentH)
574
+ while (body.length < contentH) body.push('')
575
+ }
576
+ for (let i = 0; i < contentH; i++) lines[top + i] = body[i] ?? ''
577
+
578
+ // línea de estado: input / confirm / flash / busy
579
+ const statusRow = rows - 2
580
+ if (st.busy) lines[statusRow] = ' ' + t.accent('⏳ ' + st.busy)
581
+ else if (st.input) {
582
+ const inp = st.input
583
+ const shown = inp.mask ? '•'.repeat(inp.value.length) : inp.value
584
+ const hint = inp.hint ? t.muted(' [' + inp.hint + ']') : ''
585
+ lines[statusRow] = ' ' + t.bold(inp.label + ': ') + shown + t.accent('▏') + hint
586
+ } else if (st.confirm) {
587
+ lines[statusRow] = ' ' + t.warn(st.confirm.text) + t.muted(' (s / N)')
588
+ } else if (st.flash) {
589
+ const kind = st.flash.kind
590
+ const style = kind === 'danger' ? t.danger : kind === 'warn' ? t.warn : t.ok
591
+ lines[statusRow] = ' ' + style((kind === 'danger' ? '✗ ' : kind === 'warn' ? '! ' : '✓ ') + st.flash.text)
592
+ } else lines[statusRow] = ''
593
+
594
+ // barra de ayuda
595
+ let help = HELP[st.screen] || ''
596
+ if (st.input) help = 'Enter confirmar · Esc cancelar · Ctrl-U limpiar'
597
+ else if (st.confirm) help = 's confirmar · n/Esc cancelar'
598
+ lines[rows - 1] = t.bar(help, cols)
599
+
600
+ term.render(lines)
601
+ }
602
+
603
+ // --------------------------- pantalla daemon caído -------------------------
604
+
605
+ async function daemonDownScreen (term, st) {
606
+ while (true) {
607
+ const t = term.t
608
+ const { cols, rows } = term.size()
609
+ const lines = new Array(Math.max(rows, 2)).fill('')
610
+ // Contenido en orden; se coloca desde la fila 2 y se corta si no cabe (no se
611
+ // escribe nunca en índices fijos que se salgan de un terminal pequeño).
612
+ const content = [
613
+ t.danger('El daemon del vault no está corriendo.'),
614
+ '',
615
+ 'La TUI le da órdenes al daemon (custodio de tu clave). Sin él no puede',
616
+ 'crear bóvedas, listar dispositivos ni tocar secretos.',
617
+ '',
618
+ t.bold('S') + ' intentar arrancarlo: ' + t.muted('systemctl --user start dotrino-vault'),
619
+ t.bold('R') + ' volver a comprobar',
620
+ t.bold('Q') + ' salir',
621
+ '',
622
+ t.muted('En desarrollo, arráncalo a mano: node bin/dotrino-vaultd.js')
623
+ ]
624
+ if (st.flash) content.push('', (st.flash.kind === 'danger' ? t.danger : t.warn)(st.flash.text))
625
+ lines[0] = t.bar('dotrino-vault daemon: DETENIDO', cols)
626
+ for (let i = 0; i < content.length && 2 + i < rows - 1; i++) lines[2 + i] = ' ' + content[i]
627
+ lines[rows - 1] = t.bar('S arrancar · R comprobar · Q salir', cols)
628
+ term.render(lines)
629
+
630
+ const key = await term.readKey()
631
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
632
+ if (ch === 'q' || key.name === 'ctrl-c') return false
633
+ if (ch === 'r') { if (vc.daemonAlive()) return true; flash(st, 'Sigue sin responder', 'warn') }
634
+ if (ch === 's') {
635
+ st.busy = 'Arrancando el servicio…'; // (no re-render aquí; mensaje simple)
636
+ flash(st, 'Arrancando…', 'warn'); term.render(lines)
637
+ const r = await startDaemonService()
638
+ await sleep(1500)
639
+ if (vc.daemonAlive()) return true
640
+ flash(st, r.ok ? 'Arrancó pero aún no responde; pulsa R' : ('No se pudo arrancar: ' + r.err), 'danger')
641
+ st.busy = null
642
+ }
643
+ }
644
+ }
645
+
646
+ // ---------------------------------- loop -----------------------------------
647
+
648
+ export async function runTui () {
649
+ const term = createTerm()
650
+ const st = {
651
+ screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
652
+ sel: { profiles: 0, devices: 0, secrets: 0 },
653
+ scroll: {},
654
+ profiles: null,
655
+ devices: null,
656
+ secrets: null,
657
+ pending: null,
658
+ pairing: null,
659
+ state: null,
660
+ daemonUp: false,
661
+ busy: null,
662
+ flash: null,
663
+ input: null,
664
+ confirm: null
665
+ }
666
+
667
+ try {
668
+ // Arranque: exige daemon vivo.
669
+ if (!vc.daemonAlive()) {
670
+ const cont = await daemonDownScreen(term, st)
671
+ if (!cont) { term.close(); return }
672
+ st.flash = null
673
+ }
674
+ st.state = vc.readState()
675
+ st.daemonUp = true
676
+ await refreshAll(term, st)
677
+
678
+ let running = true
679
+ while (running) {
680
+ st.state = vc.readState()
681
+ st.daemonUp = vc.daemonAlive()
682
+ // caducar el flash a los ~4 s
683
+ if (st.flash && Date.now() - st.flash.at > 4000) st.flash = null
684
+ render(term, st)
685
+
686
+ const tick = (st.screen === 'pairing' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
687
+ const key = await term.readKey(tick)
688
+
689
+ if (key.name === 'resize') continue
690
+ // input/confirm se AWAITan: serializa las ops contra el daemon (ver onInputKey).
691
+ // Ctrl-C dentro de un modal lo CANCELA (no sale); fuera de un modal, sale.
692
+ if (st.input) { await onInputKey(st, key); continue }
693
+ if (st.confirm) { await onConfirmKey(st, key); continue }
694
+ if (key.name === 'ctrl-c') { running = false; continue }
695
+
696
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
697
+ // 'q' global sale.
698
+ if (ch === 'q') { running = false; continue }
699
+ // ←→ cambia entre las pestañas de la bóveda entrada (Dispositivos/Scopes).
700
+ if ((key.name === 'left' || key.name === 'right') && INNER_TABS.includes(st.screen)) {
701
+ const i = INNER_TABS.indexOf(st.screen)
702
+ st.screen = INNER_TABS[(i + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
703
+ continue
704
+ }
705
+ // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
706
+ // entrada). La pantalla de emparejamiento maneja su propio Esc (va a Dispositivos).
707
+ if ((key.name === 'escape' || ch === 'b') && INNER_TABS.includes(st.screen)) {
708
+ st.screen = 'profiles'; continue
709
+ }
710
+
711
+ if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
712
+ else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
713
+ else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
714
+ else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
715
+ }
716
+ } finally {
717
+ term.close()
718
+ }
719
+ }
720
+
721
+ // Solo para pruebas headless (render sin terminal real). No usar en runtime.
722
+ export const __test = { render, profileRows, deviceRows, secretRows, pairingBody }