@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 +25 -0
- package/bin/dotrino-vaultd.js +16 -0
- package/package.json +1 -1
- package/src/ctl.js +10 -5
- package/src/daemon.js +5 -1
- package/src/tui/app.js +213 -136
- package/src/tui/i18n.js +352 -0
- package/src/vaultControl.js +19 -9
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
|
package/bin/dotrino-vaultd.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
147
|
+
// vault:secrets:<ns> (no puede firmar como tú 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
|
-
|
|
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
|
|
173
|
-
console.log('\nO
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
101
|
-
const name = p.current ? t.bold(p.name ||
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
240
|
+
label: i.passwordOf(p.name || p.id),
|
|
192
241
|
mask: true,
|
|
193
|
-
hint:
|
|
242
|
+
hint: i.passwordToEdit,
|
|
194
243
|
onSubmit: async (pwd) => {
|
|
195
244
|
st.input = null
|
|
196
|
-
const r = await guard(term, st,
|
|
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,
|
|
289
|
+
const r = await guard(term, st, i.switchingVault, () => vc.useProfile(cur.id))
|
|
233
290
|
if (!r.ok) return true
|
|
234
|
-
flash(st,
|
|
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:
|
|
242
|
-
hint:
|
|
298
|
+
label: i.newVaultLabel,
|
|
299
|
+
hint: i.newVaultHint,
|
|
243
300
|
onSubmit: async (name) => {
|
|
244
301
|
st.input = null
|
|
245
|
-
if (!name.trim()) { flash(st,
|
|
246
|
-
const r = await guard(term, st,
|
|
247
|
-
if (r.ok) { flash(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:
|
|
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,
|
|
258
|
-
const r = await guard(term, st,
|
|
259
|
-
if (r.ok) { flash(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,
|
|
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:
|
|
267
|
-
hint:
|
|
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,
|
|
271
|
-
const r = await guard(term, st,
|
|
272
|
-
if (r.ok) { flash(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 === '
|
|
333
|
+
} else if (ch === 'p' && cur) { // password
|
|
277
334
|
await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
|
|
278
|
-
label:
|
|
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,
|
|
339
|
+
if (pwd.length < 4) { flash(st, i.passwordTooShort, 'danger'); return }
|
|
283
340
|
setInput(st, {
|
|
284
|
-
label:
|
|
341
|
+
label: i.repeatPassword,
|
|
285
342
|
mask: true,
|
|
286
343
|
onSubmit: async (again) => {
|
|
287
344
|
st.input = null
|
|
288
|
-
if (again !== pwd) { flash(st,
|
|
289
|
-
const r = await guard(term, st,
|
|
290
|
-
if (r.ok) { flash(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,
|
|
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,
|
|
301
|
-
if (r.ok) { flash(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,
|
|
305
|
-
if (!cur.locked) { flash(st,
|
|
306
|
-
await ensureUnlocked(term, st, cur, async () => { flash(st,
|
|
307
|
-
} else if (ch === '
|
|
308
|
-
if (!cur.protected) { flash(st,
|
|
309
|
-
const r = await guard(term, st,
|
|
310
|
-
if (r.ok) { flash(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 === '
|
|
327
|
-
const r = await guard(term, 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,
|
|
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,
|
|
334
|
-
const r = await guard(term, st,
|
|
335
|
-
if (r.ok) { flash(st,
|
|
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:
|
|
396
|
+
text: i.revokeConfirm(cur.deviceId),
|
|
339
397
|
onYes: async () => {
|
|
340
398
|
st.confirm = null
|
|
341
|
-
const r = await guard(term, st,
|
|
342
|
-
if (r.ok) { flash(st,
|
|
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:
|
|
355
|
-
hint:
|
|
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,
|
|
359
|
-
const r = await guard(term, st,
|
|
360
|
-
if (r.ok) { flash(st,
|
|
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,
|
|
376
|
-
if (r.ok) { flash(st,
|
|
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 === '
|
|
380
|
-
const r = await guard(term, 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:
|
|
461
|
+
text: i.removeVarConfirm(cur.ns, cur.key),
|
|
401
462
|
onYes: async () => {
|
|
402
463
|
st.confirm = null
|
|
403
|
-
const r = await guard(term, st,
|
|
404
|
-
if (r.ok) { flash(st,
|
|
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:
|
|
472
|
+
text: i.removeScopeConfirm(cur.ns, count),
|
|
412
473
|
onYes: async () => {
|
|
413
474
|
st.confirm = null
|
|
414
|
-
const r = await guard(term, st,
|
|
415
|
-
if (r.ok) { flash(st,
|
|
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:
|
|
430
|
-
hint: existing.length ?
|
|
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,
|
|
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:
|
|
437
|
-
hint:
|
|
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,
|
|
502
|
+
if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
|
|
441
503
|
st.input = null
|
|
442
504
|
setInput(st, {
|
|
443
|
-
label:
|
|
505
|
+
label: i.valueLabel(nsv, kv),
|
|
444
506
|
mask: true,
|
|
445
|
-
hint:
|
|
507
|
+
hint: i.valueHint,
|
|
446
508
|
onSubmit: async (value) => {
|
|
447
509
|
st.input = null
|
|
448
|
-
if (!value) { flash(st,
|
|
449
|
-
const r = await guard(term, st,
|
|
450
|
-
if (r.ok) { flash(st,
|
|
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
|
|
551
|
+
const tabLabel = (i, k) => (k === 'devices' ? i.tabDevices : i.tabSecrets)
|
|
488
552
|
|
|
489
|
-
const
|
|
490
|
-
profiles:
|
|
491
|
-
devices:
|
|
492
|
-
secrets:
|
|
493
|
-
pairing:
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
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('▐ ' +
|
|
505
|
-
}).join(' ') + t.muted(
|
|
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(
|
|
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 =
|
|
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(
|
|
594
|
+
lines.push(t.bold(i.pairUrl) + info.url)
|
|
525
595
|
lines.push('')
|
|
526
|
-
lines.push(t.muted(
|
|
596
|
+
lines.push(t.muted(i.pairPaste))
|
|
527
597
|
lines.push(info.payload)
|
|
528
598
|
lines.push('')
|
|
529
|
-
lines.push(t.danger(
|
|
599
|
+
lines.push(t.danger(i.pairWarning))
|
|
530
600
|
lines.push('')
|
|
531
|
-
if (st.pending) lines.push(t.warn(
|
|
532
|
-
else lines.push(t.muted(
|
|
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(
|
|
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 ?
|
|
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 ||
|
|
555
|
-
lines[1] = '
|
|
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('» ' + (
|
|
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
|
|
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(
|
|
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 =
|
|
596
|
-
if (st.input) help =
|
|
597
|
-
else if (st.confirm) help =
|
|
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(
|
|
685
|
+
t.danger(i.downTitle),
|
|
614
686
|
'',
|
|
615
|
-
|
|
616
|
-
|
|
687
|
+
i.downBody1,
|
|
688
|
+
i.downBody2,
|
|
617
689
|
'',
|
|
618
|
-
t.bold('S') +
|
|
619
|
-
t.bold('R') +
|
|
620
|
-
t.bold('
|
|
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(
|
|
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(
|
|
626
|
-
for (let
|
|
627
|
-
lines[rows - 1] = t.bar(
|
|
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 === '
|
|
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 =
|
|
636
|
-
flash(st,
|
|
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 ?
|
|
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
|
|
702
|
-
st.screen = INNER_TABS[(
|
|
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 }
|
package/src/tui/i18n.js
ADDED
|
@@ -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
|
+
}
|
package/src/vaultControl.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
221
|
-
if (!(d.ns?.[ns] || []).includes(key)) throw
|
|
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
|
|
234
|
-
if ((d.ns?.[ns] || []).includes(key)) throw
|
|
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
|
-
|
|
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
|
|
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. */
|