@dotrino/vaultd 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,31 @@ los dispositivos/variables que estás viendo:
114
114
  sale desde cualquier pantalla. Las teclas de cada acción se listan en la barra
115
115
  inferior.
116
116
 
117
+ **Idioma (`l`).** La TUI está en **español e inglés** y la tecla `l` conmuta entre
118
+ los dos en cualquier pantalla (incluida la de "el daemon no está corriendo"). El
119
+ idioma elegido se recuerda en `prefs.json` del dir de datos. Sin elección previa
120
+ se toma del entorno (`DOTRINO_LANG`, o el locale `LC_ALL`/`LANG`), con el español
121
+ por defecto.
122
+
123
+ **Las teclas NO cambian con el idioma**: son mnemónicos en **inglés** y valen igual
124
+ en español (solo se traduce la palabra que las explica en la barra de ayuda).
125
+
126
+ | Tecla | Acción | Dónde |
127
+ |---|---|---|
128
+ | `Enter` | open — entrar a la bóveda | Bóvedas |
129
+ | `n` | new — bóveda nueva / variable nueva | Bóvedas · Scopes |
130
+ | `r` | rename (Bóvedas) · refresh (Dispositivos/Scopes) · restart (Emparejar) | — |
131
+ | `d` | delete — borrar la bóveda | Bóvedas |
132
+ | `p` | password (Bóvedas) · pair — emparejar (Dispositivos) | — |
133
+ | `x` | quitar: contraseña · dispositivo pendiente · variable/scope | todas |
134
+ | `u` / `k` | unlock / locK — candado de la bóveda | Bóvedas |
135
+ | `a` | approve — aprobar el dispositivo | Dispositivos · Emparejar |
136
+ | `v` | reVoke — revocar un dispositivo enrolado | Dispositivos |
137
+ | `y` | yes — confirmar (también se acepta `s`) | confirmaciones |
138
+ | `b` / `Esc` | back — volver | pestañas · Emparejar |
139
+ | `l` | language — español ⇄ English | todas |
140
+ | `q` | quit — salir | todas |
141
+
117
142
  ### Varios perfiles en el mismo PC
118
143
 
119
144
  Puedes tener varias identidades tuyas en la misma máquina (p. ej. personal y
@@ -14,6 +14,22 @@
14
14
  */
15
15
  import { runDaemon } from '../src/daemon.js'
16
16
  import { qrToString } from '../src/qr.js'
17
+ import { daemonAlive } from '../src/vaultControl.js'
18
+
19
+ // `--tui` con una bóveda YA CORRIENDO en otra ventana: se engancha a ella y abre solo la
20
+ // interfaz. Antes intentaba levantar una segunda bóveda sobre los mismos datos, chocaba
21
+ // con el candado y se cerraba la ventana de golpe — que desde fuera se ve como un crash.
22
+ // Es el caso normal, no el raro: la gente deja el daemon en una consola y abre la TUI en
23
+ // otra.
24
+ if (process.argv.includes('--tui') && daemonAlive()) {
25
+ if (!process.stdout.isTTY) {
26
+ console.error('--tui necesita un terminal interactivo (TTY).')
27
+ process.exit(2)
28
+ }
29
+ const { runTui } = await import('../src/tui/app.js')
30
+ await runTui()
31
+ process.exit(0)
32
+ }
17
33
 
18
34
  const mgr = await runDaemon()
19
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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": {
package/src/ctl.js CHANGED
@@ -136,7 +136,7 @@ function showChallenge (pe) {
136
136
  console.log(' dispositivo : %s%s%s', B, pe.deviceId, Z)
137
137
  console.log('\n Ingresa el código que MUESTRA el dispositivo (el vault no lo conoce):')
138
138
  console.log(' %sdotrino-vault approve <código>%s', B, Z)
139
- console.log(' Si no reconocés este dispositivo: dotrino-vault reject %s\n', pe.deviceId)
139
+ console.log(' Si no reconoces este dispositivo: dotrino-vault reject %s\n', pe.deviceId)
140
140
  }
141
141
 
142
142
  async function cmdPair (args = []) {
@@ -144,7 +144,7 @@ async function cmdPair (args = []) {
144
144
  try { fs.rmSync(pairFile, { force: true }) } catch (_) {}
145
145
  try { fs.rmSync(pendingFile, { force: true }) } catch (_) {}
146
146
  // --service <ns>: emparejar un SERVICIO (proxy, geo…) con cert limitado a
147
- // vault:secrets:<ns> (no puede firmar como vos ni leer tus datos).
147
+ // vault:secrets:<ns> (no puede firmar como ni leer tus datos).
148
148
  const svcIdx = args.indexOf('--service')
149
149
  let service = null
150
150
  if (svcIdx >= 0) {
@@ -166,11 +166,16 @@ async function cmdPair (args = []) {
166
166
  const b64 = Buffer.from(payload, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
167
167
  const url = PROFILE_URL + b64
168
168
  const mins = Math.round((pair.expiresAt - Date.now()) / 60000)
169
- console.log('\nEscaneá este QR con el dispositivo que querés conectar (válido %d min):\n', mins)
169
+ // QUÉ CUENTA se comparte: el vault puede tener varias bóvedas y este QR sale de
170
+ // UNA (la activa, o la de --profile). Decirlo evita enrolar el dispositivo en la
171
+ // equivocada; es la misma línea que muestra la TUI.
172
+ const acct = pair.profileName || pair.profile
173
+ if (acct) console.log('\nCuenta que se comparte: %s%s', acct, pair.profileName && pair.profile ? ` (${pair.profile})` : '')
174
+ console.log('\nEscanea este QR con el dispositivo que quieres conectar (válido %d min):\n', mins)
170
175
  console.log(qrToString(url)) // el QR abre vault.dotrino.com/dispositivos y empareja solo
171
176
  console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
172
- console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escaneálo en TU dispositivo.${Z}`)
173
- console.log('\nO abrí esta dirección en el dispositivo:\n ' + url)
177
+ console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
178
+ console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
174
179
  console.log('\nO pega este código en vault.dotrino.com/dispositivos :\n ' + payload)
175
180
 
176
181
  // --save [archivo]: escribe la invitación (.dpair) para transferirla y abrirla en profile.
package/src/daemon.js CHANGED
@@ -118,7 +118,11 @@ export async function runDaemon () {
118
118
  const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
119
119
  const label = pairReq?.label || (isService ? 'servicio:' + pairReq.service : 'cli')
120
120
  const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS })
121
- writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId })
121
+ // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
122
+ // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
123
+ // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`).
124
+ const profileName = mgr.profiles.get(profileId)?.name || ''
125
+ writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
122
126
  // El token es un secreto efímero: no debe quedar en disco más allá de su
123
127
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
124
128
  const tok = qr.token
package/src/tui/app.js CHANGED
@@ -11,10 +11,20 @@
11
11
  * Cada "bóveda" es un PERFIL (maestra propia, dir propio, dispositivos y secretos
12
12
  * propios). Las acciones operan sobre la bóveda ACTIVA; para operar otra, cámbiala
13
13
  * en la pantalla de bóvedas.
14
+ *
15
+ * BILINGÜE (CONVENCIONES §9): todo el texto sale de `i18n.js` según `st.lang`; la
16
+ * tecla `l` conmuta es/en en cualquier pantalla y recuerda la elección.
17
+ *
18
+ * LAS TECLAS NO CAMBIAN CON EL IDIOMA: son mnemónicos en INGLÉS y son las mismas
19
+ * en español (lo que se traduce es la palabra que las explica en la barra de
20
+ * ayuda). new · rename · delete · password · unlock · locK · pair · approve ·
21
+ * reject · reVoke · refresh · back · language · quit. Por eso el candado dejó de
22
+ * ser `l` (hoy idioma) y es `k`, la contraseña es `p` y emparejar es `p`.
14
23
  */
15
24
  import { execFile } from 'node:child_process'
16
- import { createTerm } from './term.js'
25
+ import { createTerm, widthOf } from './term.js'
17
26
  import { qrToString } from '../qr.js'
27
+ import { dict, otherLang, loadLang, saveLang } from './i18n.js'
18
28
  import * as vc from '../vaultControl.js'
19
29
 
20
30
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
@@ -25,9 +35,24 @@ const KEY_RE = /^[A-Z0-9_]{1,64}$/
25
35
 
26
36
  // ------------------------------- utilidades --------------------------------
27
37
 
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)
38
+ /** Diccionario del idioma activo (español si el estado aún no lo trae). */
39
+ const L = (st) => dict(st?.lang)
40
+
41
+ /**
42
+ * Errores en el idioma de la TUI. Los que nacen aquí o en `vaultControl` tienen
43
+ * `code` y se traducen; los que REENVÍA el daemon llegan como texto y se muestran
44
+ * tal cual (son diagnósticos del servicio, no copy de la interfaz).
45
+ */
46
+ function humanErr (e, st) {
47
+ const t = L(st)
48
+ const byCode = {
49
+ DAEMON_DOWN: t.errDaemonDown,
50
+ NO_REPLY: t.errNoReply,
51
+ NOT_APPLIED: t.errNotApplied,
52
+ NOT_DELETED: t.errNotDeleted,
53
+ PAIR_FAILED: t.errPairFailed
54
+ }
55
+ return byCode[e?.code] || e?.message || String(e)
31
56
  }
32
57
 
33
58
  function flash (st, text, kind = 'ok') { st.flash = { text, kind, at: Date.now() } }
@@ -91,52 +116,75 @@ function renderList (rows, selIdx, height, cols, t, scrollRef) {
91
116
  return out
92
117
  }
93
118
 
119
+ /**
120
+ * Barra de ayuda que SIEMPRE deja ver lo global (idioma y salir): si los segmentos
121
+ * no caben, recorta desde el MEDIO y marca el corte con «…». Sin esto, en 80
122
+ * columnas la ayuda se cortaba por la derecha y las teclas del final (justo las
123
+ * globales) no existían para quien no las supiera de memoria.
124
+ */
125
+ function fitHelp (segs, cols) {
126
+ const join = (a) => a.join(' · ')
127
+ if (widthOf(join(segs)) + 1 <= cols) return join(segs)
128
+ const head = segs.slice(0, 1)
129
+ const tail = segs.slice(-2)
130
+ const mid = segs.slice(1, -2)
131
+ while (mid.length) {
132
+ mid.pop()
133
+ const cand = join([...head, ...mid, '…', ...tail])
134
+ if (widthOf(cand) + 1 <= cols) return cand
135
+ }
136
+ return join([...head, '…', ...tail])
137
+ }
138
+
94
139
  // --------------------------------- pantallas -------------------------------
95
140
 
96
141
  function profileRows (st, t) {
142
+ const i = L(st)
97
143
  const list = st.profiles?.profiles || []
98
144
  return list.map((p) => {
99
145
  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)')
146
+ const lk = !p.protected ? t.muted(i.noPassword) : (p.locked ? t.warn(i.locked) : t.ok(i.unlocked))
147
+ const name = p.current ? t.bold(p.name || i.noName) : (p.name || i.noName)
102
148
  return { text: ` ${mark} ${name} ${t.muted(p.id)} ${t.muted(p.fingerprint || '—')} ${lk}`, sel: true, meta: p }
103
149
  })
104
150
  }
105
151
 
106
152
  function deviceRows (st, t) {
153
+ const i = L(st)
107
154
  const rows = []
108
155
  const pend = st.pending
109
156
  if (pend) {
110
- rows.push({ text: t.warn(` ⧗ PENDIENTE: ${pend.deviceId}`) + t.muted(' — pulsa A para aprobar, X para rechazar'), sel: false })
157
+ rows.push({ text: t.warn(i.pendingDevice(pend.deviceId)) + t.muted(i.pendingHint), sel: false })
111
158
  rows.push({ text: '', sel: false })
112
159
  }
113
160
  const issued = st.devices?.issued || []
114
161
  if (!issued.length) {
115
- rows.push({ text: t.muted(' (sin dispositivos enrolados — pulsa E para emparejar uno)'), sel: false })
162
+ rows.push({ text: t.muted(i.noDevices), sel: false })
116
163
  }
117
164
  for (const d of issued) {
118
- const label = d.label || t.muted('(sin etiqueta)')
165
+ const label = d.label || t.muted(i.noLabel)
119
166
  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
167
  rows.push({ text: line, sel: true, meta: d })
121
168
  }
122
169
  const revoked = st.devices?.revoked || []
123
170
  if (revoked.length) {
124
171
  rows.push({ text: '', sel: false })
125
- rows.push({ text: t.muted(` Revocados: ${revoked.length}`), sel: false })
172
+ rows.push({ text: t.muted(i.revokedCount(revoked.length)), sel: false })
126
173
  }
127
174
  return rows
128
175
  }
129
176
 
130
177
  function secretRows (st, t) {
178
+ const i = L(st)
131
179
  const ns = st.secrets || {}
132
180
  const names = Object.keys(ns).sort()
133
181
  const rows = []
134
182
  if (!names.length) {
135
- rows.push({ text: t.muted(' (sin scopes — pulsa N para agregar la primera variable)'), sel: false })
183
+ rows.push({ text: t.muted(i.noScopes), sel: false })
136
184
  return rows
137
185
  }
138
186
  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 } })
187
+ rows.push({ text: t.accent(` ▸ ${n}`) + t.muted(i.scopeOf(n)), sel: true, meta: { ns: n, key: null } })
140
188
  for (const k of ns[n].slice().sort()) {
141
189
  rows.push({ text: ` ${k} ${t.muted('••••••')}`, sel: true, meta: { ns: n, key: k } })
142
190
  }
@@ -156,11 +204,11 @@ function setConfirm (st, opts) { st.confirm = { ...opts } }
156
204
  async function guard (term, st, msg, fn) {
157
205
  st.busy = msg
158
206
  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 } }
207
+ try { const v = await fn(); st.busy = null; return { ok: true, v } } catch (e) { st.busy = null; flash(st, humanErr(e, st), 'danger'); return { ok: false, e } }
160
208
  }
161
209
 
162
210
  async function refreshAll (term, st) {
163
- const r = await guard(term, st, 'Cargando…', () => vc.snapshot(activeId(st)))
211
+ const r = await guard(term, st, L(st).loading, () => vc.snapshot(activeId(st)))
164
212
  if (!r.ok) return
165
213
  const { devices, secrets, profiles } = r.v
166
214
  if (profiles) st.profiles = profiles
@@ -172,28 +220,29 @@ async function refreshAll (term, st) {
172
220
  }
173
221
 
174
222
  async function refreshDevices (term, st) {
175
- const r = await guard(term, st, 'Cargando dispositivos…', () => vc.listDevices(activeId(st)))
223
+ const r = await guard(term, st, L(st).loadingDevices, () => vc.listDevices(activeId(st)))
176
224
  if (r.ok) st.devices = r.v
177
225
  }
178
226
  async function refreshSecrets (term, st) {
179
- const r = await guard(term, st, 'Cargando secretos…', () => vc.listSecrets(activeId(st)))
227
+ const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
180
228
  if (r.ok) st.secrets = r.v
181
229
  }
182
230
  async function refreshProfiles (term, st) {
183
- const r = await guard(term, st, 'Cargando bóvedas…', () => vc.listProfiles())
231
+ const r = await guard(term, st, L(st).loadingVaults, () => vc.listProfiles())
184
232
  if (r.ok) st.profiles = r.v
185
233
  }
186
234
 
187
235
  /** Asegura la bóveda desbloqueada antes de EDITARLA (rename/rm/password). */
188
236
  async function ensureUnlocked (term, st, p, thenFn) {
189
237
  if (!p.protected || !p.locked) return thenFn()
238
+ const i = L(st)
190
239
  setInput(st, {
191
- label: `Contraseña de "${p.name || p.id}"`,
240
+ label: i.passwordOf(p.name || p.id),
192
241
  mask: true,
193
- hint: 'necesaria para editar la bóveda',
242
+ hint: i.passwordToEdit,
194
243
  onSubmit: async (pwd) => {
195
244
  st.input = null
196
- const r = await guard(term, st, 'Desbloqueando…', () => vc.unlockProfile(p.id, pwd))
245
+ const r = await guard(term, st, i.unlocking, () => vc.unlockProfile(p.id, pwd))
197
246
  if (!r.ok) return
198
247
  await refreshProfiles(term, st)
199
248
  const fresh = (st.profiles.profiles || []).find((x) => x.id === p.id) || p
@@ -218,7 +267,15 @@ function moveSel (st, key, screen, count) {
218
267
  else if (key.name === 'end') st.sel[screen] = count - 1
219
268
  }
220
269
 
270
+ /** Conmuta es⇄en, lo recuerda y lo dice en el idioma NUEVO. */
271
+ function toggleLang (st) {
272
+ st.lang = otherLang(st.lang)
273
+ saveLang(st.lang)
274
+ flash(st, L(st).langChanged)
275
+ }
276
+
221
277
  async function onKeyProfiles (term, st, key) {
278
+ const i = L(st)
222
279
  const rows = profileRows(st, term.t)
223
280
  const sels = rows.filter((r) => r.sel).map((r) => r.meta)
224
281
  moveSel(st, key, 'profiles', sels.length)
@@ -229,65 +286,65 @@ async function onKeyProfiles (term, st, key) {
229
286
  // Entrar a la bóveda: la activa (si no lo estaba ya) y pasa a sus pestañas
230
287
  // (Dispositivos/Scopes) — así siempre es explícito de qué bóveda son los ítems.
231
288
  if (!cur.current) {
232
- const r = await guard(term, st, 'Cambiando de bóveda…', () => vc.useProfile(cur.id))
289
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(cur.id))
233
290
  if (!r.ok) return true
234
- flash(st, `Bóveda activa: ${cur.name || cur.id}`)
291
+ flash(st, i.vaultNowActive(cur.name || cur.id))
235
292
  await refreshAll(term, st)
236
293
  }
237
294
  st.screen = 'devices'
238
295
  await refreshDevices(term, st)
239
296
  } else if (ch === 'n') {
240
297
  setInput(st, {
241
- label: 'Nombre de la nueva bóveda',
242
- hint: 'crea una identidad nueva y vacía',
298
+ label: i.newVaultLabel,
299
+ hint: i.newVaultHint,
243
300
  onSubmit: async (name) => {
244
301
  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) }
302
+ if (!name.trim()) { flash(st, i.nameEmpty, 'danger'); return }
303
+ const r = await guard(term, st, i.creatingVault, () => vc.addProfile(name.trim()))
304
+ if (r.ok) { flash(st, i.vaultCreated(name.trim())); await refreshProfiles(term, st) }
248
305
  },
249
306
  onCancel: () => { st.input = null }
250
307
  })
251
308
  } else if (ch === 'r' && cur) {
252
309
  await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
253
- label: `Nuevo nombre para "${p.name || p.id}"`,
310
+ label: i.renameLabel(p.name || p.id),
254
311
  value: p.name || '',
255
312
  onSubmit: async (name) => {
256
313
  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) }
314
+ if (!name.trim()) { flash(st, i.nameEmpty, 'danger'); return }
315
+ const r = await guard(term, st, i.renaming, () => vc.renameProfile(p.id, name.trim()))
316
+ if (r.ok) { flash(st, i.vaultRenamed); await refreshProfiles(term, st) }
260
317
  },
261
318
  onCancel: () => { st.input = null }
262
319
  }))
263
320
  } 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 }
321
+ if ((st.profiles.profiles || []).length <= 1) { flash(st, i.cantDeleteLast, 'danger'); return true }
265
322
  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',
323
+ label: i.deleteLabel(p.name || p.id),
324
+ hint: i.deleteHint,
268
325
  onSubmit: async (typed) => {
269
326
  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) }
327
+ if (typed.trim() !== (p.name || p.id)) { flash(st, i.deleteMismatch, 'warn'); return }
328
+ const r = await guard(term, st, i.deletingVault, () => vc.removeProfile(p.id))
329
+ if (r.ok) { flash(st, i.vaultDeleted); st.sel.profiles = 0; await refreshAll(term, st) }
273
330
  },
274
331
  onCancel: () => { st.input = null }
275
332
  }))
276
- } else if (ch === 'k' && cur) {
333
+ } else if (ch === 'p' && cur) { // password
277
334
  await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
278
- label: `Contraseña nueva para "${p.name || p.id}" (mín. 4)`,
335
+ label: i.newPasswordLabel(p.name || p.id),
279
336
  mask: true,
280
337
  onSubmit: async (pwd) => {
281
338
  st.input = null
282
- if (pwd.length < 4) { flash(st, 'La contraseña debe tener al menos 4 caracteres', 'danger'); return }
339
+ if (pwd.length < 4) { flash(st, i.passwordTooShort, 'danger'); return }
283
340
  setInput(st, {
284
- label: 'Repite la contraseña',
341
+ label: i.repeatPassword,
285
342
  mask: true,
286
343
  onSubmit: async (again) => {
287
344
  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) }
345
+ if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
346
+ const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd))
347
+ if (r.ok) { flash(st, i.passwordSaved); await refreshProfiles(term, st) }
291
348
  },
292
349
  onCancel: () => { st.input = null }
293
350
  })
@@ -295,24 +352,25 @@ async function onKeyProfiles (term, st, key) {
295
352
  onCancel: () => { st.input = null }
296
353
  }))
297
354
  } else if (ch === 'x' && cur) { // quitar contraseña
298
- if (!cur.protected) { flash(st, 'Esta bóveda no tiene contraseña', 'warn'); return true }
355
+ if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
299
356
  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) }
357
+ const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id))
358
+ if (r.ok) { flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
302
359
  })
303
360
  } 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) }
361
+ if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
362
+ if (!cur.locked) { flash(st, i.alreadyUnlocked, 'warn'); return true }
363
+ await ensureUnlocked(term, st, cur, async () => { flash(st, i.vaultUnlocked); await refreshProfiles(term, st) })
364
+ } else if (ch === 'k' && cur) { // locK (antes `l`, que ahora es el idioma)
365
+ if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
366
+ const r = await guard(term, st, i.lockingVault, () => vc.lockProfile(cur.id))
367
+ if (r.ok) { flash(st, i.vaultLocked); await refreshProfiles(term, st) }
311
368
  }
312
369
  return true
313
370
  }
314
371
 
315
372
  async function onKeyDevices (term, st, key) {
373
+ const i = L(st)
316
374
  // Sondea el dispositivo pendiente en cada tick (uno puede conectarse mientras
317
375
  // estás en esta pantalla, no solo en la de emparejamiento).
318
376
  if (key.name === 'tick') { st.pending = vc.pendingEnroll(); return true }
@@ -323,23 +381,23 @@ async function onKeyDevices (term, st, key) {
323
381
  const cur = sels[Math.min(st.sel.devices, sels.length - 1)]
324
382
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
325
383
 
326
- if (ch === 'e') {
327
- const r = await guard(term, st, 'Iniciando emparejamiento…', () => vc.startPairing({ profile: activeId(st) }))
384
+ if (ch === 'p') { // pair
385
+ const r = await guard(term, st, i.startingPairing, () => vc.startPairing({ profile: activeId(st) }))
328
386
  if (r.ok) { st.pairing = r.v; st.pending = null; st.screen = 'pairing' }
329
387
  } else if (ch === 'a') { // aprobar el pendiente
330
- if (!st.pending) { flash(st, 'No hay ningún dispositivo pendiente', 'warn'); return true }
388
+ if (!st.pending) { flash(st, i.noPending, 'warn'); return true }
331
389
  promptApprove(term, st)
332
390
  } 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 }
391
+ if (!st.pending) { flash(st, i.noPendingToReject, 'warn'); return true }
392
+ const r = await guard(term, st, i.rejecting, () => vc.rejectPending(st.pending.deviceId, activeId(st)))
393
+ if (r.ok) { flash(st, i.deviceRejected); st.pending = null }
336
394
  } else if ((ch === 'v' || key.name === 'delete') && cur?.nonce != null) { // revocar el enrolado seleccionado
337
395
  setConfirm(st, {
338
- text: `¿Revocar ${cur.deviceId}? Se le ordena autoborrarse al reconectar.`,
396
+ text: i.revokeConfirm(cur.deviceId),
339
397
  onYes: async () => {
340
398
  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 }
399
+ const r = await guard(term, st, i.revoking, () => vc.revokeDevice(cur.nonce, activeId(st)))
400
+ if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); st.devices = r.v; st.sel.devices = 0 }
343
401
  },
344
402
  onNo: () => { st.confirm = null }
345
403
  })
@@ -350,20 +408,22 @@ async function onKeyDevices (term, st, key) {
350
408
  }
351
409
 
352
410
  function promptApprove (term, st) {
411
+ const i = L(st)
353
412
  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',
413
+ label: i.approveLabel(st.pending?.deviceId || ''),
414
+ hint: i.approveHint,
356
415
  onSubmit: async (code) => {
357
416
  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' }
417
+ if (!code.trim()) { flash(st, i.codeMissing, 'danger'); return }
418
+ const r = await guard(term, st, i.approving, () => vc.approvePending(code.trim(), activeId(st)))
419
+ if (r.ok) { flash(st, i.deviceApproved); st.devices = r.v; st.pending = null; st.screen = 'devices' }
361
420
  },
362
421
  onCancel: () => { st.input = null }
363
422
  })
364
423
  }
365
424
 
366
425
  async function onKeyPairing (term, st, key) {
426
+ const i = L(st)
367
427
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
368
428
  if (key.name === 'tick') {
369
429
  const pend = vc.pendingEnroll()
@@ -372,12 +432,12 @@ async function onKeyPairing (term, st, key) {
372
432
  }
373
433
  if (ch === 'a' && st.pending) { promptApprove(term, st); return true }
374
434
  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 }
435
+ const r = await guard(term, st, i.rejecting, () => vc.rejectPending(st.pending.deviceId, activeId(st)))
436
+ if (r.ok) { flash(st, i.deviceRejected); st.pending = null }
377
437
  return true
378
438
  }
379
- if (ch === 'e') { // reiniciar emparejamiento
380
- const r = await guard(term, st, 'Reiniciando emparejamiento…', () => vc.startPairing({ profile: activeId(st) }))
439
+ if (ch === 'r') { // restart: reiniciar el emparejamiento
440
+ const r = await guard(term, st, i.restartingPairing, () => vc.startPairing({ profile: activeId(st) }))
381
441
  if (r.ok) { st.pairing = r.v; st.pending = null }
382
442
  return true
383
443
  }
@@ -386,6 +446,7 @@ async function onKeyPairing (term, st, key) {
386
446
  }
387
447
 
388
448
  async function onKeySecrets (term, st, key) {
449
+ const i = L(st)
389
450
  const rows = secretRows(st, term.t)
390
451
  const sels = rows.filter((r) => r.sel).map((r) => r.meta)
391
452
  moveSel(st, key, 'secrets', sels.length)
@@ -397,22 +458,22 @@ async function onKeySecrets (term, st, key) {
397
458
  } else if ((ch === 'x' || key.name === 'delete') && cur) {
398
459
  if (cur.key) {
399
460
  setConfirm(st, {
400
- text: `¿Quitar la variable ${cur.ns}/${cur.key}?`,
461
+ text: i.removeVarConfirm(cur.ns, cur.key),
401
462
  onYes: async () => {
402
463
  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) }
464
+ const r = await guard(term, st, i.removingVar, () => vc.deleteSecret(cur.ns, cur.key, activeId(st)))
465
+ if (r.ok) { flash(st, i.varRemoved); st.secrets = r.v; st.sel.secrets = Math.max(0, st.sel.secrets - 1) }
405
466
  },
406
467
  onNo: () => { st.confirm = null }
407
468
  })
408
469
  } else {
409
470
  const count = (st.secrets?.[cur.ns] || []).length
410
471
  setConfirm(st, {
411
- text: `¿Quitar el scope "${cur.ns}" ENTERO (${count} variable(s))?`,
472
+ text: i.removeScopeConfirm(cur.ns, count),
412
473
  onYes: async () => {
413
474
  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 }
475
+ const r = await guard(term, st, i.removingScope, () => vc.deleteScope(cur.ns, activeId(st)))
476
+ if (r.ok) { flash(st, i.scopeRemoved(cur.ns)); st.secrets = r.v; st.sel.secrets = 0 }
416
477
  },
417
478
  onNo: () => { st.confirm = null }
418
479
  })
@@ -424,30 +485,31 @@ async function onKeySecrets (term, st, key) {
424
485
  }
425
486
 
426
487
  function promptNewVariable (term, st) {
488
+ const i = L(st)
427
489
  const existing = Object.keys(st.secrets || {})
428
490
  setInput(st, {
429
- label: 'Scope (namespace del servicio)',
430
- hint: existing.length ? `[a-z0-9-] · existen: ${existing.join(', ')}` : '[a-z0-9-], p. ej. proxy',
491
+ label: i.nsLabel,
492
+ hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
431
493
  onSubmit: (ns) => {
432
494
  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 }
495
+ if (!NS_RE.test(nsv)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
434
496
  st.input = null
435
497
  setInput(st, {
436
- label: `Variable en "${nsv}" (MAYUSCULAS_CON_GUION_BAJO)`,
437
- hint: '[A-Z0-9_], p. ej. TURN_KEY_ID',
498
+ label: i.keyLabel(nsv),
499
+ hint: i.keyHint,
438
500
  onSubmit: (key) => {
439
501
  const kv = key.trim()
440
- if (!KEY_RE.test(kv)) { flash(st, 'Clave inválida: usa [A-Z0-9_]{1,64}', 'danger'); return }
502
+ if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
441
503
  st.input = null
442
504
  setInput(st, {
443
- label: `Valor de ${nsv}/${kv}`,
505
+ label: i.valueLabel(nsv, kv),
444
506
  mask: true,
445
- hint: 'el valor nunca se muestra; se guarda en la bóveda',
507
+ hint: i.valueHint,
446
508
  onSubmit: async (value) => {
447
509
  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 }
510
+ if (!value) { flash(st, i.valueEmpty, 'danger'); return }
511
+ const r = await guard(term, st, i.savingVar, () => vc.setSecret(nsv, kv, value, activeId(st)))
512
+ if (r.ok) { flash(st, i.varSaved(nsv, kv)); st.secrets = r.v }
451
513
  },
452
514
  onCancel: () => { st.input = null }
453
515
  })
@@ -475,6 +537,8 @@ async function onInputKey (st, key) {
475
537
  async function onConfirmKey (st, key) {
476
538
  const cf = st.confirm
477
539
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
540
+ // La tecla es `y` (yes) en los dos idiomas —como el resto, mnemónico inglés—;
541
+ // `s` (sí) se sigue aceptando por costumbre, pero no se anuncia en la ayuda.
478
542
  if (ch === 's' || ch === 'y') { const f = cf.onYes; st.confirm = null; await f?.() }
479
543
  else if (ch === 'n' || key.name === 'escape' || key.name === 'enter' || key.name === 'ctrl-c') { const f = cf.onNo; st.confirm = null; await f?.() }
480
544
  }
@@ -484,62 +548,69 @@ async function onConfirmKey (st, key) {
484
548
  // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
485
549
  // bóvedas (profiles) es el nivel de arriba (se entra con Enter, no es una pestaña).
486
550
  const INNER_TABS = ['devices', 'secrets']
487
- const TAB_LABEL = { devices: 'Dispositivos', secrets: 'Scopes y variables' }
551
+ const tabLabel = (i, k) => (k === 'devices' ? i.tabDevices : i.tabSecrets)
488
552
 
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
- }
553
+ const helpSegs = (i, screen) => ({
554
+ profiles: i.helpProfiles,
555
+ devices: i.helpDevices,
556
+ secrets: i.helpSecrets,
557
+ pairing: i.helpPairing
558
+ })[screen] || []
559
+
560
+ const title = (i, screen) => (screen === 'profiles' ? i.titleProfiles : screen === 'pairing' ? i.titlePairing : '')
499
561
 
500
562
  /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
501
563
  function renderTabs (st, t) {
564
+ const i = L(st)
502
565
  return INNER_TABS.map((k) => {
503
566
  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)')
567
+ return active ? t.bold(t.accent('▐ ' + tabLabel(i, k) + ' ▌')) : t.muted(' ' + tabLabel(i, k) + ' ')
568
+ }).join(' ') + t.muted(i.tabsHint)
506
569
  }
507
570
 
508
571
  function pairingBody (st, t, cols, height) {
572
+ const i = L(st)
509
573
  const info = st.pairing
510
574
  const lines = []
575
+ // QUÉ CUENTA se está compartiendo: el vault puede tener varias y el QR sale de
576
+ // UNA (la bóveda en la que entraste). Decirlo aquí evita enrolar un dispositivo
577
+ // en la cuenta equivocada sin enterarse.
578
+ const ap = activeProfile(st)
579
+ const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
580
+ lines.push(t.bold(i.pairAccount(acct)))
511
581
  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.`))
582
+ lines.push(t.muted(i.pairValid(left)))
513
583
  lines.push('')
514
584
  // QR solo si entra cómodo (es "alto": ~ (módulos+8)/2 filas).
515
585
  let qr = ''
516
586
  try { qr = qrToString(info.url) } catch (_) {}
517
587
  const qrLines = qr ? qr.replace(/\n$/, '').split('\n') : []
518
588
  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
589
+ const reserved = 9 // encabezado + cuenta + URL + payload + aviso
520
590
  if (qrLines.length && qrWidth <= cols && qrLines.length <= height - reserved) {
521
591
  for (const l of qrLines) lines.push(l)
522
592
  lines.push('')
523
593
  }
524
- lines.push(t.bold('URL: ') + info.url)
594
+ lines.push(t.bold(i.pairUrl) + info.url)
525
595
  lines.push('')
526
- lines.push(t.muted('O pega este código en la pestaña #vault de profile.dotrino.com:'))
596
+ lines.push(t.muted(i.pairPaste))
527
597
  lines.push(info.payload)
528
598
  lines.push('')
529
- lines.push(t.danger('⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.'))
599
+ lines.push(t.danger(i.pairWarning))
530
600
  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…'))
601
+ if (st.pending) lines.push(t.warn(i.pairConnected(st.pending.deviceId)))
602
+ else lines.push(t.muted(i.pairWaiting))
533
603
  return lines
534
604
  }
535
605
 
536
606
  function render (term, st) {
537
607
  const t = term.t
608
+ const i = L(st)
538
609
  const { cols, rows } = term.size()
539
610
  // La distribución necesita: header+contexto (5) + 1 de contenido + estado + ayuda.
540
611
  // En un terminal más chico, en vez de escribir en índices fuera de rango, avisamos.
541
612
  if (rows < 9 || cols < 24) {
542
- term.render([t.warn('Terminal muy pequeño'), `Agranda a ≥ 24×9 (hay ${cols}×${rows}).`])
613
+ term.render([t.warn(i.tooSmall), i.tooSmallHint(cols, rows)])
543
614
  return
544
615
  }
545
616
  const lines = new Array(rows).fill('')
@@ -547,16 +618,16 @@ function render (term, st) {
547
618
  const s = st.state
548
619
  const up = st.daemonUp
549
620
  const ver = s?.version || 'dev'
550
- const daemonTxt = up ? 'corriendo' : 'DETENIDO'
621
+ const daemonTxt = up ? i.daemonRunning : i.daemonStopped
551
622
  lines[0] = t.bar(`dotrino-vault ${ver} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
552
623
 
553
624
  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
625
+ const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
626
+ lines[1] = ' ' + i.activeVault + apTxt
556
627
  lines[2] = ''
557
628
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
558
629
  // el resto muestra su título simple.
559
- lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + (TITLE[st.screen] || ''))
630
+ lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
560
631
  lines[4] = ''
561
632
 
562
633
  const top = 5
@@ -573,7 +644,7 @@ function render (term, st) {
573
644
  body = pb.slice(0, contentH)
574
645
  while (body.length < contentH) body.push('')
575
646
  }
576
- for (let i = 0; i < contentH; i++) lines[top + i] = body[i] ?? ''
647
+ for (let n = 0; n < contentH; n++) lines[top + n] = body[n] ?? ''
577
648
 
578
649
  // línea de estado: input / confirm / flash / busy
579
650
  const statusRow = rows - 2
@@ -584,7 +655,7 @@ function render (term, st) {
584
655
  const hint = inp.hint ? t.muted(' [' + inp.hint + ']') : ''
585
656
  lines[statusRow] = ' ' + t.bold(inp.label + ': ') + shown + t.accent('▏') + hint
586
657
  } else if (st.confirm) {
587
- lines[statusRow] = ' ' + t.warn(st.confirm.text) + t.muted(' (s / N)')
658
+ lines[statusRow] = ' ' + t.warn(st.confirm.text) + t.muted(i.confirmKeys)
588
659
  } else if (st.flash) {
589
660
  const kind = st.flash.kind
590
661
  const style = kind === 'danger' ? t.danger : kind === 'warn' ? t.warn : t.ok
@@ -592,9 +663,9 @@ function render (term, st) {
592
663
  } else lines[statusRow] = ''
593
664
 
594
665
  // 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'
666
+ let help = fitHelp(helpSegs(i, st.screen), cols)
667
+ if (st.input) help = i.helpInput
668
+ else if (st.confirm) help = i.helpConfirm
598
669
  lines[rows - 1] = t.bar(help, cols)
599
670
 
600
671
  term.render(lines)
@@ -605,39 +676,42 @@ function render (term, st) {
605
676
  async function daemonDownScreen (term, st) {
606
677
  while (true) {
607
678
  const t = term.t
679
+ const i = L(st)
608
680
  const { cols, rows } = term.size()
609
681
  const lines = new Array(Math.max(rows, 2)).fill('')
610
682
  // Contenido en orden; se coloca desde la fila 2 y se corta si no cabe (no se
611
683
  // escribe nunca en índices fijos que se salgan de un terminal pequeño).
612
684
  const content = [
613
- t.danger('El daemon del vault no está corriendo.'),
685
+ t.danger(i.downTitle),
614
686
  '',
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.',
687
+ i.downBody1,
688
+ i.downBody2,
617
689
  '',
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',
690
+ t.bold('S') + i.downStart + t.muted('systemctl --user start dotrino-vault'),
691
+ t.bold('R') + i.downRecheck,
692
+ t.bold('l') + i.downLang,
693
+ t.bold('Q') + i.downQuit,
621
694
  '',
622
- t.muted('En desarrollo, arráncalo a mano: node bin/dotrino-vaultd.js')
695
+ t.muted(i.downDev)
623
696
  ]
624
697
  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)
698
+ lines[0] = t.bar(i.downHeader, cols)
699
+ for (let n = 0; n < content.length && 2 + n < rows - 1; n++) lines[2 + n] = ' ' + content[n]
700
+ lines[rows - 1] = t.bar(fitHelp(i.downHelp, cols), cols)
628
701
  term.render(lines)
629
702
 
630
703
  const key = await term.readKey()
631
704
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
632
705
  if (ch === 'q' || key.name === 'ctrl-c') return false
633
- if (ch === 'r') { if (vc.daemonAlive()) return true; flash(st, 'Sigue sin responder', 'warn') }
706
+ if (ch === 'l') { toggleLang(st); continue }
707
+ if (ch === 'r') { if (vc.daemonAlive()) return true; flash(st, L(st).stillDown, 'warn') }
634
708
  if (ch === 's') {
635
- st.busy = 'Arrancando el servicio…'; // (no re-render aquí; mensaje simple)
636
- flash(st, 'Arrancando…', 'warn'); term.render(lines)
709
+ st.busy = L(st).starting // (no re-render aquí; mensaje simple)
710
+ flash(st, L(st).startingShort, 'warn'); term.render(lines)
637
711
  const r = await startDaemonService()
638
712
  await sleep(1500)
639
713
  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')
714
+ flash(st, r.ok ? L(st).startedNotReady : L(st).startFailed(r.err), 'danger')
641
715
  st.busy = null
642
716
  }
643
717
  }
@@ -649,6 +723,7 @@ export async function runTui () {
649
723
  const term = createTerm()
650
724
  const st = {
651
725
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
726
+ lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
652
727
  sel: { profiles: 0, devices: 0, secrets: 0 },
653
728
  scroll: {},
654
729
  profiles: null,
@@ -696,10 +771,12 @@ export async function runTui () {
696
771
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
697
772
  // 'q' global sale.
698
773
  if (ch === 'q') { running = false; continue }
774
+ // 'l' global: idioma es⇄en en cualquier pantalla (por eso el candado es 'c').
775
+ if (ch === 'l') { toggleLang(st); continue }
699
776
  // ←→ cambia entre las pestañas de la bóveda entrada (Dispositivos/Scopes).
700
777
  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]
778
+ const n = INNER_TABS.indexOf(st.screen)
779
+ st.screen = INNER_TABS[(n + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
703
780
  continue
704
781
  }
705
782
  // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
@@ -719,4 +796,4 @@ export async function runTui () {
719
796
  }
720
797
 
721
798
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
722
- export const __test = { render, profileRows, deviceRows, secretRows, pairingBody }
799
+ export const __test = { render, profileRows, deviceRows, secretRows, pairingBody, fitHelp, toggleLang }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * i18n.js — textos de la TUI en español e inglés (CONVENCIONES §9: bilingüe es/en).
3
+ *
4
+ * Sin dependencias y sin estado global: `dict(lang)` devuelve el diccionario y la
5
+ * TUI guarda el idioma activo en `st.lang`. Las entradas con datos son funciones
6
+ * (`vaultCreated: (n) => …`) para que el orden de las palabras sea el natural de
7
+ * cada idioma en vez de una plantilla con huecos.
8
+ *
9
+ * Idioma inicial: `DOTRINO_LANG` (override por ejecución) → el guardado en
10
+ * `prefs.json` (la última vez que pulsaste `l`) → el locale del sistema
11
+ * (`LC_ALL`/`LC_MESSAGES`/`LANGUAGE`/`LANG`) → español.
12
+ *
13
+ * Español de Ecuador: TUTEO, nunca voseo (CONVENCIONES §9).
14
+ */
15
+ import path from 'node:path'
16
+ import { dataDir, readJson, writeJson } from '../paths.js'
17
+
18
+ // --------------------------------- español ---------------------------------
19
+
20
+ const es = {
21
+ code: 'es',
22
+ langName: 'Español',
23
+ otherLangName: 'English',
24
+ langChanged: 'Idioma: Español',
25
+
26
+ // encabezado / estado
27
+ daemonRunning: 'corriendo',
28
+ daemonStopped: 'DETENIDO',
29
+ activeVault: 'Bóveda activa: ',
30
+ noName: '(sin nombre)',
31
+ tooSmall: 'Terminal muy pequeño',
32
+ tooSmallHint: (cols, rows) => `Agranda a ≥ 24×9 (hay ${cols}×${rows}).`,
33
+
34
+ // pestañas y títulos
35
+ tabDevices: 'Dispositivos',
36
+ tabSecrets: 'Scopes y variables',
37
+ tabsHint: ' (←→ cambiar)',
38
+ titleProfiles: 'Bóvedas',
39
+ titlePairing: 'Emparejar un dispositivo',
40
+
41
+ // bóvedas (perfiles)
42
+ noPassword: 'sin clave',
43
+ locked: '🔒 bloqueada',
44
+ unlocked: '🔓 abierta',
45
+ passwordOf: (name) => `Contraseña de "${name}"`,
46
+ passwordToEdit: 'necesaria para editar la bóveda',
47
+ unlocking: 'Desbloqueando…',
48
+ loading: 'Cargando…',
49
+ loadingDevices: 'Cargando dispositivos…',
50
+ loadingSecrets: 'Cargando secretos…',
51
+ loadingVaults: 'Cargando bóvedas…',
52
+ switchingVault: 'Cambiando de bóveda…',
53
+ vaultNowActive: (name) => `Bóveda activa: ${name}`,
54
+ newVaultLabel: 'Nombre de la nueva bóveda',
55
+ newVaultHint: 'crea una identidad nueva y vacía',
56
+ nameEmpty: 'El nombre no puede estar vacío',
57
+ creatingVault: 'Creando bóveda…',
58
+ vaultCreated: (name) => `Bóveda creada: ${name}`,
59
+ renameLabel: (name) => `Nuevo nombre para "${name}"`,
60
+ renaming: 'Renombrando…',
61
+ vaultRenamed: 'Bóveda renombrada',
62
+ cantDeleteLast: 'No se puede borrar la única bóveda',
63
+ deleteLabel: (name) => `Escribe "${name}" para BORRARLA (irreversible)`,
64
+ deleteHint: 'se pierde su clave; sus dispositivos dejan de funcionar',
65
+ deleteMismatch: 'Cancelado (el nombre no coincide)',
66
+ deletingVault: 'Borrando bóveda…',
67
+ vaultDeleted: 'Bóveda borrada',
68
+ newPasswordLabel: (name) => `Contraseña nueva para "${name}" (mín. 4)`,
69
+ passwordTooShort: 'La contraseña debe tener al menos 4 caracteres',
70
+ repeatPassword: 'Repite la contraseña',
71
+ passwordMismatch: 'Las contraseñas no coinciden',
72
+ savingPassword: 'Guardando contraseña…',
73
+ passwordSaved: 'Contraseña guardada',
74
+ noPasswordSet: 'Esta bóveda no tiene contraseña',
75
+ removingPassword: 'Quitando contraseña…',
76
+ passwordRemoved: 'Contraseña quitada',
77
+ alreadyUnlocked: 'Ya está desbloqueada',
78
+ vaultUnlocked: 'Bóveda desbloqueada',
79
+ lockingVault: 'Bloqueando…',
80
+ vaultLocked: 'Bóveda bloqueada',
81
+
82
+ // dispositivos
83
+ pendingDevice: (id) => ` ⧗ PENDIENTE: ${id}`,
84
+ pendingHint: ' — pulsa A para aprobar, X para rechazar',
85
+ noDevices: ' (sin dispositivos enrolados — pulsa P para emparejar uno)',
86
+ noLabel: '(sin etiqueta)',
87
+ revokedCount: (n) => ` Revocados: ${n}`,
88
+ startingPairing: 'Iniciando emparejamiento…',
89
+ noPending: 'No hay ningún dispositivo pendiente',
90
+ noPendingToReject: 'No hay ningún dispositivo pendiente para rechazar',
91
+ rejecting: 'Rechazando…',
92
+ deviceRejected: 'Dispositivo rechazado',
93
+ revokeConfirm: (id) => `¿Revocar ${id}? Se le ordena autoborrarse al reconectar.`,
94
+ revoking: 'Revocando…',
95
+ deviceRevoked: (id) => `Revocado ${id}`,
96
+ approveLabel: (id) => `Código que MUESTRA el dispositivo ${id}`,
97
+ approveHint: 'el vault no lo conoce: compáralo en la otra pantalla',
98
+ codeMissing: 'Falta el código',
99
+ approving: 'Aprobando…',
100
+ deviceApproved: 'Dispositivo aprobado',
101
+ restartingPairing: 'Reiniciando emparejamiento…',
102
+
103
+ // scopes y variables
104
+ noScopes: ' (sin scopes — pulsa N para agregar la primera variable)',
105
+ scopeOf: (ns) => ` (scope vault:secrets:${ns})`,
106
+ removeVarConfirm: (ns, key) => `¿Quitar la variable ${ns}/${key}?`,
107
+ removingVar: 'Quitando variable…',
108
+ varRemoved: 'Variable quitada',
109
+ removeScopeConfirm: (ns, n) => `¿Quitar el scope "${ns}" ENTERO (${n} variable(s))?`,
110
+ removingScope: 'Quitando scope…',
111
+ scopeRemoved: (ns) => `Scope "${ns}" quitado`,
112
+ nsLabel: 'Scope (namespace del servicio)',
113
+ nsHintExisting: (list) => `[a-z0-9-] · existen: ${list}`,
114
+ nsHint: '[a-z0-9-], p. ej. proxy',
115
+ nsInvalid: 'Scope inválido: usa [a-z0-9-]{1,32}',
116
+ keyLabel: (ns) => `Variable en "${ns}" (MAYUSCULAS_CON_GUION_BAJO)`,
117
+ keyHint: '[A-Z0-9_], p. ej. TURN_KEY_ID',
118
+ keyInvalid: 'Clave inválida: usa [A-Z0-9_]{1,64}',
119
+ valueLabel: (ns, key) => `Valor de ${ns}/${key}`,
120
+ valueHint: 'el valor nunca se muestra; se guarda en la bóveda',
121
+ valueEmpty: 'El valor no puede estar vacío',
122
+ savingVar: 'Guardando variable…',
123
+ varSaved: (ns, key) => `Guardado ${ns}/${key}`,
124
+
125
+ // emparejamiento
126
+ pairAccount: (name) => `Cuenta que se comparte: ${name}`,
127
+ pairValid: (min) => `Válido ~${min} min. Escanéalo o abre la URL en el dispositivo.`,
128
+ pairUrl: 'URL: ',
129
+ pairPaste: 'O pega este código en la pestaña #vault de profile.dotrino.com:',
130
+ pairWarning: '⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.',
131
+ pairConnected: (id) => `⧗ Se conectó: ${id} — pulsa A y escribe el código que muestra.`,
132
+ pairWaiting: 'Esperando a que el dispositivo se conecte…',
133
+
134
+ // confirmación / entrada
135
+ confirmKeys: ' (s / N)',
136
+ helpInput: 'Enter confirmar · Esc cancelar · Ctrl-U limpiar',
137
+ helpConfirm: 's confirmar · n/Esc cancelar',
138
+
139
+ // Barras de ayuda. Las TECLAS son las mismas en los dos idiomas (mnemónico
140
+ // INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
141
+ // language/quit); lo único que se traduce es la palabra que las explica.
142
+ // Segmentos, no una línea: el render recorta del medio si no caben.
143
+ helpProfiles: ['↑↓', 'Enter entrar', 'n nueva', 'r renombrar', 'd borrar', 'p clave', 'x quitar-clave', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
144
+ helpDevices: ['←→ pestaña', '↑↓', 'p emparejar', 'a aprobar', 'x rechazar', 'v revocar', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
145
+ helpSecrets: ['←→ pestaña', '↑↓', 'n nueva variable', 'x quitar (variable/scope)', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
146
+ helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', 'Esc atrás', 'l English'],
147
+
148
+ // pantalla "daemon caído"
149
+ downTitle: 'El daemon del vault no está corriendo.',
150
+ downBody1: 'La TUI le da órdenes al daemon (custodio de tu clave). Sin él no puede',
151
+ downBody2: 'crear bóvedas, listar dispositivos ni tocar secretos.',
152
+ downStart: ' intentar arrancarlo: ',
153
+ downRecheck: ' volver a comprobar',
154
+ downLang: ' cambiar a English',
155
+ downQuit: ' salir',
156
+ downDev: 'En desarrollo, arráncalo a mano: node bin/dotrino-vaultd.js',
157
+ downHeader: 'dotrino-vault daemon: DETENIDO',
158
+ downHelp: ['S arrancar', 'R comprobar', 'l English', 'Q salir'],
159
+ starting: 'Arrancando el servicio…',
160
+ startingShort: 'Arrancando…',
161
+ stillDown: 'Sigue sin responder',
162
+ startedNotReady: 'Arrancó pero aún no responde; pulsa R',
163
+ startFailed: (err) => `No se pudo arrancar: ${err}`,
164
+
165
+ // errores
166
+ errDaemonDown: 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).',
167
+ errNoReply: 'El daemon no respondió.',
168
+ errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
169
+ errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
170
+ errPairFailed: 'El daemon no inició el emparejamiento.'
171
+ }
172
+
173
+ // ---------------------------------- inglés ----------------------------------
174
+
175
+ const en = {
176
+ code: 'en',
177
+ langName: 'English',
178
+ otherLangName: 'Español',
179
+ langChanged: 'Language: English',
180
+
181
+ daemonRunning: 'running',
182
+ daemonStopped: 'STOPPED',
183
+ activeVault: 'Active vault: ',
184
+ noName: '(unnamed)',
185
+ tooSmall: 'Terminal too small',
186
+ tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
187
+
188
+ tabDevices: 'Devices',
189
+ tabSecrets: 'Scopes & variables',
190
+ tabsHint: ' (←→ switch)',
191
+ titleProfiles: 'Vaults',
192
+ titlePairing: 'Pair a device',
193
+
194
+ noPassword: 'no password',
195
+ locked: '🔒 locked',
196
+ unlocked: '🔓 unlocked',
197
+ passwordOf: (name) => `Password for "${name}"`,
198
+ passwordToEdit: 'needed to edit this vault',
199
+ unlocking: 'Unlocking…',
200
+ loading: 'Loading…',
201
+ loadingDevices: 'Loading devices…',
202
+ loadingSecrets: 'Loading secrets…',
203
+ loadingVaults: 'Loading vaults…',
204
+ switchingVault: 'Switching vault…',
205
+ vaultNowActive: (name) => `Active vault: ${name}`,
206
+ newVaultLabel: 'Name of the new vault',
207
+ newVaultHint: 'creates a new, empty identity',
208
+ nameEmpty: 'The name cannot be empty',
209
+ creatingVault: 'Creating vault…',
210
+ vaultCreated: (name) => `Vault created: ${name}`,
211
+ renameLabel: (name) => `New name for "${name}"`,
212
+ renaming: 'Renaming…',
213
+ vaultRenamed: 'Vault renamed',
214
+ cantDeleteLast: 'Cannot delete the only vault',
215
+ deleteLabel: (name) => `Type "${name}" to DELETE it (irreversible)`,
216
+ deleteHint: 'its key is lost; its devices stop working',
217
+ deleteMismatch: 'Cancelled (the name does not match)',
218
+ deletingVault: 'Deleting vault…',
219
+ vaultDeleted: 'Vault deleted',
220
+ newPasswordLabel: (name) => `New password for "${name}" (min. 4)`,
221
+ passwordTooShort: 'The password must be at least 4 characters',
222
+ repeatPassword: 'Repeat the password',
223
+ passwordMismatch: 'The passwords do not match',
224
+ savingPassword: 'Saving password…',
225
+ passwordSaved: 'Password saved',
226
+ noPasswordSet: 'This vault has no password',
227
+ removingPassword: 'Removing password…',
228
+ passwordRemoved: 'Password removed',
229
+ alreadyUnlocked: 'Already unlocked',
230
+ vaultUnlocked: 'Vault unlocked',
231
+ lockingVault: 'Locking…',
232
+ vaultLocked: 'Vault locked',
233
+
234
+ pendingDevice: (id) => ` ⧗ PENDING: ${id}`,
235
+ pendingHint: ' — press A to approve, X to reject',
236
+ noDevices: ' (no devices enrolled — press P to pair one)',
237
+ noLabel: '(no label)',
238
+ revokedCount: (n) => ` Revoked: ${n}`,
239
+ startingPairing: 'Starting pairing…',
240
+ noPending: 'No device is waiting',
241
+ noPendingToReject: 'No device is waiting to be rejected',
242
+ rejecting: 'Rejecting…',
243
+ deviceRejected: 'Device rejected',
244
+ revokeConfirm: (id) => `Revoke ${id}? It is told to erase itself on reconnect.`,
245
+ revoking: 'Revoking…',
246
+ deviceRevoked: (id) => `Revoked ${id}`,
247
+ approveLabel: (id) => `Code SHOWN by device ${id}`,
248
+ approveHint: 'the vault does not know it: compare it on the other screen',
249
+ codeMissing: 'The code is missing',
250
+ approving: 'Approving…',
251
+ deviceApproved: 'Device approved',
252
+ restartingPairing: 'Restarting pairing…',
253
+
254
+ noScopes: ' (no scopes — press N to add the first variable)',
255
+ scopeOf: (ns) => ` (scope vault:secrets:${ns})`,
256
+ removeVarConfirm: (ns, key) => `Remove the variable ${ns}/${key}?`,
257
+ removingVar: 'Removing variable…',
258
+ varRemoved: 'Variable removed',
259
+ removeScopeConfirm: (ns, n) => `Remove the WHOLE scope "${ns}" (${n} variable(s))?`,
260
+ removingScope: 'Removing scope…',
261
+ scopeRemoved: (ns) => `Scope "${ns}" removed`,
262
+ nsLabel: 'Scope (the service namespace)',
263
+ nsHintExisting: (list) => `[a-z0-9-] · existing: ${list}`,
264
+ nsHint: '[a-z0-9-], e.g. proxy',
265
+ nsInvalid: 'Invalid scope: use [a-z0-9-]{1,32}',
266
+ keyLabel: (ns) => `Variable in "${ns}" (UPPERCASE_WITH_UNDERSCORES)`,
267
+ keyHint: '[A-Z0-9_], e.g. TURN_KEY_ID',
268
+ keyInvalid: 'Invalid key: use [A-Z0-9_]{1,64}',
269
+ valueLabel: (ns, key) => `Value of ${ns}/${key}`,
270
+ valueHint: 'the value is never shown; it is kept in the vault',
271
+ valueEmpty: 'The value cannot be empty',
272
+ savingVar: 'Saving variable…',
273
+ varSaved: (ns, key) => `Saved ${ns}/${key}`,
274
+
275
+ pairAccount: (name) => `Account being shared: ${name}`,
276
+ pairValid: (min) => `Valid ~${min} min. Scan it or open the URL on the device.`,
277
+ pairUrl: 'URL: ',
278
+ pairPaste: 'Or paste this code into the #vault tab of profile.dotrino.com:',
279
+ pairWarning: '⚠ This code lets someone READ your data and SIGN as you. Do not share it.',
280
+ pairConnected: (id) => `⧗ Connected: ${id} — press A and type the code it shows.`,
281
+ pairWaiting: 'Waiting for the device to connect…',
282
+
283
+ confirmKeys: ' (y / N)',
284
+ helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
285
+ helpConfirm: 'y confirm · n/Esc cancel',
286
+
287
+ helpProfiles: ['↑↓', 'Enter open', 'n new', 'r rename', 'd delete', 'p password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
288
+ helpDevices: ['←→ tab', '↑↓', 'p pair', 'a approve', 'x reject', 'v revoke', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
289
+ helpSecrets: ['←→ tab', '↑↓', 'n new variable', 'x remove (variable/scope)', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
290
+ helpPairing: ['a approve', 'x reject', 'r restart', 'Esc back', 'l Español'],
291
+
292
+ downTitle: 'The vault daemon is not running.',
293
+ downBody1: 'The TUI gives orders to the daemon (the keeper of your key). Without it',
294
+ downBody2: 'it cannot create vaults, list devices or touch secrets.',
295
+ downStart: ' try to start it: ',
296
+ downRecheck: ' check again',
297
+ downLang: ' switch to Español',
298
+ downQuit: ' quit',
299
+ downDev: 'In development, start it by hand: node bin/dotrino-vaultd.js',
300
+ downHeader: 'dotrino-vault daemon: STOPPED',
301
+ downHelp: ['S start', 'R check', 'l Español', 'Q quit'],
302
+ starting: 'Starting the service…',
303
+ startingShort: 'Starting…',
304
+ stillDown: 'Still not answering',
305
+ startedNotReady: 'It started but does not answer yet; press R',
306
+ startFailed: (err) => `Could not start it: ${err}`,
307
+
308
+ errDaemonDown: 'The daemon is not running. Start it: systemctl --user start dotrino-vault (or restart the TUI).',
309
+ errNoReply: 'The daemon did not answer.',
310
+ errNotApplied: 'The daemon did not apply the change (check the service logs).',
311
+ errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
312
+ errPairFailed: 'The daemon did not start the pairing.'
313
+ }
314
+
315
+ // --------------------------- selección y persistencia -----------------------
316
+
317
+ export const LANGS = ['es', 'en']
318
+
319
+ /** Diccionario del idioma pedido (español para cualquier valor desconocido). */
320
+ export const dict = (lang) => (lang === 'en' ? en : es)
321
+
322
+ /** El OTRO idioma (el toggle es binario). */
323
+ export const otherLang = (lang) => (lang === 'en' ? 'es' : 'en')
324
+
325
+ const prefsFile = () => path.join(dataDir(), 'prefs.json')
326
+
327
+ /** 'es_EC.UTF-8' → 'es'; 'C'/'POSIX'/vacío → null (para caer al siguiente origen). */
328
+ const normalize = (v) => {
329
+ const s = String(v || '').toLowerCase()
330
+ if (s.startsWith('en')) return 'en'
331
+ if (s.startsWith('es')) return 'es'
332
+ return null
333
+ }
334
+
335
+ /** Idioma inicial: DOTRINO_LANG → prefs.json → locale del sistema → español. */
336
+ export function loadLang () {
337
+ const forced = normalize(process.env.DOTRINO_LANG)
338
+ if (forced) return forced
339
+ const saved = normalize(readJson(prefsFile(), {})?.lang)
340
+ if (saved) return saved
341
+ const locale = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANGUAGE || process.env.LANG
342
+ return normalize(locale) || 'es'
343
+ }
344
+
345
+ /** Recuerda el idioma para la próxima vez (junto al resto de preferencias). */
346
+ export function saveLang (lang) {
347
+ if (!LANGS.includes(lang)) return false
348
+ try {
349
+ writeJson(prefsFile(), { ...(readJson(prefsFile(), {}) || {}), lang })
350
+ return true
351
+ } catch (_) { return false } // preferencia: nunca romper la TUI por no poder guardarla
352
+ }
@@ -67,6 +67,13 @@ class DaemonDownError extends Error {
67
67
  constructor () { super('el daemon del vault no está corriendo'); this.code = 'DAEMON_DOWN' }
68
68
  }
69
69
 
70
+ /**
71
+ * Error con `code`: la CLI sigue imprimiendo el mensaje tal cual y la TUI, que es
72
+ * bilingüe (`src/tui/i18n.js`), lo traduce por el código. Los errores que REENVÍA
73
+ * el daemon no llevan código: son diagnósticos del servicio, no copy de interfaz.
74
+ */
75
+ const coded = (message, code) => Object.assign(new Error(message), { code })
76
+
70
77
  /**
71
78
  * Exige el daemon vivo ANTES de escribir cualquier petición. Es clave para las
72
79
  * peticiones que llevan secretos (contraseña de perfil, valor de secreto): si el
@@ -141,7 +148,7 @@ async function profileOp (op, { profile, name, password } = {}) {
141
148
  writeReq(F.profileReq, { op, ...extra }, profile)
142
149
  signalOrCleanup('SIGUSR2', [F.profileReq])
143
150
  const d = await waitFor(F.profilesList)
144
- if (!d) throw new Error('el daemon no respondió')
151
+ if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
145
152
  if (d.error) throw new Error(d.error)
146
153
  return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
147
154
  }
@@ -181,7 +188,7 @@ export async function snapshot (profile) {
181
188
  */
182
189
  export async function listDevices (profile) {
183
190
  const { devices } = await snapshot(profile)
184
- if (!devices) throw new Error('el daemon no respondió')
191
+ if (!devices) throw coded('el daemon no respondió', 'NO_REPLY')
185
192
  const issued = devices.issued || devices.active || devices.delegations || []
186
193
  const withIds = await Promise.all(issued.map(async (d) => ({
187
194
  ...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
@@ -205,7 +212,7 @@ export async function revokeDevice (nonce, profile) {
205
212
  /** Scopes→[claves] del perfil (NUNCA los valores; el daemon no los expone). */
206
213
  export async function listSecrets (profile) {
207
214
  const { secrets } = await snapshot(profile)
208
- if (!secrets) throw new Error('el daemon no respondió')
215
+ if (!secrets) throw coded('el daemon no respondió', 'NO_REPLY')
209
216
  return secrets.ns || {}
210
217
  }
211
218
 
@@ -217,8 +224,8 @@ export async function setSecret (ns, key, value, profile) {
217
224
  writeReq(F.dumpReq, {}, profile)
218
225
  signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
219
226
  const d = await waitFor(F.secretsList)
220
- if (!d) throw new Error('el daemon no respondió')
221
- if (!(d.ns?.[ns] || []).includes(key)) throw new Error('el daemon no aplicó el cambio (revisa los logs del servicio)')
227
+ if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
228
+ if (!(d.ns?.[ns] || []).includes(key)) throw coded('el daemon no aplicó el cambio (revisa los logs del servicio)', 'NOT_APPLIED')
222
229
  return d.ns
223
230
  }
224
231
 
@@ -230,8 +237,8 @@ export async function deleteSecret (ns, key, profile) {
230
237
  writeReq(F.dumpReq, {}, profile)
231
238
  signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
232
239
  const d = await waitFor(F.secretsList)
233
- if (!d) throw new Error('el daemon no respondió')
234
- if ((d.ns?.[ns] || []).includes(key)) throw new Error('el daemon no borró la variable (revisa los logs del servicio)')
240
+ if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
241
+ if ((d.ns?.[ns] || []).includes(key)) throw coded('el daemon no borró la variable (revisa los logs del servicio)', 'NOT_DELETED')
235
242
  return d.ns
236
243
  }
237
244
 
@@ -273,10 +280,13 @@ export async function startPairing ({ profile, service } = {}) {
273
280
  const pr = read(F.pair, null)
274
281
  if (pr?.expiresAt > Date.now()) {
275
282
  const { url, payload } = pairUrl(pr.qr)
276
- return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload }
283
+ // `profile`/`profileName`: DE QUÉ CUENTA del vault sale este QR. El vault
284
+ // puede tener varias y el emparejamiento mete al dispositivo en UNA; la TUI
285
+ // y la CLI lo muestran para que no se enrole en la equivocada.
286
+ return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload, profile: pr.profile || null, profileName: pr.profileName || '' }
277
287
  }
278
288
  }
279
- throw new Error('el daemon no inició el emparejamiento')
289
+ throw coded('el daemon no inició el emparejamiento', 'PAIR_FAILED')
280
290
  }
281
291
 
282
292
  /** Dispositivo pendiente de aprobar (el que se conectó con el QR), o null. */