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,814 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `ragevault` — the command-line front door to your self-hosted vault.
4
+ *
5
+ * ragevault init create an encrypted vault (asks a master key)
6
+ * ragevault add add a secret (generate or type the password)
7
+ * ragevault ls [query] list items — secrets stay blurred
8
+ * ragevault get <query> [--reveal|--copy]
9
+ * ragevault gen [--length N | --words N | --pronounceable]
10
+ * ragevault passwd change the master password (fast re-wrap)
11
+ * ragevault users [add <name> <role>]
12
+ * ragevault wg set up WireGuard for remote access (optional)
13
+ * ragevault version
14
+ *
15
+ * Secrets are shown blurred (••••••) by default; you ask to reveal or copy them,
16
+ * so a shoulder-surfer never sees every password at a glance.
17
+ *
18
+ * Part of RageVault.
19
+ * Crafted by SoyRage Agency — https://soyrage.es/
20
+ * MIT licensed (see LICENSE).
21
+ */
22
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { basename, join } from "node:path";
25
+ import { spawn } from "node:child_process";
26
+ import { fileURLToPath } from "node:url";
27
+ import { dirname, resolve } from "node:path";
28
+ import { ASCII_BANNER, BRAND } from "./branding.js";
29
+ import { ask, askSecret, confirm } from "./prompt.js";
30
+ import { canDo, emptyVault, newId, ROLE_RANK } from "./model.js";
31
+ import { estimateStrength, generatePassphrase, generatePassword, generatePronounceable } from "./generator.js";
32
+ import { createVault, readVaultFile, rewrapForPassword, saveVault, unlockVault } from "./store/vaultfile.js";
33
+ import { startServer } from "./server/server.js";
34
+ import { extractTotpSecret, totp } from "./totp.js";
35
+ import { auditVault, breachCount } from "./audit.js";
36
+ import { exportCsv, exportJson, importAny } from "./porters.js";
37
+ import { updateNoticeLine } from "./update/channel.js";
38
+ const A = "\x1b[38;5;39m", B = "\x1b[1m", D = "\x1b[2m", G = "\x1b[32m", Y = "\x1b[33m", R0 = "\x1b[0m", RED = "\x1b[31m";
39
+ const say = (s = "") => process.stdout.write(s + "\n");
40
+ const err = (s) => process.stderr.write(s + "\n");
41
+ function vaultPath() {
42
+ const base = process.env.RAGEVAULT_HOME?.trim() || join(homedir(), ".ragevault");
43
+ return join(base, "vault.ragevault");
44
+ }
45
+ /** Blur a secret, keeping length obscured. */
46
+ function blur() {
47
+ return `${D}••••••••${R0}`;
48
+ }
49
+ /** Copy text to the clipboard via OSC 52 (works over SSH). Only to a real
50
+ * terminal — never emit the (base64) secret when stdout is piped to a file/log. */
51
+ function clip(text) {
52
+ if (!process.stdout.isTTY)
53
+ return false;
54
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
55
+ return true;
56
+ }
57
+ async function readMaster(prompt = "Master password:") {
58
+ const pw = await askSecret(prompt);
59
+ if (!pw) {
60
+ err(`${RED}✗ No password entered.${R0}`);
61
+ process.exit(1);
62
+ }
63
+ return pw;
64
+ }
65
+ async function openExisting() {
66
+ const path = vaultPath();
67
+ if (!existsSync(path)) {
68
+ err(`${Y}No vault yet.${R0} Create one with ${B}ragevault init${R0}.`);
69
+ process.exit(1);
70
+ }
71
+ const file = readVaultFile(path);
72
+ const pw = await readMaster();
73
+ try {
74
+ return { vault: unlockVault(file, pw), path };
75
+ }
76
+ catch (e) {
77
+ err(`${RED}✗ ${e.message}${R0}`);
78
+ process.exit(1);
79
+ }
80
+ }
81
+ async function cmdInit() {
82
+ const path = vaultPath();
83
+ if (existsSync(path)) {
84
+ if (!(await confirm(`A vault already exists at ${path}. Overwrite it?`)))
85
+ return;
86
+ }
87
+ say(`${A}${ASCII_BANNER}${R0}`);
88
+ say(` ${B}Create your vault.${R0} Choose a strong master password — it is the ${B}only${R0} key,`);
89
+ say(` ${D}it is never stored, and it cannot be recovered. Write it down somewhere safe.${R0}\n`);
90
+ const name = await ask("Vault name", "Personal");
91
+ const owner = await ask("Your name (vault owner)", "me");
92
+ say(`\n ${B}Choose your master key:${R0}`);
93
+ say(` ${A}1${R0} Type my own password / passphrase`);
94
+ say(` ${A}2${R0} Generate a strong word-seed for me ${D}(recommended)${R0}`);
95
+ const choice = await ask("Pick 1-2", "2");
96
+ let pw;
97
+ if (choice === "1") {
98
+ pw = await askSecret("Master password:");
99
+ if (pw.length < 8) {
100
+ err(`${RED}✗ Use at least 8 characters (a passphrase is better).${R0}`);
101
+ process.exit(1);
102
+ }
103
+ const pw2 = await askSecret("Confirm master password:");
104
+ if (pw !== pw2) {
105
+ err(`${RED}✗ Passwords don't match.${R0}`);
106
+ process.exit(1);
107
+ }
108
+ }
109
+ else {
110
+ // Generate a 6-word seed; let the user re-roll until they like it.
111
+ do {
112
+ pw = generatePassphrase(6);
113
+ const s = estimateStrength(pw);
114
+ say(`\n ${A}${B}${pw}${R0}`);
115
+ say(` ${D}${s.bits} bits · ${s.label} — write this down; it is the ONLY key and cannot be recovered.${R0}`);
116
+ } while (!(await confirm("Use this seed? (n to roll a new one)", true)));
117
+ say(`\n ${Y}⚠ Save your seed somewhere safe now${R0} — it will not be shown again.`);
118
+ if (!(await confirm("I've written it down — continue?", true))) {
119
+ say(` ${D}Cancelled.${R0}`);
120
+ return;
121
+ }
122
+ }
123
+ const now = new Date().toISOString();
124
+ const vault = createVault(emptyVault(name, owner, now), pw);
125
+ saveVault(path, vault);
126
+ say(`\n ${G}✓ Vault created${R0} at ${D}${path}${R0} ${D}(owner-readable only).${R0}`);
127
+ say(` ${D}Add your first secret with${R0} ${B}ragevault add${R0}.`);
128
+ say(`\n ${Y}Security tip:${R0} keep this behind a VPN. Run ${B}ragevault wg${R0} to set up WireGuard.\n`);
129
+ }
130
+ async function cmdAdd() {
131
+ const { vault, path } = await openExisting();
132
+ const owner = vault.data.users[0];
133
+ if (!canDo(owner.role, "item.create")) {
134
+ err(`${RED}✗ Your role can't add items.${R0}`);
135
+ process.exit(1);
136
+ }
137
+ const title = await ask("Title (e.g. GitHub)");
138
+ if (!title) {
139
+ err(`${RED}✗ A title is required.${R0}`);
140
+ process.exit(1);
141
+ }
142
+ const username = await ask("Username / email");
143
+ const url = await ask("URL");
144
+ let password = "";
145
+ if (await confirm("Generate a strong password?", true)) {
146
+ password = generatePassword({ length: 20 });
147
+ const s = estimateStrength(password);
148
+ say(` ${A}${password}${R0} ${D}(${s.bits} bits · ${s.label})${R0}`);
149
+ }
150
+ else {
151
+ password = await askSecret("Password:");
152
+ }
153
+ const category = await ask("Category (optional)");
154
+ const totpRaw = await ask("2FA secret — otpauth:// or base32 (optional)");
155
+ const now = new Date().toISOString();
156
+ const item = {
157
+ id: newId(), title, username: username || undefined, url: url || undefined, password,
158
+ categoryId: category ? ensureCategory(vault, category) : undefined,
159
+ // Keep the whole otpauth:// URI (its digits/period/algorithm matter for
160
+ // Steam/8-digit/SHA-256 tokens); totp() parses it. Bare secrets pass through.
161
+ totp: totpRaw ? (totpRaw.trim().toLowerCase().startsWith("otpauth://") ? totpRaw.trim() : extractTotpSecret(totpRaw)) || undefined : undefined,
162
+ tags: [], favorite: false, createdAt: now, updatedAt: now, history: [],
163
+ };
164
+ vault.data.items.push(item);
165
+ saveVault(path, vault);
166
+ say(`\n ${G}✓ Saved${R0} “${B}${title}${R0}”. ${D}See it with${R0} ${B}ragevault get ${title}${R0}.\n`);
167
+ }
168
+ function ensureCategory(vault, name) {
169
+ const existing = vault.data.categories.find((c) => c.name.toLowerCase() === name.toLowerCase());
170
+ if (existing)
171
+ return existing.id;
172
+ const id = newId();
173
+ vault.data.categories.push({ id, name });
174
+ return id;
175
+ }
176
+ /** Older vaults predate the trash array — make sure it exists. */
177
+ function ensureTrash(vault) {
178
+ if (!vault.data.trash)
179
+ vault.data.trash = [];
180
+ return vault.data.trash;
181
+ }
182
+ function findItem(vault, query) {
183
+ const q = query.toLowerCase();
184
+ return vault.data.items.find((i) => i.title.toLowerCase() === q)
185
+ ?? vault.data.items.find((i) => i.title.toLowerCase().includes(q) || (i.username ?? "").toLowerCase().includes(q));
186
+ }
187
+ /** Manage custom fields — the flexible way to store cards, SSH keys, tokens, notes. */
188
+ async function cmdField(query, args) {
189
+ const { vault, path } = await openExisting();
190
+ const item = findItem(vault, query);
191
+ if (!item) {
192
+ err(`${RED}✗ No item matches “${query}”.${R0}`);
193
+ process.exit(1);
194
+ }
195
+ item.fields ??= [];
196
+ const sub = args[0];
197
+ if (sub === "add") {
198
+ if (!canDo(vault.data.users[0].role, "item.update")) {
199
+ err(`${RED}✗ Your role can't edit items.${R0}`);
200
+ process.exit(1);
201
+ }
202
+ const label = args[1] || await ask("Field label (e.g. Card number, CVV, Private key)");
203
+ if (!label) {
204
+ err(`${RED}✗ A label is required.${R0}`);
205
+ process.exit(1);
206
+ }
207
+ const secret = args.includes("--secret") || await confirm("Is this a secret (blur it)?", true);
208
+ const value = secret ? await askSecret(`${label}:`) : await ask(`${label}:`);
209
+ item.fields.push({ label, value, secret });
210
+ item.updatedAt = new Date().toISOString();
211
+ saveVault(path, vault);
212
+ say(` ${G}✓ Added field “${label}” to ${item.title}.${R0}`);
213
+ return;
214
+ }
215
+ if (sub === "rm" || sub === "remove") {
216
+ const label = args[1] || await ask("Which field label to remove?");
217
+ const before = item.fields.length;
218
+ item.fields = item.fields.filter((f) => f.label.toLowerCase() !== label.toLowerCase());
219
+ if (item.fields.length === before) {
220
+ err(`${Y}No field named “${label}”.${R0}`);
221
+ return;
222
+ }
223
+ item.updatedAt = new Date().toISOString();
224
+ saveVault(path, vault);
225
+ say(` ${G}✓ Removed field “${label}”.${R0}`);
226
+ return;
227
+ }
228
+ // list
229
+ say(`\n ${A}${B}${item.title}${R0} ${D}· custom fields${R0}\n`);
230
+ if (!item.fields.length) {
231
+ say(` ${D}No custom fields. Add one with${R0} ${B}ragevault field ${query} add <label>${R0}.\n`);
232
+ return;
233
+ }
234
+ for (const f of item.fields)
235
+ say(` ${B}${f.label.padEnd(16)}${R0} ${f.secret ? blur() + ` ${D}(secret)${R0}` : f.value}`);
236
+ say(`\n ${D}Reveal values with${R0} ${B}ragevault get ${query} --reveal${R0}.\n`);
237
+ }
238
+ async function cmdEdit(query) {
239
+ const { vault, path } = await openExisting();
240
+ if (!canDo(vault.data.users[0].role, "item.update")) {
241
+ err(`${RED}✗ Your role can't edit items.${R0}`);
242
+ process.exit(1);
243
+ }
244
+ const item = findItem(vault, query);
245
+ if (!item) {
246
+ err(`${RED}✗ No item matches “${query}”.${R0}`);
247
+ process.exit(1);
248
+ }
249
+ say(` ${D}Editing “${B}${item.title}${R0}${D}” — press Enter to keep each current value.${R0}`);
250
+ item.title = await ask("Title", item.title);
251
+ item.username = (await ask("Username", item.username ?? "")) || undefined;
252
+ item.url = (await ask("URL", item.url ?? "")) || undefined;
253
+ const cat = await ask("Category", vault.data.categories.find((c) => c.id === item.categoryId)?.name ?? "");
254
+ item.categoryId = cat ? ensureCategory(vault, cat) : undefined;
255
+ if (await confirm("Change the password?")) {
256
+ const gen = await confirm("Generate a new one?", true);
257
+ const next = gen ? generatePassword({ length: 20 }) : await askSecret("New password:");
258
+ if (item.password)
259
+ item.history.unshift({ password: item.password, changedAt: new Date().toISOString() });
260
+ item.password = next;
261
+ if (gen) {
262
+ const s = estimateStrength(next);
263
+ say(` ${A}${next}${R0} ${D}(${s.bits} bits · ${s.label})${R0}`);
264
+ }
265
+ }
266
+ item.updatedAt = new Date().toISOString();
267
+ saveVault(path, vault);
268
+ say(`\n ${G}✓ Updated “${item.title}”.${R0}\n`);
269
+ }
270
+ async function cmdRm(query) {
271
+ const { vault, path } = await openExisting();
272
+ if (!canDo(vault.data.users[0].role, "item.delete")) {
273
+ err(`${RED}✗ Your role can't delete items.${R0}`);
274
+ process.exit(1);
275
+ }
276
+ const item = findItem(vault, query);
277
+ if (!item) {
278
+ err(`${RED}✗ No item matches “${query}”.${R0}`);
279
+ process.exit(1);
280
+ }
281
+ if (!(await confirm(`Move “${item.title}” to the trash?`, true)))
282
+ return;
283
+ vault.data.items = vault.data.items.filter((i) => i.id !== item.id);
284
+ ensureTrash(vault).unshift(item);
285
+ saveVault(path, vault);
286
+ say(` ${G}✓ Moved to trash.${R0} ${D}Restore with${R0} ${B}ragevault trash restore${R0}.`);
287
+ }
288
+ async function cmdTrash(args) {
289
+ const { vault, path } = await openExisting();
290
+ const trash = ensureTrash(vault);
291
+ const sub = args[0];
292
+ if (sub === "empty") {
293
+ if (!trash.length) {
294
+ say(` ${D}Trash is already empty.${R0}`);
295
+ return;
296
+ }
297
+ if (!(await confirm(`Permanently delete ${trash.length} item(s)? This cannot be undone.`)))
298
+ return;
299
+ vault.data.trash = [];
300
+ saveVault(path, vault);
301
+ say(` ${G}✓ Trash emptied.${R0}`);
302
+ return;
303
+ }
304
+ if (sub === "restore") {
305
+ if (!trash.length) {
306
+ say(` ${D}Trash is empty.${R0}`);
307
+ return;
308
+ }
309
+ trash.forEach((it, i) => say(` ${A}${i + 1}${R0} ${B}${it.title}${R0}`));
310
+ const n = Number(await ask("Restore which? (number)", "1"));
311
+ const item = trash[n - 1];
312
+ if (!item) {
313
+ err(`${RED}✗ No such entry.${R0}`);
314
+ process.exit(1);
315
+ }
316
+ vault.data.trash = trash.filter((i) => i.id !== item.id);
317
+ vault.data.items.push(item);
318
+ saveVault(path, vault);
319
+ say(` ${G}✓ Restored “${item.title}”.${R0}`);
320
+ return;
321
+ }
322
+ say(`\n ${A}${B}Trash${R0} ${D}· ${trash.length} item(s)${R0}\n`);
323
+ if (!trash.length) {
324
+ say(` ${D}Empty.${R0}\n`);
325
+ return;
326
+ }
327
+ for (const it of trash)
328
+ say(` ${Y}⌫${R0} ${B}${it.title}${R0} ${D}${it.username ?? ""}${R0}`);
329
+ say(`\n ${D}${B}ragevault trash restore${R0} ${D}to recover ·${R0} ${B}ragevault trash empty${R0} ${D}to purge.${R0}\n`);
330
+ }
331
+ async function cmdMv(query, dest) {
332
+ const { vault, path } = await openExisting();
333
+ const item = findItem(vault, query);
334
+ if (!item) {
335
+ err(`${RED}✗ No item matches “${query}”.${R0}`);
336
+ process.exit(1);
337
+ }
338
+ item.categoryId = dest ? ensureCategory(vault, dest) : undefined;
339
+ item.updatedAt = new Date().toISOString();
340
+ saveVault(path, vault);
341
+ say(` ${G}✓ Moved “${item.title}” to category ${B}${dest || "(none)"}${R0}.`);
342
+ }
343
+ async function cmdLs(query) {
344
+ const { vault } = await openExisting();
345
+ const q = (query ?? "").toLowerCase();
346
+ const items = vault.data.items.filter((i) => !q || i.title.toLowerCase().includes(q) || (i.username ?? "").toLowerCase().includes(q));
347
+ if (items.length === 0) {
348
+ say(` ${D}No items${q ? " match “" + query + "”" : " yet"}.${R0}`);
349
+ return;
350
+ }
351
+ const catName = (id) => vault.data.categories.find((c) => c.id === id)?.name ?? "—";
352
+ say(`\n ${A}${B}${vault.data.meta.name}${R0} ${D}· ${items.length} item(s)${R0}\n`);
353
+ for (const i of items.sort((a, b) => a.title.localeCompare(b.title))) {
354
+ const fav = i.favorite ? `${Y}★${R0} ` : " ";
355
+ say(` ${fav}${B}${i.title.padEnd(22)}${R0} ${D}${(i.username ?? "").padEnd(24)}${R0} ${blur()} ${D}${catName(i.categoryId)}${R0}`);
356
+ }
357
+ say(`\n ${D}Reveal one with${R0} ${B}ragevault get <title> --reveal${R0} ${D}or copy with${R0} ${B}--copy${R0}.\n`);
358
+ }
359
+ async function cmdGet(query, reveal, copy) {
360
+ const { vault } = await openExisting();
361
+ const owner = vault.data.users[0];
362
+ const q = query.toLowerCase();
363
+ const item = vault.data.items.find((i) => i.title.toLowerCase() === q)
364
+ ?? vault.data.items.find((i) => i.title.toLowerCase().includes(q) || (i.username ?? "").toLowerCase().includes(q));
365
+ if (!item) {
366
+ err(`${RED}✗ No item matches “${query}”.${R0}`);
367
+ process.exit(1);
368
+ }
369
+ say(`\n ${B}${item.title}${R0}`);
370
+ if (item.username)
371
+ say(` ${D}user ${R0}${item.username}`);
372
+ if (item.url)
373
+ say(` ${D}url ${R0}${item.url}`);
374
+ const canReveal = canDo(owner.role, "item.reveal");
375
+ if (copy && canReveal) {
376
+ const ok = clip(item.password ?? "");
377
+ say(` ${D}pass ${R0}${blur()} ${ok ? G + "✓ copied to clipboard" : D + "(not a terminal — run with --reveal to print)"}${R0}`);
378
+ }
379
+ else if (reveal && canReveal) {
380
+ const s = estimateStrength(item.password ?? "");
381
+ say(` ${D}pass ${R0}${A}${item.password}${R0} ${D}(${s.bits} bits · ${s.label})${R0}`);
382
+ }
383
+ else if (!canReveal)
384
+ say(` ${D}pass ${R0}${blur()} ${D}(your role can't reveal secrets)${R0}`);
385
+ else
386
+ say(` ${D}pass ${R0}${blur()} ${D}(add --reveal to show, --copy to copy)${R0}`);
387
+ if (item.totp)
388
+ say(` ${D}totp ${R0}${reveal ? item.totp : blur()}`);
389
+ for (const f of item.fields ?? []) {
390
+ const shown = f.secret ? (reveal && canReveal ? `${A}${f.value}${R0}` : blur()) : f.value;
391
+ say(` ${D}${f.label.slice(0, 7).padEnd(7)}${R0} ${shown}${f.secret ? ` ${D}(secret)${R0}` : ""}`);
392
+ }
393
+ if (item.notes)
394
+ say(` ${D}notes ${R0}${item.notes}`);
395
+ say("");
396
+ }
397
+ function cmdGen(args) {
398
+ const words = numFlag(args, "--words");
399
+ const length = numFlag(args, "--length") ?? 20;
400
+ let pw;
401
+ if (words)
402
+ pw = generatePassphrase(words);
403
+ else if (args.includes("--pronounceable"))
404
+ pw = generatePronounceable();
405
+ else
406
+ pw = generatePassword({ length });
407
+ const s = estimateStrength(pw);
408
+ say(` ${A}${B}${pw}${R0}`);
409
+ say(` ${D}${s.bits} bits of entropy · ${s.label}${R0}`);
410
+ if (process.stdout.isTTY) {
411
+ clip(pw);
412
+ say(` ${G}✓ copied to clipboard${R0}`);
413
+ }
414
+ }
415
+ async function cmdPasswd() {
416
+ const { vault, path } = await openExisting();
417
+ const n1 = await askSecret("New master password:");
418
+ if (n1.length < 8) {
419
+ err(`${RED}✗ Use at least 8 characters.${R0}`);
420
+ process.exit(1);
421
+ }
422
+ const n2 = await askSecret("Confirm new master password:");
423
+ if (n1 !== n2) {
424
+ err(`${RED}✗ Passwords don't match.${R0}`);
425
+ process.exit(1);
426
+ }
427
+ rewrapForPassword(vault, n1);
428
+ saveVault(path, vault);
429
+ say(`\n ${G}✓ Master password changed.${R0} ${D}(The vault key was re-wrapped — items were not re-encrypted.)${R0}\n`);
430
+ }
431
+ async function cmdUsers(args) {
432
+ const { vault, path } = await openExisting();
433
+ if (args[0] === "add") {
434
+ const owner = vault.data.users[0];
435
+ if (!canDo(owner.role, "vault.manage")) {
436
+ err(`${RED}✗ Only the owner can manage users.${R0}`);
437
+ process.exit(1);
438
+ }
439
+ const name = args[1] ?? await ask("New user name");
440
+ const role = args[2] ?? (await ask("Role (admin/member/viewer)", "member"));
441
+ if (!(role in ROLE_RANK)) {
442
+ err(`${RED}✗ Unknown role “${role}”.${R0}`);
443
+ process.exit(1);
444
+ }
445
+ vault.data.users.push({ id: newId(), name, role, createdAt: new Date().toISOString() });
446
+ saveVault(path, vault);
447
+ say(` ${G}✓ Added ${name} as ${role}.${R0}`);
448
+ return;
449
+ }
450
+ say(`\n ${A}${B}Users & roles${R0}\n`);
451
+ for (const u of vault.data.users) {
452
+ say(` ${u.role === "owner" ? Y + "●" : A + "○"}${R0} ${B}${u.name.padEnd(20)}${R0} ${D}${u.role}${R0}`);
453
+ }
454
+ say(`\n ${D}Add one with${R0} ${B}ragevault users add <name> <role>${R0}.`);
455
+ say(` ${Y}Note:${R0} ${D}v0.1 has a single master password — whoever unlocks acts as the owner.`);
456
+ say(` ${D}Roles are advisory until per-user authentication ships (on the roadmap).${R0}\n`);
457
+ }
458
+ async function cmdTotp(query) {
459
+ const { vault } = await openExisting();
460
+ const q = query.toLowerCase();
461
+ const item = vault.data.items.find((i) => i.title.toLowerCase() === q)
462
+ ?? vault.data.items.find((i) => i.title.toLowerCase().includes(q));
463
+ if (!item) {
464
+ err(`${RED}✗ No item matches “${query}”.${R0}`);
465
+ process.exit(1);
466
+ }
467
+ if (!item.totp) {
468
+ err(`${Y}No 2FA secret on “${item.title}”.${R0} Add one with ${B}ragevault add${R0}.`);
469
+ process.exit(1);
470
+ }
471
+ try {
472
+ const { code, remaining } = totp(item.totp);
473
+ clip(code);
474
+ say(`\n ${B}${item.title}${R0} ${A}${B}${code.replace(/(\d{3})(\d{3})/, "$1 $2")}${R0} ${D}· expires in ${remaining}s · copied${R0}\n`);
475
+ }
476
+ catch (e) {
477
+ err(`${RED}✗ ${e.message}${R0}`);
478
+ process.exit(1);
479
+ }
480
+ }
481
+ async function cmdAudit(breach) {
482
+ const { vault } = await openExisting();
483
+ const report = auditVault(vault.data);
484
+ say(`\n ${A}${B}Password health${R0} ${D}· ${report.total} item(s)${R0}\n`);
485
+ say(` ${report.weak ? RED : G}weak ${report.weak}${R0} ${report.reused ? RED : G}reused ${report.reused}${R0} ${report.old ? Y : G}old ${report.old}${R0}`);
486
+ if (report.findings.length) {
487
+ say("");
488
+ const name = (id) => vault.data.items.find((i) => i.id === id)?.title ?? id;
489
+ for (const f of report.findings)
490
+ say(` ${Y}•${R0} ${B}${name(f.itemId).padEnd(22)}${R0} ${D}${f.issues.join(", ")}${R0}`);
491
+ }
492
+ else {
493
+ say(`\n ${G}✓ No weak, reused or stale passwords. Nicely done.${R0}`);
494
+ }
495
+ if (breach) {
496
+ say(`\n ${D}Checking Have I Been Pwned (k-anonymity — passwords never leave this machine)…${R0}`);
497
+ let hits = 0, failed = 0, checked = 0;
498
+ for (const i of vault.data.items) {
499
+ if (!i.password)
500
+ continue;
501
+ const n = await breachCount(i.password);
502
+ if (n < 0) {
503
+ failed++;
504
+ continue;
505
+ } // -1 = couldn't reach HIBP
506
+ checked++;
507
+ if (n > 0) {
508
+ hits++;
509
+ say(` ${RED}⚠ ${i.title}${R0} appears in ${B}${n.toLocaleString()}${R0} breaches — change it.`);
510
+ }
511
+ }
512
+ if (failed && !checked)
513
+ say(` ${Y}Couldn't reach Have I Been Pwned — no passwords were checked.${R0}`);
514
+ else if (!hits)
515
+ say(` ${G}✓ None of your passwords were found in known breaches.${R0}${failed ? ` ${D}(${failed} couldn't be checked)${R0}` : ""}`);
516
+ else if (failed)
517
+ say(` ${D}(${failed} password(s) couldn't be checked — network issue.)${R0}`);
518
+ }
519
+ else {
520
+ say(`\n ${D}Add${R0} ${B}--breach${R0} ${D}to also check Have I Been Pwned (needs network).${R0}`);
521
+ }
522
+ say("");
523
+ }
524
+ async function cmdImport(file) {
525
+ if (!file || !existsSync(file)) {
526
+ err(`${RED}✗ File not found: ${file}${R0}`);
527
+ process.exit(1);
528
+ }
529
+ const { vault, path } = await openExisting();
530
+ const { items, source } = importAny(readFileSync(file, "utf8"), file);
531
+ if (!items.length) {
532
+ err(`${Y}No items found${R0} — supported: Bitwarden/Proton Pass JSON, or CSV (1Password/Chrome/KeePass).`);
533
+ process.exit(1);
534
+ }
535
+ say(` ${D}Detected format:${R0} ${B}${source}${R0}`);
536
+ if (!(await confirm(`Import ${items.length} item(s) into “${vault.data.meta.name}”?`, true)))
537
+ return;
538
+ vault.data.items.push(...items);
539
+ saveVault(path, vault);
540
+ say(` ${G}✓ Imported ${items.length} item(s).${R0}`);
541
+ }
542
+ async function cmdExport(csv, out) {
543
+ const { vault } = await openExisting();
544
+ say(` ${RED}${B}⚠ WARNING${R0} ${Y}This writes your secrets in PLAINTEXT.${R0} Store it briefly, then delete it securely.`);
545
+ if (!(await confirm("Continue with a plaintext export?"))) {
546
+ say(` ${D}Cancelled.${R0}`);
547
+ return;
548
+ }
549
+ const data = csv ? exportCsv(vault.data) : exportJson(vault.data);
550
+ const dest = out ?? `ragevault-export.${csv ? "csv" : "json"}`;
551
+ // Create restricted from the first byte (umask only clears bits) so the plaintext
552
+ // is never briefly world-readable; chmod covers a pre-existing looser file.
553
+ writeFileSync(dest, data, { encoding: "utf8", mode: 0o600 });
554
+ try {
555
+ chmodSync(dest, 0o600);
556
+ }
557
+ catch { /* windows */ }
558
+ say(` ${G}✓ Exported${R0} to ${B}${dest}${R0} ${D}(0600). Delete it when you're done.${R0}`);
559
+ }
560
+ function backupDir() {
561
+ const base = process.env.RAGEVAULT_HOME?.trim() || join(homedir(), ".ragevault");
562
+ return join(base, "backups");
563
+ }
564
+ function stamp() {
565
+ const d = new Date(), p = (n) => String(n).padStart(2, "0");
566
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
567
+ }
568
+ /** Back up the (already-encrypted) vault file. Keeps the last 20, prunes older. */
569
+ async function cmdBackup() {
570
+ const src = vaultPath();
571
+ if (!existsSync(src)) {
572
+ err(`${Y}No vault to back up.${R0} Create one with ${B}ragevault init${R0}.`);
573
+ process.exit(1);
574
+ }
575
+ const dir = backupDir();
576
+ mkdirSync(dir, { recursive: true });
577
+ const dest = join(dir, `vault-${stamp()}.ragevault`);
578
+ copyFileSync(src, dest);
579
+ try {
580
+ chmodSync(dest, 0o600);
581
+ }
582
+ catch { /* windows */ }
583
+ // Prune to the 20 most recent.
584
+ const backups = readdirSync(dir).filter((f) => f.endsWith(".ragevault")).sort();
585
+ for (const old of backups.slice(0, Math.max(0, backups.length - 20))) {
586
+ try {
587
+ unlinkSync(join(dir, old));
588
+ }
589
+ catch { /* ignore */ }
590
+ }
591
+ say(` ${G}✓ Backup written${R0} to ${B}${dest}${R0} ${D}(still encrypted; keeps the last 20).${R0}`);
592
+ }
593
+ /** Restore the vault from a backup file (validated, current one saved first). */
594
+ async function cmdRestore(file) {
595
+ const dir = backupDir();
596
+ let src = file;
597
+ if (!src) {
598
+ const backups = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".ragevault")).sort().reverse() : [];
599
+ if (!backups.length) {
600
+ err(`${Y}No backups found${R0} in ${D}${dir}${R0}.`);
601
+ process.exit(1);
602
+ }
603
+ say(`\n ${A}${B}Backups${R0} ${D}(newest first)${R0}\n`);
604
+ backups.slice(0, 9).forEach((f, i) => say(` ${A}${i + 1}${R0} ${f} ${D}${(statSync(join(dir, f)).size)} bytes${R0}`));
605
+ const pick = Number(await ask("\n Restore which? (1-9)", "1"));
606
+ src = join(dir, backups[pick - 1] ?? backups[0]);
607
+ }
608
+ if (!existsSync(src)) {
609
+ err(`${RED}✗ Not found: ${src}${R0}`);
610
+ process.exit(1);
611
+ }
612
+ // Validate it's a RageVault file before overwriting anything.
613
+ try {
614
+ const f = JSON.parse(readFileSync(src, "utf8"));
615
+ if (f.magic !== "ragevault")
616
+ throw new Error("not a RageVault file");
617
+ }
618
+ catch (e) {
619
+ err(`${RED}✗ ${basename(src)} is not a valid vault (${e.message}).${R0}`);
620
+ process.exit(1);
621
+ }
622
+ const target = vaultPath();
623
+ if (existsSync(target)) {
624
+ if (!(await confirm(`This replaces your current vault at ${target}. A safety backup is taken first. Continue?`)))
625
+ return;
626
+ mkdirSync(dir, { recursive: true });
627
+ copyFileSync(target, join(dir, `vault-prerestore-${stamp()}.ragevault`));
628
+ }
629
+ copyFileSync(src, target);
630
+ try {
631
+ chmodSync(target, 0o600);
632
+ }
633
+ catch { /* windows */ }
634
+ say(` ${G}✓ Restored${R0} from ${B}${basename(src)}${R0}. ${D}Unlock as usual with your master password.${R0}`);
635
+ }
636
+ async function cmdWg() {
637
+ say(`\n ${A}${B}WireGuard setup${R0}`);
638
+ say(` ${D}RageVault is meant to live behind a VPN. This runs the bundled setup script${R0}`);
639
+ say(` ${D}which installs & configures WireGuard and prints a client config / QR.${R0}\n`);
640
+ if (process.platform === "win32") {
641
+ say(` ${Y}On Windows${R0}, run the server side on a Linux host and import the client config`);
642
+ say(` into the WireGuard app. See ${B}scripts/wireguard-setup.sh${R0} and the README.\n`);
643
+ return;
644
+ }
645
+ if (!(await confirm("Run the WireGuard setup script now (needs sudo)?"))) {
646
+ say(` ${D}Skipped. It's optional — for purely local/personal use you don't need it.${R0}\n`);
647
+ return;
648
+ }
649
+ const here = dirname(fileURLToPath(import.meta.url));
650
+ const script = resolve(here, "..", "scripts", "wireguard-setup.sh");
651
+ const child = spawn("sudo", ["sh", script], { stdio: "inherit" });
652
+ await new Promise((r) => child.on("close", () => r()));
653
+ }
654
+ function numFlag(args, flag) {
655
+ const i = args.indexOf(flag);
656
+ if (i >= 0 && args[i + 1]) {
657
+ const n = Number(args[i + 1]);
658
+ if (!Number.isNaN(n))
659
+ return n;
660
+ }
661
+ return undefined;
662
+ }
663
+ function printHelp() {
664
+ say(`${A}${ASCII_BANNER}${R0}`);
665
+ say(` ${B}${BRAND.product}${R0} ${D}v${BRAND.version} · by ${BRAND.author}${R0}\n`);
666
+ say(` ${B}Usage:${R0} ${A}ragevault${R0} ${D}<command>${R0}\n`);
667
+ say(` ${A}init${R0} create an encrypted vault`);
668
+ say(` ${A}add${R0} add a secret`);
669
+ say(` ${A}tui${R0} full-screen terminal dashboard (blur, reveal, 2FA, copy)`);
670
+ say(` ${A}ls${R0} ${D}[query]${R0} list items (secrets blurred)`);
671
+ say(` ${A}get${R0} ${D}<q>${R0} show an item ${D}(--reveal to show, --copy to clipboard)${R0}`);
672
+ say(` ${A}edit${R0} ${D}<q>${R0} edit an item ${D}(keeps password history)${R0}`);
673
+ say(` ${A}rm${R0} ${D}<q>${R0} move an item to the trash`);
674
+ say(` ${A}trash${R0} view / restore / empty the trash`);
675
+ say(` ${A}mv${R0} ${D}<q> <cat>${R0} move an item to a category`);
676
+ say(` ${A}field${R0} ${D}<q>${R0} custom fields ${D}(add/rm — cards, SSH keys, tokens, notes)${R0}`);
677
+ say(` ${A}gen${R0} generate a password ${D}(--length N | --words N | --pronounceable)${R0}`);
678
+ say(` ${A}totp${R0} ${D}<q>${R0} show the live 2FA code for an item`);
679
+ say(` ${A}audit${R0} password health ${D}(--breach checks Have I Been Pwned)${R0}`);
680
+ say(` ${A}import${R0} ${D}<f>${R0} import Bitwarden / Proton Pass (JSON) or 1Password / Chrome / KeePass (CSV)`);
681
+ say(` ${A}export${R0} export ${D}(--csv, --out FILE)${R0} — plaintext, use with care`);
682
+ say(` ${A}backup${R0} save an encrypted backup ${D}(keeps the last 20)${R0}`);
683
+ say(` ${A}restore${R0} restore the vault from a backup`);
684
+ say(` ${A}passwd${R0} change the master password`);
685
+ say(` ${A}users${R0} list / add users & roles`);
686
+ say(` ${A}serve${R0} start the self-hosted web UI + API ${D}(RAGEVAULT_HOST/PORT)${R0}`);
687
+ say(` ${A}wg${R0} set up WireGuard (optional, recommended)`);
688
+ say(` ${A}version${R0} print the version\n`);
689
+ say(` ${Y}⚠ Use a VPN.${R0} ${D}RageVault is self-hosted; never expose it to the open Internet.${R0}`);
690
+ say(` ${D}${BRAND.author} · ${BRAND.url}${R0}\n`);
691
+ }
692
+ async function main() {
693
+ const argv = process.argv.slice(2);
694
+ const cmd = (argv.find((a) => !a.startsWith("-")) || "help").toLowerCase();
695
+ const rest = argv.slice(argv.indexOf(cmd) + 1);
696
+ // A one-line "there's an update" notice on stderr (never on stdout, so it
697
+ // can't pollute secrets/exports). Skipped for the full-screen TUI and help.
698
+ if (!["tui", "dashboard", "help", "version", "--version", "-v"].includes(cmd)) {
699
+ const notice = await updateNoticeLine();
700
+ if (notice)
701
+ err(` ${A}${notice}${R0}`);
702
+ }
703
+ switch (cmd) {
704
+ case "init": return cmdInit();
705
+ case "add": return cmdAdd();
706
+ case "ls":
707
+ case "list": return cmdLs(rest.find((a) => !a.startsWith("-")));
708
+ case "get":
709
+ case "show": {
710
+ const q = rest.find((a) => !a.startsWith("-"));
711
+ if (!q) {
712
+ err(`${RED}✗ Usage: ragevault get <query> [--reveal|--copy]${R0}`);
713
+ process.exit(1);
714
+ }
715
+ return cmdGet(q, rest.includes("--reveal"), rest.includes("--copy"));
716
+ }
717
+ case "edit": {
718
+ const q = rest.find((a) => !a.startsWith("-"));
719
+ if (!q) {
720
+ err(`${RED}✗ Usage: ragevault edit <query>${R0}`);
721
+ process.exit(1);
722
+ }
723
+ return cmdEdit(q);
724
+ }
725
+ case "rm":
726
+ case "del":
727
+ case "delete": {
728
+ const q = rest.find((a) => !a.startsWith("-"));
729
+ if (!q) {
730
+ err(`${RED}✗ Usage: ragevault rm <query>${R0}`);
731
+ process.exit(1);
732
+ }
733
+ return cmdRm(q);
734
+ }
735
+ case "trash": return cmdTrash(rest);
736
+ case "field":
737
+ case "fields": {
738
+ const q = rest.find((a) => !a.startsWith("-"));
739
+ if (!q) {
740
+ err(`${RED}✗ Usage: ragevault field <query> [add <label> [--secret] | rm <label>]${R0}`);
741
+ process.exit(1);
742
+ }
743
+ return cmdField(q, rest.slice(rest.indexOf(q) + 1));
744
+ }
745
+ case "mv":
746
+ case "move": {
747
+ const args = rest.filter((a) => !a.startsWith("-"));
748
+ if (args.length < 2) {
749
+ err(`${RED}✗ Usage: ragevault mv <query> <category>${R0}`);
750
+ process.exit(1);
751
+ }
752
+ return cmdMv(args[0], args[1]);
753
+ }
754
+ case "gen":
755
+ case "generate": return void cmdGen(rest);
756
+ case "totp":
757
+ case "2fa": {
758
+ const q = rest.find((a) => !a.startsWith("-"));
759
+ if (!q) {
760
+ err(`${RED}✗ Usage: ragevault totp <query>${R0}`);
761
+ process.exit(1);
762
+ }
763
+ return cmdTotp(q);
764
+ }
765
+ case "audit":
766
+ case "health": return cmdAudit(rest.includes("--breach"));
767
+ case "import": {
768
+ const f = rest.find((a) => !a.startsWith("-"));
769
+ if (!f) {
770
+ err(`${RED}✗ Usage: ragevault import <file.csv>${R0}`);
771
+ process.exit(1);
772
+ }
773
+ return cmdImport(f);
774
+ }
775
+ case "export": {
776
+ const outIdx = rest.indexOf("--out");
777
+ return cmdExport(rest.includes("--csv"), outIdx >= 0 ? rest[outIdx + 1] : undefined);
778
+ }
779
+ case "backup": return cmdBackup();
780
+ case "restore": return cmdRestore(rest.find((a) => !a.startsWith("-")));
781
+ case "passwd": return cmdPasswd();
782
+ case "users": return cmdUsers(rest);
783
+ case "wg":
784
+ case "wireguard": return cmdWg();
785
+ case "tui":
786
+ case "dashboard": {
787
+ if (!existsSync(vaultPath())) {
788
+ err(`${Y}No vault yet.${R0} Create one first with ${B}ragevault init${R0}.`);
789
+ process.exit(1);
790
+ }
791
+ const { VaultTui } = await import("./tui/app.js");
792
+ new VaultTui(vaultPath()).start();
793
+ return;
794
+ }
795
+ case "serve":
796
+ case "web": {
797
+ const host = process.env.RAGEVAULT_HOST?.trim() || "127.0.0.1";
798
+ const port = Number(process.env.RAGEVAULT_PORT ?? 8722);
799
+ say(`${A}${ASCII_BANNER}${R0}`);
800
+ // No vault yet is fine — the web UI shows a "create your vault" screen (the
801
+ // desktop app relies on this for first run).
802
+ if (!existsSync(vaultPath()))
803
+ say(` ${Y}No vault yet${R0} — open the page to create one.\n`);
804
+ await startServer({ vaultPath: vaultPath(), host, port });
805
+ return;
806
+ }
807
+ case "version":
808
+ case "--version":
809
+ case "-v": return void say(`${BRAND.product} v${BRAND.version} — ${BRAND.author}`);
810
+ default: return void printHelp();
811
+ }
812
+ }
813
+ main().catch((e) => { err(`${RED}Fatal: ${e instanceof Error ? e.message : e}${R0}`); process.exit(1); });
814
+ //# sourceMappingURL=ragevault.js.map