@papervault/cli 0.1.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.
@@ -0,0 +1,508 @@
1
+ // Interactive setup wizard.
2
+ //
3
+ // Two modes that diverge after the source pick:
4
+ //
5
+ // 1. "I'll add them now" / ".env file" → one-shot mode
6
+ // Collect secrets in memory → vault config prompts → runKitFlow.
7
+ // No config file written. Secrets never persisted unless user
8
+ // explicitly picks a save path.
9
+ //
10
+ // 2. Azure KV / JSON file / stdin → recurring-source mode
11
+ // Source URI + vault config → write papervault.config.json.
12
+ // Subsequent `papervault backup` runs need zero flags.
13
+ //
14
+ // Secrets NEVER pass through the config file. For one-shot flows, the kit
15
+ // is generated immediately and the process exits.
16
+
17
+ import { parseArgs } from 'node:util';
18
+ import { existsSync } from 'node:fs';
19
+ import { readFile } from 'node:fs/promises';
20
+ import { resolve, isAbsolute } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import * as p from '@clack/prompts';
23
+ import { countUserContent, LIMITS } from '@papervault/core';
24
+
25
+ import { configPath, configExists, readConfig, writeConfig, CONFIG_FILENAME } from '../config.js';
26
+ import { resolveSource } from '../sources/index.js';
27
+ import { runKitFlow } from '../kit-runner.js';
28
+
29
+ const HELP = `papervault init — interactive setup
30
+
31
+ Walks through the choices and either:
32
+ • Generates a kit right now (manual entry or .env import), or
33
+ • Writes ${CONFIG_FILENAME} so subsequent 'papervault backup' runs need no flags.
34
+
35
+ Usage:
36
+ papervault init [options]
37
+
38
+ Options:
39
+ --force Overwrite an existing ${CONFIG_FILENAME} without prompting
40
+ --help
41
+ `;
42
+
43
+ const OPTIONS = {
44
+ force: { type: 'boolean' },
45
+ help: { type: 'boolean' },
46
+ };
47
+
48
+ function expandTilde(s) {
49
+ if (!s) return s;
50
+ if (s === '~') return homedir();
51
+ if (s.startsWith('~/')) return resolve(homedir(), s.slice(2));
52
+ return s;
53
+ }
54
+
55
+ function cancelIf(value) {
56
+ if (p.isCancel(value)) {
57
+ p.cancel('Setup cancelled.');
58
+ process.exit(0);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ export async function init(argv) {
64
+ const { values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: false });
65
+ if (values.help) {
66
+ process.stdout.write(HELP);
67
+ return;
68
+ }
69
+
70
+ p.intro('🔐 PaperVault init');
71
+
72
+ let prefill = null;
73
+ if (configExists() && !values.force) {
74
+ try { prefill = await readConfig(); } catch { /* fresh start */ }
75
+ if (prefill) {
76
+ const action = cancelIf(await p.select({
77
+ message: `${CONFIG_FILENAME} already exists. What now?`,
78
+ options: [
79
+ { value: 'edit', label: 'Edit it (pre-fill answers from current values)' },
80
+ { value: 'replace', label: 'Start over from scratch' },
81
+ { value: 'cancel', label: 'Cancel' },
82
+ ],
83
+ }));
84
+ if (action === 'cancel') { p.cancel('Cancelled.'); return; }
85
+ if (action === 'replace') prefill = null;
86
+ }
87
+ }
88
+
89
+ // ---------- 1. Where are your secrets? ----------
90
+ const sourceType = cancelIf(await p.select({
91
+ message: 'Where are your secrets?',
92
+ initialValue: prefill ? guessSourceType(prefill.source) : 'manual',
93
+ options: [
94
+ { value: 'manual', label: "I'll add them now", hint: 'interactive entry, nothing saved to disk' },
95
+ { value: 'env', label: 'Import from .env file', hint: 'parses KEY=value lines' },
96
+ { value: 'azure', label: 'Azure Key Vault', hint: 'uses your `az login` cache' },
97
+ { value: 'file', label: 'JSON file on disk', hint: 'advanced — you maintain the file' },
98
+ { value: 'stdin', label: 'stdin pipe', hint: 'for shell pipelines / scripts' },
99
+ { value: 'roadmap', label: 'Something else…', hint: 'aws-sm, gcp-sm, vault, 1password' },
100
+ ],
101
+ }));
102
+
103
+ if (sourceType === 'roadmap') {
104
+ p.log.info('Those backends are on the roadmap — file / stdin / Azure / manual / .env work today.');
105
+ p.outro('No config written. Re-run init when you\'re ready.');
106
+ return;
107
+ }
108
+
109
+ // ---------- One-shot modes: manual + .env ----------
110
+ if (sourceType === 'manual') {
111
+ const secrets = await collectManualSecrets();
112
+ if (secrets.length === 0) { p.cancel('No secrets entered.'); return; }
113
+ await oneShotKitFlow({ secrets, sourceLabel: 'manual entry' });
114
+ return;
115
+ }
116
+
117
+ if (sourceType === 'env') {
118
+ const secrets = await collectFromEnvFile();
119
+ if (secrets.length === 0) { p.cancel('No secrets selected.'); return; }
120
+ await oneShotKitFlow({ secrets, sourceLabel: '.env import' });
121
+ return;
122
+ }
123
+
124
+ // ---------- Recurring-source modes: Azure / JSON / stdin ----------
125
+ let sourceUri;
126
+ if (sourceType === 'file') {
127
+ const pth = cancelIf(await p.text({
128
+ message: 'Path to the JSON file with your secrets:',
129
+ placeholder: '/Users/you/secrets.json',
130
+ initialValue: prefill?.source?.startsWith('file://') ? prefill.source.slice(7) : undefined,
131
+ validate(v) {
132
+ const expanded = expandTilde(v?.trim());
133
+ if (!expanded) return 'Required.';
134
+ if (!isAbsolute(expanded)) return 'Use an absolute path so `papervault backup` works from any directory.';
135
+ if (!existsSync(expanded)) return `File not found: ${expanded}`;
136
+ },
137
+ }));
138
+ sourceUri = `file://${expandTilde(pth.trim())}`;
139
+ } else if (sourceType === 'stdin') {
140
+ sourceUri = '-';
141
+ p.log.info('Backups will read JSON from stdin — pipe your secrets each run.');
142
+ } else if (sourceType === 'azure') {
143
+ const vaultName = cancelIf(await p.text({
144
+ message: 'Azure Key Vault name:',
145
+ placeholder: 'my-vault',
146
+ initialValue: prefill?.source?.startsWith('azure-kv://') ? prefill.source.slice('azure-kv://'.length) : undefined,
147
+ validate(v) {
148
+ if (!v) return 'Required.';
149
+ if (!/^[a-zA-Z0-9-]{3,24}$/.test(v.trim())) return 'Vault names are 3-24 chars of letters, digits, and dashes.';
150
+ },
151
+ }));
152
+ sourceUri = `azure-kv://${vaultName.trim()}`;
153
+
154
+ const checkAuth = cancelIf(await p.confirm({
155
+ message: 'Verify access to this vault now? (probes with `az login` cache)',
156
+ initialValue: true,
157
+ }));
158
+ if (checkAuth) {
159
+ const s = p.spinner();
160
+ s.start(`Connecting to ${sourceUri}…`);
161
+ try {
162
+ const src = resolveSource(sourceUri);
163
+ await src.authenticate();
164
+ const refs = await src.list();
165
+ await src.close();
166
+ s.stop(`Connected: ${refs.length} secret${refs.length === 1 ? '' : 's'} visible.`);
167
+ } catch (err) {
168
+ s.stop('Auth check failed.');
169
+ p.log.warn(err.message.split('\n')[0]);
170
+ const skip = cancelIf(await p.confirm({
171
+ message: 'Save the config anyway? (fix auth before the first backup)',
172
+ initialValue: true,
173
+ }));
174
+ if (!skip) { p.cancel('Aborted.'); return; }
175
+ }
176
+ }
177
+ }
178
+
179
+ const cfg = await collectVaultConfig({ prefill, sourceUri });
180
+ p.note(formatConfigPreview(cfg), 'Config preview');
181
+
182
+ const confirm = cancelIf(await p.confirm({
183
+ message: `Write ${CONFIG_FILENAME} to ${configPath()}?`,
184
+ initialValue: true,
185
+ }));
186
+ if (!confirm) { p.cancel('Nothing written.'); return; }
187
+
188
+ const written = await writeConfig(cfg);
189
+ p.log.success(`Wrote ${written}`);
190
+ p.note(
191
+ sourceType === 'stdin' ? 'cat secrets.json | papervault backup' : 'papervault backup',
192
+ 'Next: generate a kit'
193
+ );
194
+ p.outro('Done.');
195
+ }
196
+
197
+ // =====================================================================
198
+ // Manual entry
199
+ // =====================================================================
200
+
201
+ const KIND_FIELDS = {
202
+ password: [
203
+ { key: 'service', label: 'Service / site', required: true, mask: false },
204
+ { key: 'username', label: 'Username / email', required: false, mask: false },
205
+ { key: 'password', label: 'Password', required: true, mask: true },
206
+ { key: 'url', label: 'URL', required: false, mask: false },
207
+ { key: 'notes', label: 'Notes', required: false, mask: false },
208
+ ],
209
+ wallet: [
210
+ { key: 'name', label: 'Wallet name', required: true, mask: false },
211
+ { key: 'seed', label: 'Seed phrase', required: false, mask: true },
212
+ { key: 'privateKey', label: 'Private key', required: false, mask: true },
213
+ { key: 'address', label: 'Address', required: false, mask: false },
214
+ { key: 'notes', label: 'Notes', required: false, mask: false },
215
+ ],
216
+ note: [
217
+ { key: 'title', label: 'Title', required: true, mask: false },
218
+ { key: 'content', label: 'Content', required: true, mask: false },
219
+ ],
220
+ apikey: [
221
+ { key: 'service', label: 'Service', required: true, mask: false },
222
+ { key: 'key', label: 'API key', required: true, mask: true },
223
+ { key: 'secret', label: 'API secret', required: false, mask: true },
224
+ { key: 'notes', label: 'Notes', required: false, mask: false },
225
+ ],
226
+ custom: [
227
+ { key: 'label', label: 'Label', required: true, mask: false },
228
+ { key: 'value', label: 'Value', required: true, mask: true },
229
+ { key: 'notes', label: 'Notes', required: false, mask: false },
230
+ ],
231
+ };
232
+
233
+ async function collectManualSecrets() {
234
+ const secrets = [];
235
+ p.log.info(`Add secrets one at a time. The vault holds up to ${LIMITS.MAX_STORAGE} characters of content total (matches papervault.xyz so QR codes stay scannable).`);
236
+
237
+ while (true) {
238
+ const remaining = LIMITS.MAX_STORAGE - countUserContent(secrets);
239
+ const counter = remaining >= 0 ? `${remaining} chars left` : `OVER by ${-remaining} chars`;
240
+
241
+ const kind = cancelIf(await p.select({
242
+ message: `Add a secret? (${secrets.length} so far · ${counter})`,
243
+ options: [
244
+ { value: 'password', label: 'Password', hint: 'service + username + password + url + notes' },
245
+ { value: 'wallet', label: 'Wallet / seed', hint: 'name + seed phrase / private key + address' },
246
+ { value: 'apikey', label: 'API key', hint: 'service + key + secret' },
247
+ { value: 'note', label: 'Note', hint: 'title + content' },
248
+ { value: 'custom', label: 'Custom', hint: 'label + value + notes' },
249
+ { value: 'done', label: secrets.length === 0 ? 'Cancel' : 'Done — print my kit' },
250
+ ],
251
+ }));
252
+
253
+ if (kind === 'done') break;
254
+
255
+ const entry = { kind };
256
+ let cancelled = false;
257
+ for (const field of KIND_FIELDS[kind]) {
258
+ const prompt = field.mask ? p.password : p.text;
259
+ const ans = await prompt({
260
+ message: `${field.label}${field.required ? '' : ' (optional)'}:`,
261
+ validate(v) {
262
+ if (field.required && (!v || !v.trim())) return 'Required.';
263
+ },
264
+ });
265
+ if (p.isCancel(ans)) { cancelled = true; break; }
266
+ if (ans) entry[field.key] = ans.trim();
267
+ }
268
+ if (cancelled) {
269
+ p.log.warn('Entry cancelled — skipping this one.');
270
+ continue;
271
+ }
272
+
273
+ // Re-check limit AFTER adding this entry; reject if it tipped over.
274
+ const trial = [...secrets, entry];
275
+ const trialCount = countUserContent(trial);
276
+ if (trialCount > LIMITS.MAX_STORAGE) {
277
+ p.log.warn(`Adding this entry would push content to ${trialCount} chars (over the ${LIMITS.MAX_STORAGE} limit). Entry NOT added — shorten the values or remove an earlier entry.`);
278
+ continue;
279
+ }
280
+ secrets.push(entry);
281
+ p.log.success(`Added ${kind} (${trialCount}/${LIMITS.MAX_STORAGE} chars used).`);
282
+ }
283
+
284
+ return secrets;
285
+ }
286
+
287
+ // =====================================================================
288
+ // .env import
289
+ // =====================================================================
290
+
291
+ async function collectFromEnvFile() {
292
+ const pth = cancelIf(await p.text({
293
+ message: 'Path to .env file:',
294
+ placeholder: '/Users/you/project/.env',
295
+ validate(v) {
296
+ const expanded = expandTilde(v?.trim());
297
+ if (!expanded) return 'Required.';
298
+ if (!isAbsolute(expanded)) return 'Use an absolute path.';
299
+ if (!existsSync(expanded)) return `File not found: ${expanded}`;
300
+ },
301
+ }));
302
+ const content = await readFile(expandTilde(pth.trim()), 'utf8');
303
+ const parsed = parseEnvFile(content);
304
+
305
+ if (parsed.length === 0) {
306
+ p.log.warn('No KEY=value entries found in that file.');
307
+ return [];
308
+ }
309
+
310
+ p.log.info(`Found ${parsed.length} entries in ${pth}. Pick which to back up — values will be visible in your terminal selection list as a single dot per item.`);
311
+
312
+ // Multiselect: show name + truncated value as label
313
+ const choices = parsed.map(e => ({
314
+ value: e.service,
315
+ label: `${e.service}`,
316
+ hint: e.key.length > 20 ? `${e.key.slice(0, 20)}…` : e.key,
317
+ }));
318
+
319
+ const picked = cancelIf(await p.multiselect({
320
+ message: `Select entries to back up (${LIMITS.MAX_STORAGE} chars max total):`,
321
+ options: choices,
322
+ required: false,
323
+ }));
324
+
325
+ const selected = parsed.filter(e => picked.includes(e.service));
326
+
327
+ const totalChars = countUserContent(selected);
328
+ if (totalChars > LIMITS.MAX_STORAGE) {
329
+ p.log.error(`Selection is ${totalChars} chars — over the ${LIMITS.MAX_STORAGE} limit. Re-run init and pick fewer entries.`);
330
+ return [];
331
+ }
332
+ if (totalChars > 0) {
333
+ p.log.success(`${selected.length} selected, ${totalChars}/${LIMITS.MAX_STORAGE} chars.`);
334
+ }
335
+ return selected;
336
+ }
337
+
338
+ /**
339
+ * Minimal .env parser. Handles:
340
+ * - blank lines and # comments
341
+ * - "export KEY=value" prefix
342
+ * - single- and double-quoted values
343
+ * - inline # comments after unquoted values
344
+ * Each entry becomes a PaperVault `apikey` with service=name, key=value.
345
+ */
346
+ export function parseEnvFile(content) {
347
+ const out = [];
348
+ for (const raw of content.split('\n')) {
349
+ const line = raw.replace(/\r$/, '');
350
+ const trimmed = line.trim();
351
+ if (!trimmed || trimmed.startsWith('#')) continue;
352
+
353
+ const stripped = trimmed.replace(/^export\s+/, '');
354
+ const eq = stripped.indexOf('=');
355
+ if (eq < 1) continue;
356
+
357
+ const key = stripped.slice(0, eq).trim();
358
+ let value = stripped.slice(eq + 1);
359
+
360
+ // Quoted values keep everything inside the quotes literal (incl. # and =).
361
+ const dq = value.match(/^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/);
362
+ const sq = value.match(/^'([^']*)'\s*(?:#.*)?$/);
363
+ if (dq) value = dq[1].replace(/\\"/g, '"').replace(/\\n/g, '\n');
364
+ else if (sq) value = sq[1];
365
+ else {
366
+ // Unquoted: strip inline comments and trim.
367
+ const hashIdx = value.indexOf(' #');
368
+ if (hashIdx >= 0) value = value.slice(0, hashIdx);
369
+ value = value.trim();
370
+ }
371
+
372
+ if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
373
+ if (value === '') continue; // skip empty values
374
+ out.push({ kind: 'apikey', service: key, key: value });
375
+ }
376
+ return out;
377
+ }
378
+
379
+ // =====================================================================
380
+ // Shared: vault config (shares, threshold, name, custodians, save path)
381
+ // =====================================================================
382
+
383
+ async function collectVaultConfig({ prefill, sourceUri }) {
384
+ const shares = Number(cancelIf(await p.text({
385
+ message: 'How many key shares should the vault be split into?',
386
+ placeholder: '3',
387
+ initialValue: prefill?.shares != null ? String(prefill.shares) : '3',
388
+ validate(v) {
389
+ const n = Number(v);
390
+ if (!Number.isInteger(n) || n < 1 || n > LIMITS.MAX_KEYS) return `Integer 1..${LIMITS.MAX_KEYS}.`;
391
+ },
392
+ })));
393
+
394
+ let threshold;
395
+ if (shares === 1) {
396
+ threshold = 1;
397
+ p.log.info('Single share → threshold is 1 (no Shamir split).');
398
+ } else {
399
+ threshold = Number(cancelIf(await p.text({
400
+ message: `How many shares are needed to unlock? (2..${shares})`,
401
+ placeholder: '2',
402
+ initialValue: prefill?.threshold != null ? String(prefill.threshold) : String(Math.min(2, shares)),
403
+ validate(v) {
404
+ const n = Number(v);
405
+ if (!Number.isInteger(n) || n < 2 || n > shares) return `Integer 2..${shares}.`;
406
+ },
407
+ })));
408
+ }
409
+
410
+ const vaultName = cancelIf(await p.text({
411
+ message: 'Vault name (shown on the printed kit):',
412
+ placeholder: 'Disaster Recovery Kit',
413
+ initialValue: prefill?.vaultName ?? 'Disaster Recovery Kit',
414
+ validate(v) { if (v && v.length > 100) return 'Keep it under 100 chars.'; },
415
+ }));
416
+
417
+ let custodianNames;
418
+ if (shares > 1) {
419
+ const useCustodians = cancelIf(await p.confirm({
420
+ message: `Assign custodian names to the ${shares} shares?`,
421
+ initialValue: prefill?.custodianNames != null,
422
+ }));
423
+ if (useCustodians) {
424
+ const raw = cancelIf(await p.text({
425
+ message: `Names (comma-separated, ${shares} total):`,
426
+ placeholder: 'alice, bob, carol',
427
+ initialValue: prefill?.custodianNames?.join(', '),
428
+ validate(v) {
429
+ const parts = (v ?? '').split(',').map(s => s.trim()).filter(Boolean);
430
+ if (parts.length !== shares) return `Need exactly ${shares} names, got ${parts.length}.`;
431
+ if (parts.some(s => s.length > 60)) return 'Each name must be 60 chars or fewer.';
432
+ },
433
+ }));
434
+ custodianNames = raw.split(',').map(s => s.trim()).filter(Boolean);
435
+ }
436
+ }
437
+
438
+ const useSavePath = cancelIf(await p.confirm({
439
+ message: 'Set a default save path for printable kits?',
440
+ initialValue: prefill?.savePath != null,
441
+ }));
442
+ let savePath;
443
+ if (useSavePath) {
444
+ savePath = expandTilde(cancelIf(await p.text({
445
+ message: 'Default save path (kits go in <path>/vault-<id>/):',
446
+ placeholder: '/Users/you/papervault-backups',
447
+ initialValue: prefill?.savePath,
448
+ validate(v) {
449
+ const expanded = expandTilde(v?.trim());
450
+ if (!expanded) return 'Required.';
451
+ if (!isAbsolute(expanded)) return 'Use an absolute path.';
452
+ },
453
+ })).trim());
454
+ }
455
+
456
+ return {
457
+ source: sourceUri,
458
+ threshold, shares, vaultName,
459
+ ...(custodianNames ? { custodianNames } : {}),
460
+ ...(savePath ? { savePath } : {}),
461
+ };
462
+ }
463
+
464
+ function formatConfigPreview(cfg) {
465
+ return [
466
+ `source ${cfg.source}`,
467
+ `vault ${cfg.vaultName}`,
468
+ `unlock ${cfg.threshold} of ${cfg.shares} shares`,
469
+ cfg.custodianNames ? `custodians ${cfg.custodianNames.join(', ')}` : null,
470
+ cfg.savePath ? `savePath ${cfg.savePath}` : null,
471
+ ].filter(Boolean).join('\n');
472
+ }
473
+
474
+ function guessSourceType(uri) {
475
+ if (!uri) return 'manual';
476
+ if (uri === '-' || uri.startsWith('stdin')) return 'stdin';
477
+ if (uri.startsWith('file://')) return 'file';
478
+ if (uri.startsWith('azure-kv://')) return 'azure';
479
+ return 'manual';
480
+ }
481
+
482
+ // =====================================================================
483
+ // One-shot flow: collected secrets → vault config → kit → exit
484
+ // =====================================================================
485
+
486
+ async function oneShotKitFlow({ secrets, sourceLabel }) {
487
+ p.log.info(`Configuring vault for ${secrets.length} secret${secrets.length === 1 ? '' : 's'} (${sourceLabel}).`);
488
+
489
+ const cfg = await collectVaultConfig({ prefill: null, sourceUri: sourceLabel });
490
+
491
+ const confirm = cancelIf(await p.confirm({
492
+ message: `Generate the kit now? (opens print dialog${cfg.savePath ? ' + saves files' : ''})`,
493
+ initialValue: true,
494
+ }));
495
+ if (!confirm) { p.cancel('Aborted. Secrets dropped, nothing written.'); return; }
496
+
497
+ p.outro('Generating kit…');
498
+
499
+ await runKitFlow({
500
+ secrets,
501
+ vaultName: cfg.vaultName,
502
+ threshold: cfg.threshold,
503
+ shares: cfg.shares,
504
+ custodianNames: cfg.custodianNames,
505
+ savePath: cfg.savePath,
506
+ print: true,
507
+ });
508
+ }
@@ -0,0 +1,25 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { SUPPORTED_SOURCES } from '../sources/index.js';
3
+
4
+ const HELP = `papervault sources — manage and inspect source backends
5
+
6
+ Usage:
7
+ papervault sources list List all supported source URI schemes
8
+ `;
9
+
10
+ export async function sourcesCmd(argv) {
11
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true });
12
+ const sub = positionals[0];
13
+
14
+ if (!sub || sub === 'list') {
15
+ const colWidth = Math.max(...SUPPORTED_SOURCES.map(s => s.scheme.length)) + 2;
16
+ for (const s of SUPPORTED_SOURCES) {
17
+ const status = s.status === 'ready' ? '✓ ready ' : '○ roadmap';
18
+ process.stdout.write(` ${status} ${s.scheme.padEnd(colWidth)} ${s.description}\n`);
19
+ }
20
+ return;
21
+ }
22
+
23
+ process.stderr.write(HELP);
24
+ process.exit(2);
25
+ }
@@ -0,0 +1,71 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { decrypt, combineShares } from '@papervault/core';
5
+ import { audit } from '../audit.js';
6
+
7
+ const HELP = `papervault verify <kit-dir> — decrypt-check a saved kit
8
+
9
+ Reads vault.html and key-*.html from a saved kit directory, extracts the QR
10
+ payloads, recombines a threshold of shares, and confirms the vault decrypts.
11
+
12
+ Outputs OK / FAILED and the secret count — never the secret values.
13
+
14
+ Usage:
15
+ papervault verify <kit-dir> [options]
16
+
17
+ Options:
18
+ --threshold N Number of shares to use (default: all that are present)
19
+ --help
20
+ `;
21
+
22
+ const OPTIONS = {
23
+ threshold: { type: 'string' },
24
+ help: { type: 'boolean' },
25
+ };
26
+
27
+ // Extract { ... } JSON payload from an HTML kit page. We embedded the raw
28
+ // JSON behind the QR image — but to keep verify self-contained we scan the
29
+ // rendered QR data URL's *source data* by re-parsing the HTML for the literal
30
+ // payload via a sentinel field. For MVP simplicity, we look for the QR <img
31
+ // alt> + a sibling <script id="payload"> we add when saving. If absent, the
32
+ // user can pass --threshold and we'll decrypt by recombining the cipherKey
33
+ // from key shares.
34
+ //
35
+ // MVP approach: the saved HTML pages don't expose the cipher payload directly
36
+ // (only the QR image). For verify we re-derive by reading the embedded
37
+ // metadata pill in the vault page. To keep this honest, MVP verify only works
38
+ // against saves produced by this CLI; we annotate a tiny JSON block in the
39
+ // HTML's <head> via a <meta name="papervault:debug" content='{...}'> tag.
40
+ //
41
+ // To keep the MVP small and safe, we instead require the user to point us at
42
+ // the JSON sidecar file (`vault.json`) we write alongside the HTML in --save
43
+ // mode (planned). For now, verify is a no-op stub.
44
+ //
45
+ // TODO(v0.2): emit a vault.json sidecar in --save and parse it here.
46
+
47
+ export async function verify(argv) {
48
+ const { values, positionals } = parseArgs({
49
+ args: argv, options: OPTIONS, allowPositionals: true,
50
+ });
51
+ if (values.help || positionals.length === 0) {
52
+ process.stdout.write(HELP);
53
+ return;
54
+ }
55
+ const dir = positionals[0];
56
+ let entries;
57
+ try {
58
+ entries = await readdir(dir);
59
+ } catch (err) {
60
+ throw new Error(`verify: cannot read ${dir} — ${err.message}`);
61
+ }
62
+
63
+ process.stderr.write(`verify: found ${entries.length} files in ${dir}\n`);
64
+ process.stderr.write('verify: full decrypt-roundtrip is on the v0.2 roadmap (needs vault.json sidecar).\n');
65
+ process.stderr.write('verify: for now you can confirm a kit by scanning QRs at papervault.xyz/unlock.\n');
66
+
67
+ await audit({ action: 'verify', outcome: 'not-implemented', dir });
68
+ // Touch the imports so they aren't unused — keeps the verify symbol shaped
69
+ // correctly for when we wire it up.
70
+ void decrypt; void combineShares; void readFile; void join;
71
+ }
package/src/config.js ADDED
@@ -0,0 +1,69 @@
1
+ // Persistent CLI config — lives at ./papervault.config.json by default.
2
+ // Generated by `papervault init`; consumed by `papervault backup` so users
3
+ // don't have to re-type flags every run.
4
+ //
5
+ // Schema is intentionally tiny — only the things that don't change between
6
+ // invocations. Secret values are NEVER stored here.
7
+
8
+ import { readFile, writeFile } from 'node:fs/promises';
9
+ import { existsSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ export const CONFIG_FILENAME = 'papervault.config.json';
13
+ export const CONFIG_VERSION = '1';
14
+
15
+ /**
16
+ * @typedef {object} PapervaultConfig
17
+ * @property {string} version
18
+ * @property {string} source Source URI (file://, stdin, azure-kv://, etc.)
19
+ * @property {number} threshold
20
+ * @property {number} shares
21
+ * @property {string} [vaultName]
22
+ * @property {string[]} [custodianNames]
23
+ * @property {string} [savePath] Absolute dir; backup will write <savePath>/vault-<id>/
24
+ * @property {string} [select]
25
+ * @property {number} [maxSecrets]
26
+ */
27
+
28
+ export function configPath(cwd = process.cwd()) {
29
+ return resolve(cwd, CONFIG_FILENAME);
30
+ }
31
+
32
+ export function configExists(cwd = process.cwd()) {
33
+ return existsSync(configPath(cwd));
34
+ }
35
+
36
+ /** @returns {Promise<PapervaultConfig|null>} */
37
+ export async function readConfig(cwd = process.cwd()) {
38
+ const p = configPath(cwd);
39
+ if (!existsSync(p)) return null;
40
+ let raw;
41
+ try { raw = await readFile(p, 'utf8'); }
42
+ catch (err) { throw new Error(`could not read ${p}: ${err.message}`); }
43
+ let doc;
44
+ try { doc = JSON.parse(raw); }
45
+ catch (err) { throw new Error(`invalid JSON in ${p}: ${err.message}`); }
46
+ if (doc.version !== CONFIG_VERSION) {
47
+ throw new Error(`${p} has version ${doc.version}; this CLI expects ${CONFIG_VERSION}.`);
48
+ }
49
+ return doc;
50
+ }
51
+
52
+ /** @param {PapervaultConfig} cfg */
53
+ export async function writeConfig(cfg, cwd = process.cwd()) {
54
+ const p = configPath(cwd);
55
+ // Sort keys for deterministic diffs.
56
+ const ordered = {
57
+ version: CONFIG_VERSION,
58
+ source: cfg.source,
59
+ threshold: cfg.threshold,
60
+ shares: cfg.shares,
61
+ ...(cfg.vaultName != null ? { vaultName: cfg.vaultName } : {}),
62
+ ...(cfg.custodianNames != null ? { custodianNames: cfg.custodianNames } : {}),
63
+ ...(cfg.savePath != null ? { savePath: cfg.savePath } : {}),
64
+ ...(cfg.select != null ? { select: cfg.select } : {}),
65
+ ...(cfg.maxSecrets != null ? { maxSecrets: cfg.maxSecrets } : {}),
66
+ };
67
+ await writeFile(p, JSON.stringify(ordered, null, 2) + '\n', 'utf8');
68
+ return p;
69
+ }
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+
4
+ main(process.argv.slice(2)).catch(err => {
5
+ process.stderr.write(`papervault: ${err.message}\n`);
6
+ if (process.env.PAPERVAULT_DEBUG) {
7
+ process.stderr.write(err.stack + '\n');
8
+ }
9
+ process.exit(1);
10
+ });