@dotrino/vaultd 0.161.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/core/libVersion.js +1 -1
- package/package.json +1 -1
- package/src/ctl.js +8 -20
- package/src/tui/app.js +300 -5
- package/src/tui/i18n.js +109 -2
- package/src/vaultControl.js +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.162.0",
|
|
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
|
@@ -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
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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'] : []),
|
package/src/vaultControl.js
CHANGED
|
@@ -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, () => {})
|