@dotrino/vaultd 0.161.0 → 0.162.1

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.
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.89.0'
8
+ export const LIB_VERSION = '0.90.1'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.161.0",
3
+ "version": "0.162.1",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -22,12 +22,12 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "^0.118.0",
25
+ "@dotrino/identity": "0.119.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.18.4",
28
- "@dotrino/proxy-client": "^0.36.1",
29
- "@dotrino/roadmap": "0.4.26",
30
- "@dotrino/store": "0.11.0",
28
+ "@dotrino/proxy-client": "0.36.1",
29
+ "@dotrino/roadmap": "0.4.30",
30
+ "@dotrino/store": "0.12.3",
31
31
  "@dotrino/update": "0.9.0",
32
32
  "sigstore": "5.0.0",
33
33
  "ws": "^8.18.0"
@@ -51,11 +51,11 @@
51
51
  "!lib/src/types.d.ts"
52
52
  ],
53
53
  "devDependencies": {
54
- "@dotrino/remote-agent": "0.13.0",
54
+ "@dotrino/remote-agent": "0.20.1",
55
55
  "@types/node": "^22.0.0",
56
56
  "typescript": "5.9.3"
57
57
  },
58
58
  "optionalDependencies": {
59
- "@dotrino/webrtc": "0.1.0"
59
+ "@dotrino/webrtc": "0.1.1"
60
60
  }
61
61
  }
package/src/ctl.js CHANGED
@@ -41,6 +41,7 @@ import * as hw from './hwkeys.js'
41
41
  import { isNewer } from './update.js'
42
42
  import { COMM_CN } from '../lib/src/core/commKey.js'
43
43
  import { machineLine } from '@dotrino/identity/machine'
44
+ import * as vc from './vaultControl.js'
44
45
 
45
46
  const dir = dataDir()
46
47
  const stateFile = path.join(dir, 'state.json')
@@ -2338,25 +2339,10 @@ function drawerArg (word, devices = []) {
2338
2339
  return 'ns:' + w
2339
2340
  }
2340
2341
 
2341
- /** Una orden de respaldo al daemon, y su respuesta (o su error, con código). */
2342
- async function backupRequest (req, { timeoutMs = 120_000 } = {}) {
2343
- const s = requireDaemon()
2344
- const listFile = path.join(dataDir(), 'secrets-list.json')
2345
- const id = crypto.randomBytes(8).toString('hex')
2346
- try { fs.rmSync(listFile, { force: true }) } catch (_) {}
2347
- writeReq('secret-request.json', { ...req, id })
2348
- writeReq('dump-request.json', {})
2349
- sendSignal(s.pid, 'SIGUSR2')
2350
- for (let i = 0; i < timeoutMs / 100; i++) {
2351
- await sleep(100)
2352
- const d = ipcRead(listFile, null)
2353
- if (!d?.at) continue
2354
- try { fs.rmSync(listFile, { force: true }) } catch (_) {}
2355
- assertOpen(d)
2356
- if (d.backup?.id === id) return d.backup
2357
- if (d.secretError?.id === id) throw Object.assign(new Error(d.secretError.error), d.secretError)
2358
- }
2359
- throw Object.assign(new Error('the daemon did not reply'), { code: 'NO_REPLY' })
2342
+ /** Una orden de respaldo al daemon: la misma pieza que usa la TUI (`vaultControl.js`). */
2343
+ function backupRequest (req) {
2344
+ requireDaemon()
2345
+ return vc.backupOp(req, PROFILE || undefined)
2360
2346
  }
2361
2347
 
2362
2348
  function backupFail (e) {
@@ -2367,7 +2353,9 @@ function backupFail (e) {
2367
2353
  BACKUP_CORRUPT: 'El respaldo está dañado: no se puede leer.',
2368
2354
  WEAK_PASSWORD: 'La contraseña del respaldo tiene que tener al menos 12 caracteres.',
2369
2355
  NO_SUCH_DRAWER: `No existe ese cajón: ${(e.missing || []).join(', ')}. Míralos con: dotrino-vault backup list`,
2370
- NO_REPLY: 'El daemon no respondió.'
2356
+ NO_REPLY: 'El daemon no respondió.',
2357
+ DAEMON_DOWN: 'El servicio de la bóveda no está corriendo.',
2358
+ PROFILE_LOCKED: 'Perfil bloqueado. Ábrelo con: dotrino-vault unlock'
2371
2359
  }[e.code]
2372
2360
  console.error(msg || `No se pudo: ${e.message}`)
2373
2361
  process.exit(1)
package/src/tui/app.js CHANGED
@@ -30,6 +30,8 @@
30
30
  * que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
31
31
  */
32
32
  import fs from 'node:fs'
33
+ import os from 'node:os'
34
+ import path from 'node:path'
33
35
  import { spawnSync } from 'node:child_process'
34
36
  import { parseEnvInput } from '../../lib/src/envtext.js'
35
37
  import { createTerm, widthOf, makeTheme } from './term.js'
@@ -1338,6 +1340,15 @@ async function onKeyProfiles (term, st, key) {
1338
1340
  st.sel.settings = 0
1339
1341
  st.scroll.settings = { value: 0 }
1340
1342
  st.screen = 'settings'
1343
+ } else if (ch === 'o' && cur) { // el respaldo: exportar e importar, con la bóveda abierta
1344
+ await ensureUnlocked(term, st, cur, async (p = cur) => {
1345
+ if (!p.current) {
1346
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
1347
+ if (!r.ok) return
1348
+ await refreshAll(term, st)
1349
+ }
1350
+ await openBackup(term, st)
1351
+ })
1341
1352
  } else if (ch === 'y' && cur) { // las llaves: con qué se abre la bóveda
1342
1353
  await ensureUnlocked(term, st, cur, async (p = cur) => {
1343
1354
  st.doorsFor = p.id
@@ -2427,6 +2438,280 @@ async function onConfirmKey (st, key) {
2427
2438
  }
2428
2439
  }
2429
2440
 
2441
+ // -------------------------------- respaldo ---------------------------------
2442
+ //
2443
+ // EL RESPALDO (`docs/respaldo.md`): un archivo cifrado con una contraseña propia, con los
2444
+ // valores de los cajones elegidos (o de todos). Exportar e importar son de bóveda ABIERTA,
2445
+ // así que se entra con `ensureUnlocked`. Importar pasa por un PLAN: se ve qué es nuevo, qué
2446
+ // es igual y qué choca, y no se escribe nada hasta decidir cada choque.
2447
+
2448
+ const BACKUP_PWD_MIN = 12
2449
+ const drawerTitle = (i, owner, device) => owner === 'passwords'
2450
+ ? i.bkPasswords
2451
+ : owner === 'ns:@me' ? i.bkProfile
2452
+ : owner.startsWith('ns:') ? owner.slice(3)
2453
+ : i.bkDevice(device?.id || owner.slice(4, 12), device?.label || null)
2454
+
2455
+ /** El nombre por defecto del archivo: en la carpeta personal, con la fecha. */
2456
+ const defaultBackupPath = () => path.join(os.homedir(), `dotrino-vault-backup-${new Date().toISOString().slice(0, 10)}.json`)
2457
+
2458
+ function backupRows (st, t) {
2459
+ const i = L(st)
2460
+ const b = st.backup
2461
+ if (!b) return [{ text: t.muted(' —'), sel: false }]
2462
+ const rows = [{ text: ' ' + t.bold(i.bkExportTitle), sel: false }]
2463
+ if (!b.drawers.length) rows.push({ text: t.muted(' ' + i.bkNothing), sel: false })
2464
+ for (const d of b.drawers) {
2465
+ const on = b.chosen.has(d.owner)
2466
+ rows.push({
2467
+ text: ` ${on ? t.accent('[x]') : '[ ]'} ${drawerTitle(i, d.owner, d.device)} ${t.muted(i.bkCount(d.count, d.owner === 'passwords'))}`,
2468
+ sel: true,
2469
+ meta: { owner: d.owner }
2470
+ })
2471
+ }
2472
+ rows.push({ text: '', sel: false })
2473
+ rows.push({ text: t.muted(' ' + (b.chosen.size ? i.bkSomeChosen(b.chosen.size) : i.bkAllChosen)), sel: false })
2474
+ rows.push({ text: t.muted(' ' + i.bkNoKeys), sel: false })
2475
+ return rows
2476
+ }
2477
+
2478
+ const conflictsOf = (plan) => plan.drawers.flatMap((d) => d.items.filter((it) => it.status === 'conflict').map((it) => `${d.to} ${it.key}`))
2479
+
2480
+ function backupPlanRows (st, t) {
2481
+ const i = L(st)
2482
+ const bp = st.backupPlan
2483
+ if (!bp) return [{ text: t.muted(' —'), sel: false }]
2484
+ const rows = [{ text: ' ' + t.bold(i.bkPlanFrom(path.basename(bp.path))), sel: false }]
2485
+ const conflicts = conflictsOf(bp.plan)
2486
+ if (conflicts.length) {
2487
+ const all = new Set(conflicts.map((r) => bp.decisions[r]))
2488
+ const label = all.size === 1 && all.has('keep') ? i.bkKeep : all.size === 1 && all.has('replace') ? i.bkReplace : i.bkMixed
2489
+ rows.push({ text: ` ${t.bold(i.bkAllConflicts)}: ${t.accent(label)}`, sel: true, meta: { all: true } })
2490
+ }
2491
+ for (const d of bp.plan.drawers) {
2492
+ const name = d.from === d.to ? drawerTitle(i, d.to, d.label) : `${drawerTitle(i, d.from, d.label)} → ${drawerTitle(i, d.to, null)}`
2493
+ rows.push({ text: '', sel: false })
2494
+ if (d.status !== 'ok') {
2495
+ rows.push({ text: ` ${t.bold(name)} ${t.warn(i.bkNoDevice)}`, sel: true, meta: { drawer: d.from } })
2496
+ continue
2497
+ }
2498
+ rows.push({ text: ` ${t.bold(name)}`, sel: true, meta: { drawer: d.from } })
2499
+ for (const it of d.items) {
2500
+ const what = it.title ? `${it.title}${it.username ? ' · ' + it.username : ''}` : it.key
2501
+ if (it.status !== 'conflict') {
2502
+ rows.push({ text: ` ${t.muted((it.status === 'new' ? i.bkNew : i.bkSame).padEnd(10))} ${what}`, sel: false })
2503
+ continue
2504
+ }
2505
+ const ref = `${d.to} ${it.key}`
2506
+ const dec = bp.decisions[ref]
2507
+ const decTxt = dec === 'keep' ? i.bkKeep : dec === 'replace' ? i.bkReplace : t.warn(i.bkUndecided)
2508
+ rows.push({ text: ` ${t.warn(i.bkConflict.padEnd(10))} ${what} → ${decTxt}`, sel: true, meta: { ref } })
2509
+ }
2510
+ }
2511
+ const c = bp.plan.counts
2512
+ rows.push({ text: '', sel: false })
2513
+ rows.push({ text: t.muted(' ' + i.bkCounts(c.new, c.same, c.conflict, c.skipped)), sel: false })
2514
+ return rows
2515
+ }
2516
+
2517
+ /** Abre la pantalla de respaldo de la bóveda activa: carga qué cajones hay. */
2518
+ async function openBackup (term, st) {
2519
+ const i = L(st)
2520
+ const r = await guard(term, st, i.bkLoading, () => vc.backupDrawers(activeId(st)))
2521
+ if (!r.ok) return
2522
+ st.backup = { drawers: r.v, chosen: new Set() }
2523
+ st.sel.backup = 0
2524
+ st.scroll.backup = { value: 0 }
2525
+ st.screen = 'backup'
2526
+ }
2527
+
2528
+ /** Pide la contraseña del respaldo (dos veces al exportar) y sigue con ella. */
2529
+ function askBackupPassword (st, { twice }, done) {
2530
+ const i = L(st)
2531
+ setInput(st, {
2532
+ label: i.bkPassword,
2533
+ hint: twice ? i.bkPasswordHint : '',
2534
+ mask: true,
2535
+ onSubmit: async (pwd) => {
2536
+ st.input = null
2537
+ if (!pwd) return
2538
+ if (twice && pwd.length < BACKUP_PWD_MIN) { flash(st, i.bkPasswordShort, 'danger'); return }
2539
+ if (!twice) { await done(pwd); return }
2540
+ setInput(st, {
2541
+ label: i.repeatPassword,
2542
+ mask: true,
2543
+ onSubmit: async (again) => {
2544
+ st.input = null
2545
+ if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
2546
+ await done(pwd)
2547
+ },
2548
+ onCancel: () => { st.input = null }
2549
+ })
2550
+ },
2551
+ onCancel: () => { st.input = null }
2552
+ })
2553
+ }
2554
+
2555
+ function backupErr (e, st) {
2556
+ const i = L(st)
2557
+ return ({
2558
+ VAULT_LOCKED: i.errProfileLocked,
2559
+ NOT_A_BACKUP: i.bkNotABackup,
2560
+ BACKUP_CORRUPT: i.bkCorrupt,
2561
+ WRONG_PASSWORD: i.bkWrongPassword,
2562
+ BACKUP_CONFLICTS: i.bkConflictsAppeared
2563
+ })[e?.code] || humanErr(e, st)
2564
+ }
2565
+ /** `guard`, con los errores del respaldo dichos en su idioma. */
2566
+ async function backupGuard (term, st, msg, fn) {
2567
+ st.busy = msg
2568
+ render(term, st)
2569
+ try { const v = await fn(); st.busy = null; return { ok: true, v } } catch (e) { st.busy = null; flash(st, backupErr(e, st), 'danger'); return { ok: false, e } }
2570
+ }
2571
+
2572
+ async function onKeyBackup (term, st, key) {
2573
+ const i = L(st)
2574
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
2575
+ const b = st.backup
2576
+ if (key.name === 'escape' || ch === 'b' || !b) { st.screen = 'profiles'; st.backup = null; return true }
2577
+ const selectable = backupRows(st, makeTheme()).filter((r) => r.sel)
2578
+ if (['up', 'down', 'pageup', 'pagedown', 'home', 'end'].includes(key.name)) { moveSel(st, key, 'backup', selectable.length); return true }
2579
+ const cur = selectable[Math.min(st.sel.backup || 0, selectable.length - 1)]?.meta
2580
+ if ((key.name === 'enter' || (key.name === 'char' && key.ch === ' ')) && cur?.owner) {
2581
+ if (b.chosen.has(cur.owner)) b.chosen.delete(cur.owner)
2582
+ else b.chosen.add(cur.owner)
2583
+ return true
2584
+ }
2585
+ if (ch === 'g') {
2586
+ if (!b.drawers.length) { flash(st, i.bkNothing, 'warn'); return true }
2587
+ setInput(st, {
2588
+ label: i.bkSaveTo,
2589
+ hint: i.bkSaveToHint,
2590
+ value: defaultBackupPath(),
2591
+ file: true,
2592
+ save: true,
2593
+ onSubmit: async (raw) => {
2594
+ st.input = null
2595
+ const out = raw.trim()
2596
+ if (!out) return
2597
+ if (fs.existsSync(out)) { flash(st, i.bkExists(out), 'danger'); return }
2598
+ askBackupPassword(st, { twice: true }, async (pwd) => {
2599
+ const r = await backupGuard(term, st, i.bkExporting, () => vc.backupExport(b.chosen.size ? [...b.chosen] : null, pwd, activeId(st)))
2600
+ if (!r.ok) return
2601
+ try { fs.writeFileSync(out, JSON.stringify(r.v.file, null, 2) + '\n', { mode: 0o600 }) } catch (e) { flash(st, i.bkWriteFailed(e.message), 'danger'); return }
2602
+ const s = r.v.summary
2603
+ if (s.passwordsUnreadable) flash(st, i.bkSavedButMissing(out, s.passwordsUnreadable), 'warn')
2604
+ else flash(st, i.bkSaved(out, s.drawers.length))
2605
+ })
2606
+ },
2607
+ onCancel: () => { st.input = null }
2608
+ })
2609
+ return true
2610
+ }
2611
+ if (ch === 'i') {
2612
+ setInput(st, {
2613
+ label: i.bkOpenFile,
2614
+ hint: i.bkOpenFileHint,
2615
+ file: true,
2616
+ onSubmit: async (raw) => {
2617
+ st.input = null
2618
+ const where = raw.trim()
2619
+ if (!where) return
2620
+ let file
2621
+ try { file = JSON.parse(fs.readFileSync(where, 'utf8')) } catch (e) { flash(st, i.bkReadFailed(where), 'danger'); return }
2622
+ askBackupPassword(st, { twice: false }, async (pwd) => {
2623
+ const r = await backupGuard(term, st, i.bkPlanning, () => vc.backupPlan(file, pwd, {}, activeId(st)))
2624
+ if (!r.ok) return
2625
+ st.backupPlan = { path: where, file, pwd, plan: r.v, map: {}, decisions: {} }
2626
+ st.sel.backupplan = 0
2627
+ st.scroll.backupplan = { value: 0 }
2628
+ st.screen = 'backupplan'
2629
+ })
2630
+ },
2631
+ onCancel: () => { st.input = null }
2632
+ })
2633
+ return true
2634
+ }
2635
+ return true
2636
+ }
2637
+
2638
+ /** Rehace el plan (tras mover un cajón), conservando lo que ya se había decidido. */
2639
+ async function replanBackup (term, st) {
2640
+ const i = L(st)
2641
+ const bp = st.backupPlan
2642
+ const r = await backupGuard(term, st, i.bkPlanning, () => vc.backupPlan(bp.file, bp.pwd, { map: bp.map }, activeId(st)))
2643
+ if (!r.ok) return
2644
+ bp.plan = r.v
2645
+ const valid = new Set(conflictsOf(bp.plan))
2646
+ for (const k of Object.keys(bp.decisions)) if (!valid.has(k)) delete bp.decisions[k]
2647
+ }
2648
+
2649
+ async function onKeyBackupPlan (term, st, key) {
2650
+ const i = L(st)
2651
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
2652
+ const bp = st.backupPlan
2653
+ const leave = () => { st.backupPlan = null; st.screen = st.backup ? 'backup' : 'profiles' }
2654
+ if (key.name === 'escape' || ch === 'b' || !bp) { leave(); return true }
2655
+ const selectable = backupPlanRows(st, makeTheme()).filter((r) => r.sel)
2656
+ if (['up', 'down', 'pageup', 'pagedown', 'home', 'end'].includes(key.name)) { moveSel(st, key, 'backupplan', selectable.length); return true }
2657
+ const cur = selectable[Math.min(st.sel.backupplan || 0, selectable.length - 1)]?.meta
2658
+ const next = (d) => (d === 'keep' ? 'replace' : 'keep')
2659
+ if (key.name === 'enter' || (key.name === 'char' && key.ch === ' ')) {
2660
+ if (cur?.ref) bp.decisions[cur.ref] = next(bp.decisions[cur.ref])
2661
+ else if (cur?.all) {
2662
+ const refs = conflictsOf(bp.plan)
2663
+ const to = refs.every((r) => bp.decisions[r] === 'keep') ? 'replace' : 'keep'
2664
+ for (const r of refs) bp.decisions[r] = to
2665
+ }
2666
+ return true
2667
+ }
2668
+ if (ch === 'm' && cur?.drawer) {
2669
+ const from = cur.drawer
2670
+ if (from === 'passwords') { flash(st, i.bkCantMovePasswords, 'warn'); return true }
2671
+ setInput(st, {
2672
+ label: i.bkMoveTo(drawerTitle(i, from, null)),
2673
+ hint: i.bkMoveToHint,
2674
+ onSubmit: async (raw) => {
2675
+ st.input = null
2676
+ const w = raw.trim()
2677
+ if (!w) return
2678
+ let to
2679
+ if (/^[0-9A-F]{4}-[0-9A-F]{4}$/i.test(w)) {
2680
+ const m = (st.members || []).find((x) => x.id === w.toUpperCase())
2681
+ if (!m) { flash(st, i.bkNoSuchDevice(w), 'danger'); return }
2682
+ to = 'dev:' + m.pub
2683
+ } else if (NS_RE.test(w)) to = 'ns:' + w
2684
+ else { flash(st, i.bkBadTarget, 'danger'); return }
2685
+ if (to === from) delete bp.map[from]
2686
+ else bp.map[from] = to
2687
+ await replanBackup(term, st)
2688
+ },
2689
+ onCancel: () => { st.input = null }
2690
+ })
2691
+ return true
2692
+ }
2693
+ if (ch === 'g') {
2694
+ const undecided = conflictsOf(bp.plan).filter((r) => !bp.decisions[r])
2695
+ if (undecided.length) { flash(st, i.bkStillUndecided(undecided.length), 'warn'); return true }
2696
+ const writes = bp.plan.counts.new + Object.values(bp.decisions).filter((d) => d === 'replace').length
2697
+ if (!writes) { flash(st, i.bkNothingToImport, 'warn'); return true }
2698
+ setConfirm(st, {
2699
+ text: i.bkConfirm(writes),
2700
+ onYes: async () => {
2701
+ const r = await backupGuard(term, st, i.bkImporting, () => vc.backupImport(bp.file, bp.pwd, { map: bp.map, decisions: bp.decisions }, activeId(st)))
2702
+ if (!r.ok) return
2703
+ flash(st, i.bkImported(r.v.written, r.v.kept, r.v.keptPrivate?.length || 0))
2704
+ leave()
2705
+ await refreshAll(term, st)
2706
+ if (st.backup) await openBackup(term, st)
2707
+ },
2708
+ onNo: () => {}
2709
+ })
2710
+ return true
2711
+ }
2712
+ return true
2713
+ }
2714
+
2430
2715
  // --------------------------------- render ----------------------------------
2431
2716
 
2432
2717
  // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
@@ -2453,7 +2738,9 @@ const helpSegs = (i, screen, st = {}) => {
2453
2738
  join: i.helpJoin,
2454
2739
  me: i.helpMe,
2455
2740
  caps: i.helpCaps,
2456
- devvars: i.helpDevVars
2741
+ devvars: i.helpDevVars,
2742
+ backup: i.helpBackup,
2743
+ backupplan: i.helpBackupPlan
2457
2744
  }[screen] || []
2458
2745
  if (typeof segs !== 'function') return segs
2459
2746
  // El aparato señalado ahora mismo: `e variables` solo tiene sentido en un servicio (es
@@ -2488,7 +2775,9 @@ const title = (i, screen) => ({
2488
2775
  paircaps: i.titlePairCaps,
2489
2776
  join: i.titleJoin,
2490
2777
  caps: i.titleCaps,
2491
- devvars: i.titleDevVars
2778
+ devvars: i.titleDevVars,
2779
+ backup: i.titleBackup,
2780
+ backupplan: i.titleBackupPlan
2492
2781
  })[screen] || ''
2493
2782
 
2494
2783
  /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
@@ -2627,6 +2916,8 @@ function render (term, st) {
2627
2916
  else if (st.screen === 'doors') body = renderList(doorRows(st, t), st.sel.doors || 0, contentH, cols, t, scrollRef)
2628
2917
  else if (st.screen === 'settings') body = renderList(settingsRows(st, t), st.sel.settings || 0, contentH, cols, t, scrollRef)
2629
2918
  else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
2919
+ else if (st.screen === 'backup') body = renderList(backupRows(st, t), st.sel.backup || 0, contentH, cols, t, scrollRef)
2920
+ else if (st.screen === 'backupplan') body = renderList(backupPlanRows(st, t), st.sel.backupplan || 0, contentH, cols, t, scrollRef)
2630
2921
  else if (st.screen === 'pairing') {
2631
2922
  const pb = pairingBody(st, t, cols, contentH)
2632
2923
  body = scrollBody(pb, contentH, scrollRef)
@@ -2669,7 +2960,9 @@ const LIST_SCREENS = {
2669
2960
  pairmode: [pairModeRows, 'pairmode'],
2670
2961
  doors: [doorRows, 'doors'],
2671
2962
  settings: [settingsRows, 'settings'],
2672
- paircaps: [pairCapsRows, 'paircaps']
2963
+ paircaps: [pairCapsRows, 'paircaps'],
2964
+ backup: [backupRows, 'backup'],
2965
+ backupplan: [backupPlanRows, 'backupplan']
2673
2966
  }
2674
2967
 
2675
2968
  /**
@@ -2738,7 +3031,7 @@ export function viewModel (st, t) {
2738
3031
  else if (st.flash) vm.status = { text: st.flash.text, kind: st.flash.kind || 'ok' }
2739
3032
  if (st.input) {
2740
3033
  const inp = st.input
2741
- vm.input = { label: inp.label || '', hint: inp.hint || '', mask: !!inp.mask, value: inp.value || '', file: !!inp.file }
3034
+ vm.input = { label: inp.label || '', hint: inp.hint || '', mask: !!inp.mask, value: inp.value || '', file: !!inp.file, save: !!inp.save }
2742
3035
  } else if (st.confirm) vm.confirm = { text: st.confirm.text, defaultYes: !!st.confirm.defaultYes }
2743
3036
  else vm.help = helpSegs(i, st.screen, st)
2744
3037
  return vm
@@ -2951,6 +3244,8 @@ export async function handleKey (term, st, key) {
2951
3244
  if (st.screen === 'doors') return onKeyDoors(term, st, key)
2952
3245
  if (st.screen === 'settings') return onKeySettings(term, st, key)
2953
3246
  if (st.screen === 'paircaps') return onKeyPairCaps(term, st, key)
3247
+ if (st.screen === 'backup') return onKeyBackup(term, st, key)
3248
+ if (st.screen === 'backupplan') return onKeyBackupPlan(term, st, key)
2954
3249
  if (st.screen === 'pairing') return onKeyPairing(term, st, key)
2955
3250
  if (st.screen === 'join') return onKeyJoin(term, st, key)
2956
3251
  return true
@@ -3013,4 +3308,4 @@ export async function runTui () {
3013
3308
  }
3014
3309
 
3015
3310
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
3016
- export const __test = { checkForUpdate, offerUpdate, installUpdate, render, settingsRows, onKeySettings, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
3311
+ export const __test = { checkForUpdate, offerUpdate, installUpdate, render, settingsRows, onKeySettings, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, backupRows, backupPlanRows, onKeyBackup, onKeyBackupPlan, resetToque: () => { ultimoToque = 0 } }
package/src/tui/i18n.js CHANGED
@@ -384,6 +384,60 @@ const es = {
384
384
  // variables de UN aparato (Dispositivos → tecla e). Las del scope las comparten todos
385
385
  // los aparatos que sirven ese namespace; estas las lee solo él, y le ganan.
386
386
  titleDevVars: 'Variables del dispositivo',
387
+ // EL RESPALDO (`docs/respaldo.md`).
388
+ titleBackup: 'Respaldo',
389
+ titleBackupPlan: 'Importar un respaldo',
390
+ helpBackup: ['↑↓', 'Enter marcar', 'g exportar', 'i importar', 'Esc atrás', 'l English', 'q salir'],
391
+ helpBackupPlan: ['↑↓', 'Enter decidir', 'm mover cajón', 'g importar', 'Esc cancelar', 'l English', 'q salir'],
392
+ bkLoading: 'mirando qué hay…',
393
+ bkExportTitle: 'Qué cajones van en el respaldo',
394
+ bkNothing: 'No hay nada que respaldar todavía.',
395
+ bkPasswords: 'contraseñas',
396
+ bkProfile: '@me (tu perfil)',
397
+ bkDevice: (id, label) => `aparato ${id}${label ? ' (' + label + ')' : ''}`,
398
+ bkCount: (n, entries) => `${n} ${entries ? (n === 1 ? 'entrada' : 'entradas') : (n === 1 ? 'valor' : 'valores')}`,
399
+ bkAllChosen: 'Sin marcar ninguno, se exportan todos.',
400
+ bkSomeChosen: (n) => `Se exportan ${n === 1 ? 'el cajón marcado' : `los ${n} cajones marcados`}.`,
401
+ bkNoKeys: 'Las llaves no van: son de cada aparato. Al importar se vuelve a cerrar todo con las de la otra bóveda.',
402
+ bkSaveTo: 'Guardar el respaldo en',
403
+ bkSaveToHint: 'la ruta del archivo (Esc cancela)',
404
+ bkExists: (f) => `${f} ya existe: elige otro nombre`,
405
+ bkPassword: 'Contraseña del respaldo',
406
+ bkPasswordHint: 'al menos 12 caracteres; no se puede recuperar',
407
+ bkPasswordShort: 'Tiene que tener al menos 12 caracteres. Varias palabras al azar funcionan bien.',
408
+ bkExporting: 'exportando…',
409
+ bkWriteFailed: (m) => `No se pudo escribir el archivo: ${m}`,
410
+ bkSaved: (f, n) => `Respaldo guardado en ${f} (${n} ${n === 1 ? 'cajón' : 'cajones'})`,
411
+ bkSavedButMissing: (f, n) => `Guardado en ${f}, pero ${n} contraseña(s) no entraron: ábrelas una vez desde un aparato con «contraseñas» y repite`,
412
+ bkOpenFile: 'Archivo del respaldo',
413
+ bkOpenFileHint: 'la ruta del archivo (Esc cancela)',
414
+ bkReadFailed: (f) => `No se pudo leer ${f}`,
415
+ bkPlanning: 'abriendo el respaldo…',
416
+ bkNotABackup: 'Ese archivo no es un respaldo de la bóveda.',
417
+ bkCorrupt: 'El respaldo está dañado: no se puede leer.',
418
+ bkWrongPassword: 'Esa contraseña no abre este respaldo.',
419
+ bkConflictsAppeared: 'Apareció un choque nuevo (la bóveda cambió). No se importó nada; vuelve a abrir el respaldo.',
420
+ bkPlanFrom: (f) => `Lo que trae ${f}`,
421
+ bkAllConflicts: 'Todos los choques',
422
+ bkKeep: 'conservar lo de aquí',
423
+ bkReplace: 'reemplazar con el respaldo',
424
+ bkMixed: 'según cada uno',
425
+ bkUndecided: 'sin decidir',
426
+ bkNew: 'nuevo',
427
+ bkSame: 'igual',
428
+ bkConflict: 'CHOCA',
429
+ bkNoDevice: 'no entra: ese aparato no está en esta cuenta (m para moverlo)',
430
+ bkCounts: (n, s, c, k) => `${n} nuevo(s) · ${s} igual(es) · ${c} choque(s)${k ? ` · ${k} sin destino` : ''}`,
431
+ bkMoveTo: (from) => `¿A qué cajón va ${from}?`,
432
+ bkMoveToHint: 'el nombre de un scope, o el ID de un aparato (AB12-CD34)',
433
+ bkCantMovePasswords: 'Las contraseñas van a la bóveda de contraseñas: no se mueven.',
434
+ bkNoSuchDevice: (id) => `No hay ningún aparato ${id} en esta cuenta`,
435
+ bkBadTarget: 'Usa el nombre de un scope ([a-z0-9-]) o el ID de un aparato.',
436
+ bkStillUndecided: (n) => `Falta decidir ${n} choque(s): Enter en cada uno, o en «Todos los choques»`,
437
+ bkNothingToImport: 'No hay nada que importar.',
438
+ bkConfirm: (n) => `¿Importar ${n} cambio(s)?`,
439
+ bkImporting: 'importando…',
440
+ bkImported: (w, k, p) => `Importado: ${w} escrito(s), ${k} conservado(s)${p ? ` · ${p} se quedan privadas` : ''}`,
387
441
  devVarsFor: (id, name) => `Variables de ${id}${name ? ' · ' + name : ''}`,
388
442
  devVarsService: (cn) => `servicio «${cn}» — solo las lee este aparato, y pisan a las del scope`,
389
443
  noDevVars: '(sin variables propias — pulsa N para agregar la primera)',
@@ -447,7 +501,7 @@ const es = {
447
501
  // INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
448
502
  // language/quit); lo único que se traduce es la palabra que las explica.
449
503
  // Segmentos, no una línea: si no caben en una, el render los reparte en varias.
450
- helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 's ajustes', 'l English', 'q salir'],
504
+ helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'o respaldo', 'u desbloq', 'k bloquear', 's ajustes', 'l English', 'q salir'],
451
505
  // La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
452
506
  // valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
453
507
  // Anunciar teclas muertas confunde y además las quema para otros usos.
@@ -853,6 +907,59 @@ const en = {
853
907
 
854
908
  // variables de UN aparato (Dispositivos → tecla e)
855
909
  titleDevVars: 'Device variables',
910
+ titleBackup: 'Backup',
911
+ titleBackupPlan: 'Import a backup',
912
+ helpBackup: ['↑↓', 'Enter mark', 'g export', 'i import', 'Esc back', 'l Español', 'q quit'],
913
+ helpBackupPlan: ['↑↓', 'Enter decide', 'm move drawer', 'g import', 'Esc cancel', 'l Español', 'q quit'],
914
+ bkLoading: 'looking at what there is…',
915
+ bkExportTitle: 'Which drawers go in the backup',
916
+ bkNothing: 'There is nothing to back up yet.',
917
+ bkPasswords: 'passwords',
918
+ bkProfile: '@me (your profile)',
919
+ bkDevice: (id, label) => `device ${id}${label ? ' (' + label + ')' : ''}`,
920
+ bkCount: (n, entries) => `${n} ${entries ? (n === 1 ? 'entry' : 'entries') : (n === 1 ? 'value' : 'values')}`,
921
+ bkAllChosen: 'With none marked, all of them are exported.',
922
+ bkSomeChosen: (n) => `${n === 1 ? 'The marked drawer is' : `The ${n} marked drawers are`} exported.`,
923
+ bkNoKeys: 'Keys do not go: they belong to each device. On import everything is sealed again with the other vault\'s keys.',
924
+ bkSaveTo: 'Save the backup to',
925
+ bkSaveToHint: 'the file path (Esc cancels)',
926
+ bkExists: (f) => `${f} already exists: pick another name`,
927
+ bkPassword: 'Backup password',
928
+ bkPasswordHint: 'at least 12 characters; it cannot be recovered',
929
+ bkPasswordShort: 'It must be at least 12 characters. A few random words work well.',
930
+ bkExporting: 'exporting…',
931
+ bkWriteFailed: (m) => `Could not write the file: ${m}`,
932
+ bkSaved: (f, n) => `Backup saved to ${f} (${n} ${n === 1 ? 'drawer' : 'drawers'})`,
933
+ bkSavedButMissing: (f, n) => `Saved to ${f}, but ${n} password(s) did not go in: open them once from a device with «passwords» and repeat`,
934
+ bkOpenFile: 'Backup file',
935
+ bkOpenFileHint: 'the file path (Esc cancels)',
936
+ bkReadFailed: (f) => `Could not read ${f}`,
937
+ bkPlanning: 'opening the backup…',
938
+ bkNotABackup: 'That file is not a vault backup.',
939
+ bkCorrupt: 'The backup is damaged: it cannot be read.',
940
+ bkWrongPassword: 'That password does not open this backup.',
941
+ bkConflictsAppeared: 'A new conflict showed up (the vault changed). Nothing was imported; open the backup again.',
942
+ bkPlanFrom: (f) => `What ${f} brings`,
943
+ bkAllConflicts: 'All conflicts',
944
+ bkKeep: 'keep what is here',
945
+ bkReplace: 'replace with the backup',
946
+ bkMixed: 'one by one',
947
+ bkUndecided: 'undecided',
948
+ bkNew: 'new',
949
+ bkSame: 'same',
950
+ bkConflict: 'CONFLICT',
951
+ bkNoDevice: 'does not go in: that device is not in this account (m to move it)',
952
+ bkCounts: (n, s, c, k) => `${n} new · ${s} same · ${c} conflict(s)${k ? ` · ${k} without a destination` : ''}`,
953
+ bkMoveTo: (from) => `Which drawer does ${from} go to?`,
954
+ bkMoveToHint: 'a scope name, or a device ID (AB12-CD34)',
955
+ bkCantMovePasswords: 'Passwords go to the password vault: they are not moved.',
956
+ bkNoSuchDevice: (id) => `There is no device ${id} in this account`,
957
+ bkBadTarget: 'Use a scope name ([a-z0-9-]) or a device ID.',
958
+ bkStillUndecided: (n) => `${n} conflict(s) still undecided: Enter on each one, or on «All conflicts»`,
959
+ bkNothingToImport: 'There is nothing to import.',
960
+ bkConfirm: (n) => `Import ${n} change(s)?`,
961
+ bkImporting: 'importing…',
962
+ bkImported: (w, k, p) => `Imported: ${w} written, ${k} kept${p ? ` · ${p} stay private` : ''}`,
856
963
  devVarsFor: (id, name) => `Variables of ${id}${name ? ' · ' + name : ''}`,
857
964
  devVarsService: (cn) => `“${cn}” service — only this device reads them, and they override the scope ones`,
858
965
  noDevVars: '(no variables of its own — press N to add the first one)',
@@ -907,7 +1014,7 @@ const en = {
907
1014
  helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
908
1015
  helpConfirm: 'y confirm · n/Esc cancel',
909
1016
 
910
- helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 's settings', 'l Español', 'q quit'],
1017
+ helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'o backup', 'u unlock', 'k lock', 's settings', 'l Español', 'q quit'],
911
1018
  helpDevices: ({ pending, hasDevices, isService, drafts, curBlocked } = {}) => [
912
1019
  '←→ tab', '↑↓', 'p pair',
913
1020
  ...(drafts ? ['g save permissions'] : []),
@@ -629,6 +629,54 @@ export async function revealSecret (owner, key, profile, password) {
629
629
  return d.revealed.value
630
630
  }
631
631
 
632
+ // ---------------------------------------------------------------------------
633
+ // EL RESPALDO (`docs/respaldo.md`). Lo usan la CLI (`dotrino-vault backup`) y la TUI.
634
+ //
635
+ // Bóveda abierta, y la contraseña del RESPALDO (no la del perfil) viaja en la petición, que
636
+ // el daemon borra al leerla. Lo que vuelve nunca es un valor en claro: el archivo ya
637
+ // cifrado, o el plan con nombres y estados.
638
+ // ---------------------------------------------------------------------------
639
+
640
+ /**
641
+ * Una orden de respaldo y su respuesta. Se reconoce la nuestra por el `id` de la petición
642
+ * (`backup.id` o `secretError.id`), no por el del volcado: el error de otra orden no es el
643
+ * nuestro. Hasta dos minutos: exportar abre cada valor y deriva la contraseña.
644
+ */
645
+ export async function backupOp (req, profile, { timeoutMs = 120_000 } = {}) {
646
+ requireAlive()
647
+ rm(F.secretsList)
648
+ const id = await writeReq(F.secretReq, req, profile)
649
+ await writeReq(F.dumpReq, {}, profile)
650
+ signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
651
+ for (let i = 0; i < timeoutMs / 100; i++) {
652
+ await sleep(100)
653
+ const d = read(F.secretsList, null)
654
+ if (!d?.at) continue
655
+ rm(F.secretsList)
656
+ assertOpen(d)
657
+ if (d.backup?.id === id) return d.backup
658
+ if (d.secretError?.id === id) throw Object.assign(new Error(d.secretError.error), d.secretError)
659
+ }
660
+ throw coded('the daemon did not reply', 'NO_REPLY')
661
+ }
662
+
663
+ /** Los cajones que hay: `[{ owner, count, device? }]`. */
664
+ export async function backupDrawers (profile) {
665
+ return (await backupOp({ op: 'backup-drawers' }, profile)).drawers
666
+ }
667
+ /** Exporta (todo con `drawers = null`). Devuelve `{ file, summary }`; `file` ya va cifrado. */
668
+ export async function backupExport (drawers, backupPassword, profile) {
669
+ return backupOp({ op: 'backup-export', drawers: drawers || null, backupPassword }, profile)
670
+ }
671
+ /** El plan de importar: qué es nuevo, igual o choca. No escribe nada. */
672
+ export async function backupPlan (file, backupPassword, { drawers = null, map = {} } = {}, profile) {
673
+ return (await backupOp({ op: 'backup-plan', file, backupPassword, drawers, map }, profile)).plan
674
+ }
675
+ /** Importa. Con un choque sin decidir lanza `BACKUP_CONFLICTS` y no escribe nada. */
676
+ export async function backupImport (file, backupPassword, { drawers = null, map = {}, onConflict = null, decisions = {} } = {}, profile) {
677
+ return (await backupOp({ op: 'backup-import', file, backupPassword, drawers, map, onConflict, decisions }, profile)).result
678
+ }
679
+
632
680
  /** SALDA lo que quedó a deber (heredar lo viejo, rotar de verdad). Pide la contraseña. */
633
681
  export function settleSecrets (profile, password) {
634
682
  return secretOp({ op: 'settle', ...(password ? { password } : {}) }, profile, () => {})