ragevault 0.2.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/dist/assets.d.ts +14 -0
  4. package/dist/assets.js +47 -0
  5. package/dist/assets.js.map +1 -0
  6. package/dist/audit.d.ts +35 -0
  7. package/dist/audit.js +79 -0
  8. package/dist/audit.js.map +1 -0
  9. package/dist/branding.d.ts +36 -0
  10. package/dist/branding.js +55 -0
  11. package/dist/branding.js.map +1 -0
  12. package/dist/crypto/cipher.d.ts +74 -0
  13. package/dist/crypto/cipher.js +95 -0
  14. package/dist/crypto/cipher.js.map +1 -0
  15. package/dist/crypto/cipher.test.d.ts +7 -0
  16. package/dist/crypto/cipher.test.js +50 -0
  17. package/dist/crypto/cipher.test.js.map +1 -0
  18. package/dist/generator.d.ts +36 -0
  19. package/dist/generator.js +114 -0
  20. package/dist/generator.js.map +1 -0
  21. package/dist/model.d.ts +96 -0
  22. package/dist/model.js +43 -0
  23. package/dist/model.js.map +1 -0
  24. package/dist/porters.d.ts +35 -0
  25. package/dist/porters.js +175 -0
  26. package/dist/porters.js.map +1 -0
  27. package/dist/prompt.d.ts +14 -0
  28. package/dist/prompt.js +56 -0
  29. package/dist/prompt.js.map +1 -0
  30. package/dist/ragevault.d.ts +22 -0
  31. package/dist/ragevault.js +814 -0
  32. package/dist/ragevault.js.map +1 -0
  33. package/dist/server/public/app.js +420 -0
  34. package/dist/server/public/apple-touch-icon.png +0 -0
  35. package/dist/server/public/favicon.png +0 -0
  36. package/dist/server/public/fonts/archivo.woff2 +0 -0
  37. package/dist/server/public/fonts/space-grotesk.woff2 +0 -0
  38. package/dist/server/public/icon-192.png +0 -0
  39. package/dist/server/public/icon-512.png +0 -0
  40. package/dist/server/public/icon-maskable.png +0 -0
  41. package/dist/server/public/index.html +225 -0
  42. package/dist/server/public/manifest.webmanifest +16 -0
  43. package/dist/server/public/styles.css +362 -0
  44. package/dist/server/public/sw.js +30 -0
  45. package/dist/server/server.d.ts +30 -0
  46. package/dist/server/server.js +410 -0
  47. package/dist/server/server.js.map +1 -0
  48. package/dist/server/session.d.ts +36 -0
  49. package/dist/server/session.js +89 -0
  50. package/dist/server/session.js.map +1 -0
  51. package/dist/store/vaultfile.d.ts +64 -0
  52. package/dist/store/vaultfile.js +110 -0
  53. package/dist/store/vaultfile.js.map +1 -0
  54. package/dist/store/vaultfile.test.d.ts +5 -0
  55. package/dist/store/vaultfile.test.js +45 -0
  56. package/dist/store/vaultfile.test.js.map +1 -0
  57. package/dist/totp.d.ts +26 -0
  58. package/dist/totp.js +88 -0
  59. package/dist/totp.js.map +1 -0
  60. package/dist/totp.test.d.ts +5 -0
  61. package/dist/totp.test.js +41 -0
  62. package/dist/totp.test.js.map +1 -0
  63. package/dist/tui/ansi.d.ts +34 -0
  64. package/dist/tui/ansi.js +52 -0
  65. package/dist/tui/ansi.js.map +1 -0
  66. package/dist/tui/app.d.ts +83 -0
  67. package/dist/tui/app.js +607 -0
  68. package/dist/tui/app.js.map +1 -0
  69. package/dist/update/channel.d.ts +53 -0
  70. package/dist/update/channel.js +135 -0
  71. package/dist/update/channel.js.map +1 -0
  72. package/package.json +68 -0
@@ -0,0 +1,607 @@
1
+ /**
2
+ * RageVault terminal UI — a menu-driven, searchable vault browser.
3
+ *
4
+ * No shortcuts to memorise: you move with the arrow keys, **type to search**, and
5
+ * press Enter to open a **menu** of actions. Secrets stay blurred until you pick
6
+ * "Reveal". Passwords live in a folder tree (branches), each item shows an icon,
7
+ * and there's a built-in generator and one-key copy. Auto-locks after idle.
8
+ *
9
+ * Part of RageVault.
10
+ * Crafted by SoyRage Agency — https://soyrage.es/
11
+ * MIT licensed (see LICENSE).
12
+ */
13
+ import { ASCII_BANNER, BRAND } from "../branding.js";
14
+ import { color, ctl, padEnd, truncate, visLen } from "./ansi.js";
15
+ import { readVaultFile, saveVault, unlockVault } from "../store/vaultfile.js";
16
+ import { estimateStrength, generatePassphrase, generatePassword } from "../generator.js";
17
+ import { breachCount } from "../audit.js";
18
+ import { totp } from "../totp.js";
19
+ import { newId } from "../model.js";
20
+ const IDLE_LOCK_MS = 5 * 60 * 1000;
21
+ const UNFILED = "__unfiled__";
22
+ export class VaultTui {
23
+ path;
24
+ out;
25
+ inp;
26
+ mode = "unlock";
27
+ vault = null;
28
+ pwBuffer = "";
29
+ unlockError = "";
30
+ search = "";
31
+ sel = 0;
32
+ expanded = new Set();
33
+ revealed = new Set();
34
+ menu = null;
35
+ prompt = null;
36
+ overlay = "";
37
+ status = "";
38
+ lastActivity = Date.now();
39
+ timer = null;
40
+ clearTimer = null;
41
+ /** Breach-check cache: password → count (-2 checking, -1 unknown/offline). */
42
+ breach = new Map();
43
+ breachTimer = null;
44
+ onData = (k) => this.onKey(k);
45
+ constructor(path, out = process.stdout, inp = process.stdin) {
46
+ this.path = path;
47
+ this.out = out;
48
+ this.inp = inp;
49
+ }
50
+ start() {
51
+ this.out.write(ctl.enterAlt + ctl.hideCursor + ctl.clear);
52
+ if (this.inp.isTTY)
53
+ this.inp.setRawMode(true);
54
+ this.inp.resume();
55
+ this.inp.setEncoding("utf8");
56
+ this.inp.on("data", this.onData);
57
+ this.out.on("resize", () => this.render());
58
+ this.timer = setInterval(() => {
59
+ if (this.mode === "main" && Date.now() - this.lastActivity > IDLE_LOCK_MS)
60
+ this.lock("Auto-locked (idle).");
61
+ }, 5000);
62
+ if (typeof this.timer.unref === "function")
63
+ this.timer.unref();
64
+ this.render();
65
+ }
66
+ quit() {
67
+ if (this.timer)
68
+ clearInterval(this.timer);
69
+ if (this.clearTimer) {
70
+ clearTimeout(this.clearTimer);
71
+ this.out.write("\x1b]52;c;\x07");
72
+ }
73
+ this.out.write(ctl.showCursor + ctl.exitAlt);
74
+ this.out.write(`\n Locked. Thanks for using ${color.accent(BRAND.product)} — ${color.brightBlue(BRAND.url)}\n\n`);
75
+ process.exit(0);
76
+ }
77
+ lock(reason) {
78
+ this.vault = null;
79
+ this.revealed.clear();
80
+ this.mode = "unlock";
81
+ this.pwBuffer = "";
82
+ this.menu = null;
83
+ this.prompt = null;
84
+ this.overlay = "";
85
+ this.unlockError = reason;
86
+ this.render();
87
+ }
88
+ save() { if (this.vault)
89
+ saveVault(this.path, this.vault); }
90
+ // ---- input routing ------------------------------------------------------
91
+ onKey(key) {
92
+ if (key === "\x03")
93
+ return this.quit();
94
+ this.lastActivity = Date.now();
95
+ if (this.overlay) {
96
+ this.overlay = "";
97
+ return this.render();
98
+ }
99
+ if (this.mode === "unlock")
100
+ return this.onUnlockKey(key);
101
+ if (this.prompt)
102
+ return this.onPromptKey(key);
103
+ if (this.menu)
104
+ return this.onMenuKey(key);
105
+ return this.onBrowseKey(key);
106
+ }
107
+ onUnlockKey(key) {
108
+ if (key === "\r" || key === "\n")
109
+ return this.tryUnlock();
110
+ if (key === "\x7f" || key === "\b") {
111
+ this.pwBuffer = this.pwBuffer.slice(0, -1);
112
+ return this.render();
113
+ }
114
+ if (key >= " " && key.length === 1) {
115
+ this.pwBuffer += key;
116
+ this.render();
117
+ }
118
+ }
119
+ tryUnlock() {
120
+ const pw = this.pwBuffer;
121
+ this.pwBuffer = "";
122
+ try {
123
+ this.vault = unlockVault(readVaultFile(this.path), pw);
124
+ this.vault.data.folders ??= [];
125
+ this.vault.data.trash ??= [];
126
+ for (const f of this.vault.data.folders)
127
+ if (!f.parentId)
128
+ this.expanded.add(f.id);
129
+ this.expanded.add(UNFILED);
130
+ this.mode = "main";
131
+ this.sel = 0;
132
+ this.unlockError = "";
133
+ }
134
+ catch (e) {
135
+ this.unlockError = e.message;
136
+ }
137
+ this.render();
138
+ this.scheduleBreach();
139
+ }
140
+ onPromptKey(key) {
141
+ const p = this.prompt;
142
+ if (key === "\x1b") {
143
+ this.prompt = null;
144
+ return this.render();
145
+ }
146
+ if (key === "\r" || key === "\n") {
147
+ this.prompt = null;
148
+ p.onSubmit(p.value.trim());
149
+ return;
150
+ }
151
+ if (key === "\x7f" || key === "\b")
152
+ p.value = p.value.slice(0, -1);
153
+ else if (key >= " " && key.length === 1)
154
+ p.value += key;
155
+ this.render();
156
+ }
157
+ onMenuKey(key) {
158
+ const m = this.menu;
159
+ if (key === "\x1b" || key === "q") {
160
+ this.menu = null;
161
+ return this.render();
162
+ }
163
+ if (key === "\x1b[A" || key === "k") {
164
+ m.index = (m.index - 1 + m.items.length) % m.items.length;
165
+ return this.render();
166
+ }
167
+ if (key === "\x1b[B" || key === "j") {
168
+ m.index = (m.index + 1) % m.items.length;
169
+ return this.render();
170
+ }
171
+ if (key === "\r" || key === "\n") {
172
+ const it = m.items[m.index];
173
+ this.menu = null;
174
+ it.run();
175
+ return;
176
+ }
177
+ }
178
+ onBrowseKey(key) {
179
+ const rows = this.rows();
180
+ switch (key) {
181
+ case "\x1b":
182
+ if (this.search) {
183
+ this.search = "";
184
+ this.sel = 0;
185
+ return this.render();
186
+ }
187
+ return this.openMainMenu();
188
+ case "\x1b[A":
189
+ case "\x10":
190
+ this.move(rows, -1);
191
+ return;
192
+ case "\x1b[B":
193
+ case "\x0e":
194
+ this.move(rows, 1);
195
+ return;
196
+ case "\r":
197
+ case "\n": return this.activate(rows[this.sel]);
198
+ case "\x7f":
199
+ case "\b":
200
+ if (this.search) {
201
+ this.search = this.search.slice(0, -1);
202
+ this.sel = 0;
203
+ this.render();
204
+ this.scheduleBreach();
205
+ }
206
+ return;
207
+ default:
208
+ if (key >= " " && key.length === 1) {
209
+ this.search += key;
210
+ this.sel = 0;
211
+ this.render();
212
+ this.scheduleBreach();
213
+ }
214
+ }
215
+ }
216
+ move(rows, d) {
217
+ if (!rows.length)
218
+ return;
219
+ this.sel = (this.sel + d + rows.length) % rows.length;
220
+ this.render();
221
+ this.scheduleBreach();
222
+ }
223
+ activate(row) {
224
+ if (!row)
225
+ return;
226
+ if (row.kind === "folder") {
227
+ const id = row.folder.id;
228
+ this.expanded.has(id) ? this.expanded.delete(id) : this.expanded.add(id);
229
+ return this.render();
230
+ }
231
+ this.openItemMenu(row.item);
232
+ }
233
+ // ---- menus --------------------------------------------------------------
234
+ openMainMenu() {
235
+ this.menu = {
236
+ title: "Menu", index: 0, items: [
237
+ { label: "+ New item", run: () => this.newItem() },
238
+ { label: "📁 New folder", run: () => this.newFolder() },
239
+ { label: "🎲 Generate a password", run: () => this.generate() },
240
+ { label: "🔒 Lock the vault", run: () => this.lock("Locked.") },
241
+ { label: "✕ Quit", run: () => this.quit() },
242
+ ],
243
+ };
244
+ this.render();
245
+ }
246
+ openItemMenu(item) {
247
+ const shown = this.revealed.has(item.id);
248
+ const items = [
249
+ { label: shown ? "🙈 Hide password" : "👁 Reveal password", run: () => { shown ? this.revealed.delete(item.id) : this.revealed.add(item.id); this.render(); } },
250
+ { label: "📋 Copy password", run: () => { if (item.password) {
251
+ this.copy(item.password);
252
+ this.flash("Password copied (clears in 20s)");
253
+ } } },
254
+ ];
255
+ if (item.username)
256
+ items.push({ label: "👤 Copy username", run: () => { this.copy(item.username); this.flash("Username copied"); } });
257
+ if (item.totp)
258
+ items.push({ label: "🔑 Copy 2FA code", run: () => { try {
259
+ const { code, remaining } = totp(item.totp);
260
+ this.copy(code);
261
+ this.flash(`2FA ${code} copied (${remaining}s)`);
262
+ }
263
+ catch {
264
+ this.flash("bad 2FA secret");
265
+ } } });
266
+ items.push({ label: "✏️ Rename", run: () => this.rename(item) }, { label: "🎲 Regenerate password", run: () => this.regenerate(item) }, { label: "📂 Move to folder…", run: () => this.openMoveMenu(item) }, { label: "🗑 Delete", danger: true, run: () => this.confirmDelete(item) }, { label: "← Close", run: () => { } });
267
+ this.menu = { title: item.title, items, index: 0 };
268
+ this.render();
269
+ }
270
+ openMoveMenu(item) {
271
+ const folders = this.vault.data.folders;
272
+ const items = [{ label: "* General (no folder)", run: () => { item.folderId = undefined; item.updatedAt = new Date().toISOString(); this.save(); this.flash("Moved to General"); } }];
273
+ for (const f of folders)
274
+ items.push({ label: `📁 ${f.name}`, run: () => { item.folderId = f.id; item.updatedAt = new Date().toISOString(); this.save(); this.flash(`Moved to ${f.name}`); } });
275
+ items.push({ label: "← Cancel", run: () => { } });
276
+ this.menu = { title: `Move “${item.title}”`, items, index: 0 };
277
+ this.render();
278
+ }
279
+ confirmDelete(item) {
280
+ this.menu = {
281
+ title: `Delete “${item.title}”?`, index: 1, items: [
282
+ { label: "🗑 Yes, move to trash", danger: true, run: () => { const v = this.vault.data; v.items = v.items.filter((i) => i.id !== item.id); (v.trash ??= []).unshift(item); this.save(); this.sel = Math.max(0, this.sel - 1); this.flash("Moved to trash"); } },
283
+ { label: "← No, keep it", run: () => { } },
284
+ ],
285
+ };
286
+ this.render();
287
+ }
288
+ // ---- actions ------------------------------------------------------------
289
+ currentFolderId() {
290
+ const row = this.rows()[this.sel];
291
+ if (!row)
292
+ return undefined;
293
+ if (row.kind === "folder")
294
+ return row.folder.id === UNFILED ? undefined : row.folder.id;
295
+ return row.item.folderId;
296
+ }
297
+ newItem() {
298
+ this.prompt = { label: "New item — title", value: "", hint: "a strong password is generated for you", onSubmit: (title) => {
299
+ if (!title)
300
+ return this.render();
301
+ const now = new Date().toISOString();
302
+ const item = { id: newId(), title, password: generatePassword({ length: 20 }), folderId: this.currentFolderId(), tags: [], favorite: false, createdAt: now, updatedAt: now, history: [] };
303
+ this.vault.data.items.push(item);
304
+ this.save();
305
+ this.flash(`Added “${title}” with a generated password`);
306
+ this.render();
307
+ } };
308
+ this.render();
309
+ }
310
+ newFolder() {
311
+ this.prompt = { label: "New folder — name", value: "", onSubmit: (name) => {
312
+ if (!name)
313
+ return this.render();
314
+ const parentId = this.currentFolderId();
315
+ const f = { id: newId(), name, parentId };
316
+ this.vault.data.folders.push(f);
317
+ this.expanded.add(f.id);
318
+ this.save();
319
+ this.flash(`Created folder “${name}”`);
320
+ this.render();
321
+ } };
322
+ this.render();
323
+ }
324
+ rename(item) {
325
+ this.prompt = { label: "Rename to", value: item.title, onSubmit: (v) => { if (v) {
326
+ item.title = v;
327
+ item.updatedAt = new Date().toISOString();
328
+ this.save();
329
+ this.flash("Renamed");
330
+ } this.render(); } };
331
+ this.render();
332
+ }
333
+ regenerate(item) {
334
+ if (item.password)
335
+ item.history.unshift({ password: item.password, changedAt: new Date().toISOString() });
336
+ item.password = generatePassword({ length: 20 });
337
+ item.updatedAt = new Date().toISOString();
338
+ this.revealed.add(item.id);
339
+ this.save();
340
+ const s = estimateStrength(item.password);
341
+ this.flash(`New password · ${s.bits} bits · ${s.label}`);
342
+ }
343
+ generate() {
344
+ const pw = generatePassword({ length: 20 });
345
+ const s = estimateStrength(pw);
346
+ this.copy(pw);
347
+ this.overlay = `${color.bold("Generated password")}\n\n ${color.accent(pw)}\n\n ${color.dim(`${s.bits} bits · ${s.label} · copied to clipboard`)}\n\n ${color.dim("Passphrase:")} ${color.accent(generatePassphrase(4))}`;
348
+ this.render();
349
+ }
350
+ flash(msg) { this.status = msg; this.render(); }
351
+ copy(text) {
352
+ this.out.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
353
+ if (this.clearTimer)
354
+ clearTimeout(this.clearTimer);
355
+ this.clearTimer = setTimeout(() => { this.out.write("\x1b]52;c;\x07"); if (this.mode === "main") {
356
+ this.status = color.gray("Clipboard cleared");
357
+ this.render();
358
+ } }, 20000);
359
+ if (typeof this.clearTimer.unref === "function")
360
+ this.clearTimer.unref();
361
+ }
362
+ // ---- password safety (strength + HIBP breach check) ---------------------
363
+ isRisky(it) {
364
+ if (!it.password)
365
+ return false;
366
+ if (estimateStrength(it.password).bits < 50)
367
+ return true;
368
+ return (this.breach.get(it.password) ?? 0) > 0;
369
+ }
370
+ breachStatus(it) {
371
+ if (!it.password)
372
+ return color.gray("—");
373
+ if (process.env.RAGEVAULT_NO_BREACH_CHECK)
374
+ return color.gray("check disabled");
375
+ const n = this.breach.get(it.password);
376
+ if (n === undefined)
377
+ return color.dim("…");
378
+ if (n === -2)
379
+ return color.dim("checking HIBP…");
380
+ if (n === -1)
381
+ return color.gray("unavailable (offline)");
382
+ if (n === 0)
383
+ return color.green("✓ not in known breaches");
384
+ return color.red(`⚠ found in ${n.toLocaleString()} breaches — regenerate!`);
385
+ }
386
+ /** Debounced HIBP check for the selected item (k-anonymity — only a hash prefix leaves). */
387
+ scheduleBreach() {
388
+ if (process.env.RAGEVAULT_NO_BREACH_CHECK)
389
+ return;
390
+ if (this.breachTimer)
391
+ clearTimeout(this.breachTimer);
392
+ this.breachTimer = setTimeout(() => {
393
+ const row = this.rows()[this.sel];
394
+ const it = row?.item;
395
+ if (!it?.password || this.breach.has(it.password))
396
+ return;
397
+ const pw = it.password;
398
+ this.breach.set(pw, -2);
399
+ this.render();
400
+ void breachCount(pw).then((n) => { this.breach.set(pw, n); if (this.mode === "main")
401
+ this.render(); }).catch(() => this.breach.set(pw, -1));
402
+ }, 350);
403
+ if (typeof this.breachTimer.unref === "function")
404
+ this.breachTimer.unref();
405
+ }
406
+ // ---- tree ---------------------------------------------------------------
407
+ matches(i) {
408
+ const f = this.search.trim().toLowerCase();
409
+ return !f || i.title.toLowerCase().includes(f) || (i.username ?? "").toLowerCase().includes(f) || (i.url ?? "").toLowerCase().includes(f);
410
+ }
411
+ rows() {
412
+ const v = this.vault?.data;
413
+ if (!v)
414
+ return [];
415
+ const rows = [];
416
+ // When searching, show a flat list of matches (no folder chrome).
417
+ if (this.search.trim()) {
418
+ for (const i of v.items.filter((x) => this.matches(x)).sort((a, b) => a.title.localeCompare(b.title)))
419
+ rows.push({ kind: "item", depth: 0, last: false, item: i });
420
+ return rows;
421
+ }
422
+ const childFolders = (pid) => v.folders.filter((f) => (f.parentId ?? undefined) === pid).sort((a, b) => a.name.localeCompare(b.name));
423
+ const itemsIn = (fid) => v.items.filter((i) => (i.folderId ?? UNFILED) === fid).sort((a, b) => Number(b.favorite) - Number(a.favorite) || a.title.localeCompare(b.title));
424
+ const walk = (folder, depth) => {
425
+ rows.push({ kind: "folder", depth, last: false, folder });
426
+ if (!this.expanded.has(folder.id))
427
+ return;
428
+ for (const c of childFolders(folder.id))
429
+ walk(c, depth + 1);
430
+ const its = itemsIn(folder.id);
431
+ its.forEach((i, k) => rows.push({ kind: "item", depth: depth + 1, last: k === its.length - 1, item: i }));
432
+ };
433
+ for (const root of childFolders(undefined))
434
+ walk(root, 0);
435
+ // Unfiled items under a synthetic "General" branch.
436
+ const unfiled = itemsIn(UNFILED);
437
+ if (unfiled.length || !v.folders.length) {
438
+ rows.push({ kind: "folder", depth: 0, last: false, folder: { id: UNFILED, name: "General" } });
439
+ if (this.expanded.has(UNFILED))
440
+ unfiled.forEach((i, k) => rows.push({ kind: "item", depth: 1, last: k === unfiled.length - 1, item: i }));
441
+ }
442
+ return rows;
443
+ }
444
+ // ---- render -------------------------------------------------------------
445
+ cols() { return this.out.columns && this.out.columns > 40 ? this.out.columns : 92; }
446
+ rows_() { return this.out.rows && this.out.rows > 12 ? this.out.rows : 28; }
447
+ render() {
448
+ const lines = this.mode === "unlock" ? this.unlockLines() : this.mainLines();
449
+ if (this.menu)
450
+ this.overlayBox(lines, this.menuText(), "☰ " + this.menu.title);
451
+ if (this.prompt)
452
+ this.overlayBox(lines, this.promptText(), "✎");
453
+ if (this.overlay)
454
+ this.overlayBox(lines, this.overlay, "");
455
+ const cols = this.cols();
456
+ let frame = ctl.home;
457
+ for (let i = 0; i < lines.length; i++) {
458
+ frame += truncate(lines[i] ?? "", cols) + ctl.clearLine;
459
+ if (i < lines.length - 1)
460
+ frame += "\n";
461
+ }
462
+ frame += ctl.clearBelow;
463
+ this.out.write(frame);
464
+ }
465
+ unlockLines() {
466
+ const cols = this.cols(), rows = this.rows_();
467
+ const center = (s) => " ".repeat(Math.max(0, Math.floor((cols - visLen(s)) / 2))) + s;
468
+ const lines = [];
469
+ const banner = ASCII_BANNER.split("\n");
470
+ for (let i = 0; i < Math.max(1, Math.floor((rows - banner.length - 7) / 2)); i++)
471
+ lines.push("");
472
+ for (const b of banner)
473
+ lines.push(center(color.accent(b)));
474
+ lines.push("", center(color.bold("Enter your master password to unlock")), "");
475
+ lines.push(center(`${color.accent("❯")} ${"•".repeat(Math.min(32, this.pwBuffer.length))}${color.dim("▏")}`), "");
476
+ if (this.unlockError)
477
+ lines.push(center(color.red("✗ " + this.unlockError)), "");
478
+ lines.push(center(color.dim("Enter to unlock · Ctrl-C to quit · keep this behind your VPN")));
479
+ return lines;
480
+ }
481
+ mainLines() {
482
+ const cols = this.cols(), rows = this.rows_();
483
+ const list = this.rows();
484
+ if (this.sel >= list.length)
485
+ this.sel = Math.max(0, list.length - 1);
486
+ const lines = [];
487
+ const brand = `${color.accent(color.bold("SOYRAGE"))} ${color.gray("▸")} ${color.bold(BRAND.product)} ${color.gray("·")} ${this.vault.data.meta.name}`;
488
+ const count = `${color.gray(this.vault.data.items.length + " items")} ${color.green("● unlocked")}`;
489
+ lines.push(" " + brand + " ".repeat(Math.max(1, cols - 2 - visLen(brand) - visLen(count))) + count);
490
+ const sb = this.search ? `${color.bold(this.search)}${color.dim("▏")}` : color.dim("type to search…");
491
+ const hint = color.dim("Enter = open · Esc = menu");
492
+ lines.push(` ${color.accent("⌕")} ${sb}${" ".repeat(Math.max(1, cols - 4 - visLen(sb) - visLen(hint)))}${hint}`);
493
+ lines.push(" " + color.gray("─".repeat(cols - 2)));
494
+ // Two panes: the tree on the left, live detail for the selection on the right.
495
+ const bodyH = Math.max(4, rows - 5);
496
+ const leftW = Math.max(28, Math.floor(cols * 0.52));
497
+ const rightW = Math.max(20, cols - leftW - 3);
498
+ const start = Math.max(0, Math.min(this.sel - Math.floor(bodyH / 2), Math.max(0, list.length - bodyH)));
499
+ const detail = this.detailRows(list[this.sel], rightW, bodyH);
500
+ for (let i = 0; i < bodyH; i++) {
501
+ const row = list[start + i];
502
+ const left = row ? this.rowText(row, start + i === this.sel, leftW) : " ".repeat(leftW);
503
+ lines.push(` ${left} ${color.gray("│")} ${detail[i] ?? ""}`);
504
+ }
505
+ lines.push(" " + color.gray("─".repeat(cols - 2)));
506
+ const foot = this.status ? color.green(this.status) : color.gray("↑/↓ move · type to search · Enter opens a menu · Esc for the main menu");
507
+ lines.push(" " + truncate(foot, cols - 2));
508
+ return lines;
509
+ }
510
+ rowText(row, active, width) {
511
+ const indent = " ".repeat(row.depth);
512
+ let text;
513
+ if (row.kind === "folder") {
514
+ const open = this.expanded.has(row.folder.id);
515
+ const caret = color.gray(open ? "▾" : "▸");
516
+ const glyph = row.folder.icon || "📁";
517
+ text = `${indent}${caret} ${glyph} ${color.bold(color.accent(row.folder.name))}`;
518
+ }
519
+ else {
520
+ const it = row.item;
521
+ const branch = color.gray(row.last ? "└─" : "├─");
522
+ const icon = this.itemIcon(it);
523
+ const twofa = it.totp ? color.gray(" ·2FA") : "";
524
+ const bad = this.isRisky(it) ? color.red(" ⚠") : "";
525
+ text = `${indent}${branch} ${icon} ${it.title}${twofa}${bad}`;
526
+ }
527
+ const t = padEnd(truncate(text, width), width);
528
+ return active ? color.bgAccent(t) : t;
529
+ }
530
+ /** The live detail pane for whatever is selected. */
531
+ detailRows(sel, width, height) {
532
+ const field = (k, v) => `${color.gray(padEnd(k, 9))} ${v}`;
533
+ let out = [];
534
+ if (!sel)
535
+ out = [color.gray("Empty vault.")];
536
+ else if (sel.kind === "folder") {
537
+ const f = sel.folder;
538
+ const n = this.vault.data.items.filter((i) => (i.folderId ?? UNFILED) === f.id).length;
539
+ out = [`${f.icon || "📁"} ${color.bold(color.accent(f.name))}`, "", field("Items", String(n)), "", color.dim("Enter to " + (this.expanded.has(f.id) ? "collapse" : "expand") + " this folder.")];
540
+ }
541
+ else {
542
+ const it = sel.item;
543
+ const revealed = this.revealed.has(it.id);
544
+ const s = it.password ? estimateStrength(it.password) : null;
545
+ out = [
546
+ `${this.itemIcon(it)} ${color.bold(it.title)}`,
547
+ "",
548
+ field("User", it.username ?? color.gray("—")),
549
+ field("URL", it.url ?? color.gray("—")),
550
+ field("Folder", this.vault.data.folders.find((f) => f.id === it.folderId)?.name ?? color.gray("General")),
551
+ "",
552
+ field("Password", revealed ? color.accent(it.password ?? "") : color.dim("•••••••• ") + color.gray("(reveal in menu)")),
553
+ ];
554
+ if (s)
555
+ out.push(field("Strength", `${s.bits < 50 ? color.red(s.bits + " bits · " + s.label) : color.green(s.bits + " bits · " + s.label)}`));
556
+ out.push(field("Breach", this.breachStatus(it)));
557
+ if (it.totp) {
558
+ try {
559
+ const { code, remaining } = totp(it.totp);
560
+ out.push(field("2FA", `${color.accent(code)} ${color.gray("(" + remaining + "s)")}`));
561
+ }
562
+ catch {
563
+ out.push(field("2FA", color.red("bad secret")));
564
+ }
565
+ }
566
+ for (const f of it.fields ?? [])
567
+ out.push(field(f.label.slice(0, 8), f.secret && !revealed ? color.dim("••••••") : f.value));
568
+ if (this.isRisky(it))
569
+ out.push("", color.red("⚠ Weak or breached — open the menu and pick"), color.red(" “Regenerate password”."));
570
+ out.push("", color.dim("Enter → actions menu"));
571
+ }
572
+ return out.slice(0, height).map((r) => truncate(r, width)).concat(Array(Math.max(0, height - out.length)).fill(""));
573
+ }
574
+ itemIcon(it) {
575
+ if (it.icon)
576
+ return it.icon;
577
+ const ch = (it.title.trim()[0] || "•").toUpperCase();
578
+ return color.accent(ch);
579
+ }
580
+ menuText() {
581
+ return this.menu.items.map((it, i) => {
582
+ const label = it.danger ? color.red(it.label) : it.label;
583
+ return i === this.menu.index ? color.bgAccent(" " + padEnd(it.label, 26) + " ") : " " + label;
584
+ }).join("\n");
585
+ }
586
+ promptText() {
587
+ const p = this.prompt;
588
+ return `${color.bold(p.label)}\n\n ${color.accent("❯")} ${color.bold(p.value)}${color.dim("▏")}\n${p.hint ? "\n " + color.dim(p.hint) : ""}\n ${color.dim("Enter to confirm · Esc to cancel")}`;
589
+ }
590
+ overlayBox(lines, body, title) {
591
+ const cols = this.cols(), rows = this.rows_();
592
+ const raw = (title ? [color.accent(color.bold(title)), color.gray("─".repeat(Math.max(6, visLen(title) + 2))), ""] : []).concat(body.split("\n"));
593
+ const w = Math.min(cols - 6, Math.max(28, ...raw.map(visLen)) + 4);
594
+ const box = [color.gray("╭" + "─".repeat(w) + "╮")];
595
+ for (const r of raw)
596
+ box.push(color.gray("│") + " " + padEnd(r, w - 2) + " " + color.gray("│"));
597
+ box.push(color.gray("╰" + "─".repeat(w) + "╯"));
598
+ if (this.overlay)
599
+ box.push(color.gray(" press any key to close"));
600
+ const top = Math.max(0, Math.floor((rows - box.length) / 2));
601
+ const left = Math.max(0, Math.floor((cols - w) / 2));
602
+ for (let i = 0; i < box.length; i++)
603
+ if (top + i < lines.length)
604
+ lines[top + i] = " ".repeat(left) + box[i];
605
+ }
606
+ }
607
+ //# sourceMappingURL=app.js.map