@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/README.md +301 -0
- package/bin/dotrino-vault-tui.js +28 -0
- package/bin/dotrino-vault.js +23 -0
- package/bin/dotrino-vaultd.js +29 -0
- package/bin/sea-entry.js +29 -0
- package/lib/README.md +139 -0
- package/lib/src/config.js +26 -0
- package/lib/src/enroll.js +293 -0
- package/lib/src/env.js +95 -0
- package/lib/src/index.js +166 -0
- package/lib/src/protocol.js +53 -0
- package/lib/src/sealed.js +84 -0
- package/lib/src/service.js +258 -0
- package/package.json +41 -0
- package/src/atrest.js +0 -0
- package/src/client.js +149 -0
- package/src/ctl.js +597 -0
- package/src/daemon.js +217 -0
- package/src/manager.js +88 -0
- package/src/node-globals.js +37 -0
- package/src/paths.js +47 -0
- package/src/profiles.js +214 -0
- package/src/protocol.js +6 -0
- package/src/qr.js +61 -0
- package/src/secretsStore.js +61 -0
- package/src/store.js +64 -0
- package/src/threadStore.js +111 -0
- package/src/transport.js +64 -0
- package/src/tui/app.js +722 -0
- package/src/tui/term.js +278 -0
- package/src/vault.js +303 -0
- package/src/vaultControl.js +296 -0
- package/vendor/qrcode-generator.cjs +2297 -0
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 }
|