urchin-vault 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1206 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import inquirer from 'inquirer';
5
+ import { registerAuthCommands } from './commands/auth.js';
6
+ import { registerKeyCommands } from './commands/keys.js';
7
+ import { registerFileCommands } from './commands/files.js';
8
+ import { registerConfigCommands } from './commands/config.js';
9
+ import { banner, dashboard as renderDashboard, formatBytes, ACCENT, DIM, BOLD, CYAN, PURPLE, success, error as uiError, info, warn, sep, providerEmoji, createTable, progressBar, } from './lib/ui.js';
10
+ import { loadStore, saveStore, getSession } from './lib/store.js';
11
+ import { listProviders, getProvider } from './lib/storage.js';
12
+ import { restoreWeb2Session, createWeb2Secret, encryptFileWeb2, decryptFileWeb2 } from './lib/cifer.js';
13
+ import { fileSelector, ItemType } from 'inquirer-file-selector';
14
+ import { execFile, exec as execCb } from 'child_process';
15
+ import { promisify } from 'util';
16
+ import ora from 'ora';
17
+ import crypto from 'crypto';
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import os from 'os';
21
+ const execAsync = promisify(execFile);
22
+ const execShell = promisify(execCb);
23
+ const program = new Command();
24
+ program
25
+ .name('urchin')
26
+ .description(ACCENT.bold('Urchin') +
27
+ ' — Quantum-encrypted file vault\n\n' +
28
+ DIM(' Encrypt with CIFER (ML-KEM-768 + AES-256-GCM)\n') +
29
+ DIM(' Store on Filecoin, IPFS, or locally\n'))
30
+ .version('0.2.0')
31
+ .action(async () => {
32
+ await interactiveMode();
33
+ });
34
+ registerAuthCommands(program);
35
+ registerKeyCommands(program);
36
+ registerFileCommands(program);
37
+ registerConfigCommands(program);
38
+ // ─── Top-level shortcuts ─────────────────────────────────
39
+ program
40
+ .command('push')
41
+ .description('Encrypt & upload a file (interactive if no args)')
42
+ .argument('[file-path]', 'Path to file')
43
+ .option('-k, --key <name>', 'Encryption key name or ID')
44
+ .option('-p, --provider <name>', 'Storage provider (storacha, local)')
45
+ .action(async (filePath, opts) => {
46
+ const fileCmd = program.commands.find(c => c.name() === 'file');
47
+ const pushCmd = fileCmd?.commands.find((c) => c.name() === 'push');
48
+ if (pushCmd) {
49
+ await pushCmd.parseAsync([
50
+ ...(filePath ? [filePath] : []),
51
+ ...(opts.key ? ['-k', opts.key] : []),
52
+ ...(opts.provider ? ['-p', opts.provider] : []),
53
+ ], { from: 'user' });
54
+ }
55
+ });
56
+ program
57
+ .command('pull')
58
+ .description('Download & decrypt a file (interactive if no args)')
59
+ .argument('[name-or-id]', 'File name or ID')
60
+ .option('-o, --output <path>', 'Output path')
61
+ .action(async (nameOrId, opts) => {
62
+ const fileCmd = program.commands.find(c => c.name() === 'file');
63
+ const pullCmd = fileCmd?.commands.find((c) => c.name() === 'pull');
64
+ if (pullCmd) {
65
+ await pullCmd.parseAsync([...(nameOrId ? [nameOrId] : []), ...(opts.output ? ['-o', opts.output] : [])], { from: 'user' });
66
+ }
67
+ });
68
+ program
69
+ .command('ls')
70
+ .description('List files')
71
+ .option('-k, --key <name>', 'Filter by key')
72
+ .action(async (opts) => {
73
+ const fileCmd = program.commands.find(c => c.name() === 'file');
74
+ const listCmd = fileCmd?.commands.find((c) => c.name() === 'list');
75
+ if (listCmd) {
76
+ await listCmd.parseAsync([...(opts.key ? ['-k', opts.key] : [])], { from: 'user' });
77
+ }
78
+ });
79
+ program.parse();
80
+ // ═════════════════════════════════════════════════════════
81
+ // SYSTEM HELPERS
82
+ // ═════════════════════════════════════════════════════════
83
+ async function openInBrowser(url) {
84
+ try {
85
+ const platform = os.platform();
86
+ if (platform === 'darwin') {
87
+ await execAsync('open', [url]);
88
+ }
89
+ else if (platform === 'win32') {
90
+ await execAsync('cmd', ['/c', 'start', url]);
91
+ }
92
+ else {
93
+ await execAsync('xdg-open', [url]);
94
+ }
95
+ success(`Opened in browser`);
96
+ console.log(` ${DIM(url)}`);
97
+ }
98
+ catch {
99
+ // Fallback: just show the URL
100
+ info(`Open this URL in your browser:`);
101
+ console.log(` ${CYAN(url)}`);
102
+ }
103
+ }
104
+ async function copyToClipboard(text) {
105
+ try {
106
+ const platform = os.platform();
107
+ if (platform === 'darwin') {
108
+ await execShell(`echo ${JSON.stringify(text)} | pbcopy`);
109
+ }
110
+ else if (platform === 'win32') {
111
+ await execShell(`echo ${JSON.stringify(text)} | clip`);
112
+ }
113
+ else {
114
+ await execShell(`echo ${JSON.stringify(text)} | xclip -selection clipboard`);
115
+ }
116
+ success(`Copied to clipboard`);
117
+ console.log(` ${DIM(text)}`);
118
+ }
119
+ catch {
120
+ info(`CID: ${CYAN(text)}`);
121
+ console.log(` ${DIM('(could not copy to clipboard)')}`);
122
+ }
123
+ }
124
+ // ═════════════════════════════════════════════════════════
125
+ // FILE SELECTOR HELPERS
126
+ // ═════════════════════════════════════════════════════════
127
+ const fileSelectorTheme = {
128
+ style: {
129
+ file: (text) => ACCENT(text),
130
+ directory: (text) => CYAN.bold(text + '/'),
131
+ currentDir: (text) => chalk.white.bold(text),
132
+ help: () => DIM(' ↑↓ navigate → enter folder ← go back ⏎ confirm esc cancel'),
133
+ },
134
+ prefix: {
135
+ file: ' 📄 ',
136
+ directory: ' 📂 ',
137
+ currentDir: ' 📍 ',
138
+ },
139
+ };
140
+ const dirSelectorTheme = {
141
+ style: {
142
+ directory: (text) => CYAN.bold(text + '/'),
143
+ currentDir: (text) => chalk.white.bold(text),
144
+ help: () => DIM(' ↑↓ navigate → enter folder ← go back ⏎ select folder esc cancel'),
145
+ },
146
+ prefix: {
147
+ directory: ' 📂 ',
148
+ currentDir: ' 📍 ',
149
+ },
150
+ };
151
+ function defaultFilter(item) {
152
+ const name = path.basename(item.path);
153
+ if (name.startsWith('.') && name !== '..')
154
+ return false;
155
+ if (name === 'node_modules' || name === 'dist')
156
+ return false;
157
+ return true;
158
+ }
159
+ async function pickFile(message = '📄 Select file:') {
160
+ const result = await fileSelector({
161
+ message,
162
+ basePath: process.cwd(),
163
+ type: ItemType.File,
164
+ pageSize: 15,
165
+ allowCancel: true,
166
+ filter: defaultFilter,
167
+ theme: fileSelectorTheme,
168
+ });
169
+ return result ? result.path : null;
170
+ }
171
+ async function pickDirectory(message = '📂 Select folder:') {
172
+ const result = await fileSelector({
173
+ message,
174
+ basePath: process.cwd(),
175
+ type: ItemType.Directory,
176
+ pageSize: 15,
177
+ allowCancel: true,
178
+ filter: defaultFilter,
179
+ theme: dirSelectorTheme,
180
+ });
181
+ return result ? result.path : null;
182
+ }
183
+ async function promptSaveLocation() {
184
+ const { saveTo } = await inquirer.prompt([{
185
+ type: 'list',
186
+ name: 'saveTo',
187
+ message: '💾 Save to:',
188
+ choices: [
189
+ { name: `${ACCENT('.')} Current directory ${DIM(`(${process.cwd()})`)}`, value: 'cwd' },
190
+ { name: `${ACCENT('~')} Home ${DIM(`(${os.homedir()})`)}`, value: 'home' },
191
+ { name: `${ACCENT('⋯')} Desktop ${DIM(`(${path.join(os.homedir(), 'Desktop')})`)}`, value: 'desktop' },
192
+ { name: `${ACCENT('⋯')} Downloads ${DIM(`(${path.join(os.homedir(), 'Downloads')})`)}`, value: 'downloads' },
193
+ { name: `${ACCENT('📂')} Browse... ${DIM('choose a folder')}`, value: 'browse' },
194
+ ],
195
+ loop: false,
196
+ }]);
197
+ return saveTo;
198
+ }
199
+ async function resolveSavePath(saveTo, fileName) {
200
+ let outputDir;
201
+ if (saveTo === 'browse') {
202
+ const dir = await pickDirectory('📂 Select destination folder:');
203
+ if (!dir) {
204
+ info('Cancelled.');
205
+ return null;
206
+ }
207
+ outputDir = dir;
208
+ }
209
+ else {
210
+ const dirs = {
211
+ cwd: process.cwd(),
212
+ home: os.homedir(),
213
+ desktop: path.join(os.homedir(), 'Desktop'),
214
+ downloads: path.join(os.homedir(), 'Downloads'),
215
+ };
216
+ outputDir = dirs[saveTo] || process.cwd();
217
+ }
218
+ return path.join(outputDir, fileName);
219
+ }
220
+ // ═════════════════════════════════════════════════════════
221
+ // INTERACTIVE MODE
222
+ // ═════════════════════════════════════════════════════════
223
+ async function interactiveMode() {
224
+ const store = loadStore();
225
+ const s = store.session;
226
+ banner();
227
+ // Not logged in → auth menu
228
+ if (!s) {
229
+ console.log(` ${chalk.yellow('⚠')} Not logged in.\n`);
230
+ await authMenu();
231
+ return;
232
+ }
233
+ // Show dashboard
234
+ showDashboard();
235
+ // Main action loop
236
+ await mainMenu();
237
+ }
238
+ // ─── Auth Menu (not logged in) ──────────────────────────
239
+ async function authMenu() {
240
+ const { action } = await inquirer.prompt([{
241
+ type: 'list',
242
+ name: 'action',
243
+ message: 'What would you like to do?',
244
+ choices: [
245
+ { name: `${ACCENT('→')} Login to existing account`, value: 'login' },
246
+ { name: `${ACCENT('+')} Create a new account`, value: 'register' },
247
+ { name: `${DIM('×')} Exit`, value: 'exit' },
248
+ ],
249
+ loop: false,
250
+ }]);
251
+ if (action === 'login') {
252
+ await program.commands.find(c => c.name() === 'login')?.parseAsync([], { from: 'user' });
253
+ // After login, restart interactive
254
+ const store = loadStore();
255
+ if (store.session) {
256
+ console.log('');
257
+ showDashboard();
258
+ await mainMenu();
259
+ }
260
+ }
261
+ else if (action === 'register') {
262
+ await program.commands.find(c => c.name() === 'register')?.parseAsync([], { from: 'user' });
263
+ const store = loadStore();
264
+ if (store.session) {
265
+ console.log('');
266
+ showDashboard();
267
+ await mainMenu();
268
+ }
269
+ }
270
+ }
271
+ // ─── Main Menu (logged in) ──────────────────────────────
272
+ async function mainMenu() {
273
+ let running = true;
274
+ while (running) {
275
+ const store = loadStore();
276
+ const fileCount = store.files.length;
277
+ const keyCount = store.vaults.length;
278
+ console.log('');
279
+ const { action } = await inquirer.prompt([{
280
+ type: 'list',
281
+ name: 'action',
282
+ message: ACCENT('?') + ' What would you like to do?',
283
+ choices: [
284
+ new inquirer.Separator(DIM('─── Files ───')),
285
+ { name: `${ACCENT('↑')} Push a file ${DIM('encrypt & upload')}`, value: 'push' },
286
+ { name: `${ACCENT('↓')} Pull a file ${DIM('download & decrypt')}`, value: 'pull' },
287
+ { name: `${ACCENT('◆')} Browse files ${DIM(`${fileCount} file${fileCount !== 1 ? 's' : ''} in vault`)}`, value: 'browse' },
288
+ new inquirer.Separator(DIM('─── Keys ───')),
289
+ { name: `${ACCENT('+')} Create a key ${DIM('ML-KEM-768 key pair')}`, value: 'key-create' },
290
+ { name: `${ACCENT('⚷')} Manage keys ${DIM(`${keyCount} key${keyCount !== 1 ? 's' : ''}`)}`, value: 'key-manage' },
291
+ new inquirer.Separator(DIM('─── System ───')),
292
+ { name: `${ACCENT('☁')} Manage providers ${DIM('configure storage backends')}`, value: 'providers' },
293
+ { name: `${ACCENT('⚙')} Settings ${DIM('view & edit config')}`, value: 'settings' },
294
+ { name: `${ACCENT('○')} Who am I ${DIM('session info')}`, value: 'whoami' },
295
+ { name: `${DIM('×')} Exit`, value: 'exit' },
296
+ ],
297
+ loop: false,
298
+ pageSize: 18,
299
+ }]);
300
+ switch (action) {
301
+ case 'push':
302
+ await interactivePush();
303
+ break;
304
+ case 'pull':
305
+ await interactivePull();
306
+ break;
307
+ case 'browse':
308
+ await interactiveFileBrowser();
309
+ break;
310
+ case 'key-create':
311
+ await interactiveKeyCreate();
312
+ break;
313
+ case 'key-manage':
314
+ await interactiveKeyManage();
315
+ break;
316
+ case 'providers':
317
+ await interactiveProviders();
318
+ break;
319
+ case 'settings':
320
+ await interactiveSettings();
321
+ break;
322
+ case 'whoami':
323
+ interactiveWhoami();
324
+ break;
325
+ case 'exit':
326
+ running = false;
327
+ break;
328
+ }
329
+ }
330
+ }
331
+ // ═════════════════════════════════════════════════════════
332
+ // INTERACTIVE ACTIONS
333
+ // ═════════════════════════════════════════════════════════
334
+ // ─── Push ───────────────────────────────────────────────
335
+ async function interactivePush() {
336
+ try {
337
+ const session = getSession();
338
+ const store = loadStore();
339
+ const owner = session.mode === 'web2' ? session.email : session.walletAddress;
340
+ const keys = store.vaults.filter(v => v.owner === owner);
341
+ if (keys.length === 0) {
342
+ warn('No keys yet. Let\'s create one first.\n');
343
+ await interactiveKeyCreate();
344
+ return;
345
+ }
346
+ // 1. Pick file with visual browser
347
+ const filePath = await pickFile('📄 Select file to encrypt:');
348
+ if (!filePath) {
349
+ info('Cancelled.');
350
+ return;
351
+ }
352
+ // 2. Pick key
353
+ let key;
354
+ if (keys.length === 1) {
355
+ key = keys[0];
356
+ info(`Using key ${ACCENT.bold(key.name)}`);
357
+ }
358
+ else {
359
+ const { selected } = await inquirer.prompt([{
360
+ type: 'list',
361
+ name: 'selected',
362
+ message: ACCENT('⚷') + ' Encryption key:',
363
+ choices: keys.map(k => {
364
+ const fc = store.files.filter(f => f.vaultId === k.id).length;
365
+ return {
366
+ name: `${ACCENT('⚷')} ${BOLD(k.name)} ${DIM(`secret #${k.secretId}`)} ${DIM(`(${fc} files)`)}`,
367
+ value: k, short: k.name,
368
+ };
369
+ }),
370
+ loop: false,
371
+ }]);
372
+ key = selected;
373
+ }
374
+ // 3. Pick provider
375
+ const providers = listProviders();
376
+ let providerName = store.settings.defaultProvider || 'storacha';
377
+ if (providers.length > 1) {
378
+ const provDescs = {
379
+ storacha: 'Filecoin + IPFS (decentralized)',
380
+ local: 'Local encrypted storage',
381
+ };
382
+ const { selected } = await inquirer.prompt([{
383
+ type: 'list',
384
+ name: 'selected',
385
+ message: '📦 Storage provider:',
386
+ default: providerName,
387
+ choices: providers.map(name => ({
388
+ name: `${providerEmoji(name)} ${BOLD(name)} ${DIM(provDescs[name] || name)}${name === providerName ? ACCENT(' ★') : ''}`,
389
+ value: name, short: name,
390
+ })),
391
+ loop: false,
392
+ }]);
393
+ providerName = selected;
394
+ }
395
+ const provider = getProvider(providerName);
396
+ const provCheck = await provider.check();
397
+ if (!provCheck.ok) {
398
+ uiError(provCheck.error);
399
+ return;
400
+ }
401
+ // Read file
402
+ const absPath = path.resolve(filePath);
403
+ const fileName = path.basename(absPath);
404
+ const fileBuffer = fs.readFileSync(absPath);
405
+ // Flow display
406
+ console.log('');
407
+ console.log(` ${BOLD(fileName)} ${ACCENT('→')} ${ACCENT('⚷')} ${BOLD(key.name)} ${ACCENT('→')} ${providerEmoji(providerName)} ${BOLD(providerName)}`);
408
+ sep();
409
+ // Encrypt
410
+ const spinner = ora({ text: 'Connecting to CIFER...', indent: 2 }).start();
411
+ if (session.mode === 'web2')
412
+ await restoreWeb2Session(session);
413
+ spinner.text = `Encrypting ${BOLD(fileName)} ${DIM(`(${formatBytes(fileBuffer.byteLength)})`)}...`;
414
+ const { encryptedBlob } = await encryptFileWeb2(key.secretId, fileBuffer, fileName, (pct) => {
415
+ spinner.text = `Encrypting... ${pct}%`;
416
+ });
417
+ spinner.succeed(`Encrypted with ${ACCENT('ML-KEM-768')} + ${ACCENT('AES-256-GCM')}`);
418
+ // Upload
419
+ const uploadSpinner = ora({ text: `Uploading to ${BOLD(providerName)}...`, indent: 2 }).start();
420
+ const blobBuffer = Buffer.from(await encryptedBlob.arrayBuffer());
421
+ const cid = await provider.upload(blobBuffer, fileName);
422
+ uploadSpinner.succeed(`Stored on ${BOLD(providerName)}`);
423
+ // Save
424
+ const fileId = crypto.randomUUID();
425
+ store.files.push({
426
+ id: fileId, vaultId: key.id, originalName: fileName,
427
+ originalSize: fileBuffer.byteLength, encryptedCid: cid,
428
+ provider: providerName, ciferJobId: '', uploadedAt: new Date().toISOString(),
429
+ });
430
+ saveStore(store);
431
+ console.log('');
432
+ success(`${BOLD(fileName)} pushed`);
433
+ console.log(` ${DIM('Key:')} ${ACCENT('⚷')} ${key.name}`);
434
+ console.log(` ${DIM('Provider:')} ${providerEmoji(providerName)} ${providerName}`);
435
+ console.log(` ${DIM('CID:')} ${CYAN(cid)}`);
436
+ }
437
+ catch (err) {
438
+ uiError(err.message);
439
+ }
440
+ }
441
+ // ─── Pull ───────────────────────────────────────────────
442
+ async function interactivePull() {
443
+ try {
444
+ const session = getSession();
445
+ const store = loadStore();
446
+ if (store.files.length === 0) {
447
+ warn('No files in your vault yet.');
448
+ return;
449
+ }
450
+ // Pick file
451
+ const { selected } = await inquirer.prompt([{
452
+ type: 'list',
453
+ name: 'selected',
454
+ message: '📥 Select file to pull:',
455
+ choices: store.files.map(f => {
456
+ const key = store.vaults.find(v => v.id === f.vaultId);
457
+ const prov = f.provider || 'storacha';
458
+ return {
459
+ name: `${providerEmoji(prov)} ${BOLD(f.originalName)} ${DIM(formatBytes(f.originalSize))} ${ACCENT('⚷')} ${key?.name || '?'} ${DIM(f.uploadedAt.slice(0, 10))}`,
460
+ value: f, short: f.originalName,
461
+ };
462
+ }),
463
+ loop: false, pageSize: 10,
464
+ }]);
465
+ const fileRecord = selected;
466
+ const key = store.vaults.find(v => v.id === fileRecord.vaultId);
467
+ if (!key) {
468
+ uiError('Key not found');
469
+ return;
470
+ }
471
+ const providerName = fileRecord.provider || 'storacha';
472
+ const provider = getProvider(providerName);
473
+ console.log('');
474
+ console.log(` ${providerEmoji(providerName)} ${BOLD(providerName)} ${ACCENT('→')} ${ACCENT('⚷')} ${BOLD(key.name)} ${ACCENT('→')} ${BOLD(fileRecord.originalName)}`);
475
+ sep();
476
+ const spinner = ora({ text: 'Connecting to CIFER...', indent: 2 }).start();
477
+ if (session.mode === 'web2')
478
+ await restoreWeb2Session(session);
479
+ spinner.text = `Downloading from ${BOLD(providerName)}...`;
480
+ const encryptedBuffer = await provider.download(fileRecord.encryptedCid);
481
+ spinner.succeed(`Downloaded ${DIM(`(${formatBytes(encryptedBuffer.byteLength)})`)}`);
482
+ const decryptSpinner = ora({ text: 'Decrypting...', indent: 2 }).start();
483
+ const decryptedBlob = await decryptFileWeb2(key.secretId, encryptedBuffer, fileRecord.originalName, (pct) => {
484
+ decryptSpinner.text = `Decrypting... ${pct}%`;
485
+ });
486
+ decryptSpinner.succeed(`Decrypted with ${ACCENT('ML-KEM-768')} + ${ACCENT('AES-256-GCM')}`);
487
+ const saveTo = await promptSaveLocation();
488
+ if (!saveTo)
489
+ return;
490
+ const outputPath = await resolveSavePath(saveTo, fileRecord.originalName);
491
+ if (!outputPath)
492
+ return;
493
+ fs.writeFileSync(outputPath, Buffer.from(await decryptedBlob.arrayBuffer()));
494
+ console.log('');
495
+ success(`Saved to ${BOLD(outputPath)}`);
496
+ }
497
+ catch (err) {
498
+ uiError(err.message);
499
+ }
500
+ }
501
+ // ─── File Browser ───────────────────────────────────────
502
+ async function interactiveFileBrowser() {
503
+ let browsing = true;
504
+ while (browsing) {
505
+ const store = loadStore();
506
+ if (store.files.length === 0) {
507
+ warn('No files in your vault yet.');
508
+ return;
509
+ }
510
+ // Filter menu
511
+ const session = store.session;
512
+ const owner = session?.mode === 'web2' ? session.email : session?.walletAddress;
513
+ const keys = store.vaults.filter(v => v.owner === owner);
514
+ // Build filter choices
515
+ const filterChoices = [
516
+ { name: `${ACCENT('*')} All files ${DIM(`(${store.files.length})`)}`, value: 'all' },
517
+ ];
518
+ for (const k of keys) {
519
+ const count = store.files.filter(f => f.vaultId === k.id).length;
520
+ if (count > 0) {
521
+ filterChoices.push({
522
+ name: `${ACCENT('⚷')} ${k.name} ${DIM(`(${count} file${count !== 1 ? 's' : ''})`)}`,
523
+ value: `key:${k.id}`,
524
+ });
525
+ }
526
+ }
527
+ for (const prov of listProviders()) {
528
+ const count = store.files.filter(f => (f.provider || 'storacha') === prov).length;
529
+ if (count > 0) {
530
+ filterChoices.push({
531
+ name: `${providerEmoji(prov)} ${prov} ${DIM(`(${count} file${count !== 1 ? 's' : ''})`)}`,
532
+ value: `prov:${prov}`,
533
+ });
534
+ }
535
+ }
536
+ filterChoices.push({ name: `${DIM('←')} Back`, value: 'back' });
537
+ const { filter } = await inquirer.prompt([{
538
+ type: 'list',
539
+ name: 'filter',
540
+ message: '📂 Browse by:',
541
+ choices: filterChoices,
542
+ loop: false,
543
+ }]);
544
+ if (filter === 'back') {
545
+ browsing = false;
546
+ continue;
547
+ }
548
+ // Apply filter
549
+ let filteredFiles = store.files;
550
+ let filterLabel = 'All files';
551
+ if (filter.startsWith('key:')) {
552
+ const keyId = filter.slice(4);
553
+ filteredFiles = store.files.filter(f => f.vaultId === keyId);
554
+ const k = store.vaults.find(v => v.id === keyId);
555
+ filterLabel = `Key: ${k?.name || '?'}`;
556
+ }
557
+ else if (filter.startsWith('prov:')) {
558
+ const prov = filter.slice(5);
559
+ filteredFiles = store.files.filter(f => (f.provider || 'storacha') === prov);
560
+ filterLabel = `Provider: ${prov}`;
561
+ }
562
+ // File selection loop
563
+ let fileView = true;
564
+ while (fileView && filteredFiles.length > 0) {
565
+ const fileChoices = filteredFiles.map(f => {
566
+ const key = store.vaults.find(v => v.id === f.vaultId);
567
+ const prov = f.provider || 'storacha';
568
+ return {
569
+ name: `${providerEmoji(prov)} ${BOLD(f.originalName)} ${DIM(formatBytes(f.originalSize))} ${ACCENT('⚷')} ${key?.name || '?'} ${DIM(f.uploadedAt.slice(0, 10))}`,
570
+ value: f.id,
571
+ short: f.originalName,
572
+ };
573
+ });
574
+ fileChoices.push({ name: `${DIM('←')} Back`, value: 'back', short: 'Back' });
575
+ console.log(`\n ${DIM(filterLabel)} ${DIM(`— ${filteredFiles.length} file${filteredFiles.length !== 1 ? 's' : ''}`)}`);
576
+ const { fileId } = await inquirer.prompt([{
577
+ type: 'list',
578
+ name: 'fileId',
579
+ message: '📄 Select a file:',
580
+ choices: fileChoices,
581
+ loop: false,
582
+ pageSize: 15,
583
+ }]);
584
+ if (fileId === 'back') {
585
+ fileView = false;
586
+ continue;
587
+ }
588
+ // Show file detail + action menu
589
+ await fileDetailMenu(fileId);
590
+ // Refresh in case file was deleted
591
+ const refreshed = loadStore();
592
+ filteredFiles = filter === 'all'
593
+ ? refreshed.files
594
+ : filter.startsWith('key:')
595
+ ? refreshed.files.filter(f => f.vaultId === filter.slice(4))
596
+ : refreshed.files.filter(f => (f.provider || 'storacha') === filter.slice(5));
597
+ }
598
+ }
599
+ }
600
+ // ─── File Detail + Actions ──────────────────────────────
601
+ async function fileDetailMenu(fileId) {
602
+ const store = loadStore();
603
+ const f = store.files.find(x => x.id === fileId);
604
+ if (!f)
605
+ return;
606
+ const key = store.vaults.find(v => v.id === f.vaultId);
607
+ const prov = f.provider || 'storacha';
608
+ const provider = getProvider(prov);
609
+ // Show details
610
+ console.log('');
611
+ console.log(` ${ACCENT('─── File Details ───')}`);
612
+ console.log(` ${DIM('Name:')} ${BOLD(f.originalName)}`);
613
+ console.log(` ${DIM('Size:')} ${formatBytes(f.originalSize)}`);
614
+ console.log(` ${DIM('Key:')} ${ACCENT('⚷')} ${key?.name || '?'} ${DIM(`(secret #${key?.secretId || '?'})`)}`);
615
+ console.log(` ${DIM('Provider:')} ${providerEmoji(prov)} ${prov}`);
616
+ console.log(` ${DIM('CID:')} ${CYAN(f.encryptedCid)}`);
617
+ console.log(` ${DIM('URL:')} ${DIM(provider.getUrl(f.encryptedCid))}`);
618
+ console.log(` ${DIM('Uploaded:')} ${f.uploadedAt.slice(0, 10)}`);
619
+ console.log(` ${DIM('ID:')} ${DIM(f.id)}`);
620
+ const remoteUrl = provider.getUrl(f.encryptedCid);
621
+ const isRemote = prov !== 'local';
622
+ const { action } = await inquirer.prompt([{
623
+ type: 'list',
624
+ name: 'action',
625
+ message: 'Action:',
626
+ choices: [
627
+ { name: `${ACCENT('↓')} Pull (download & decrypt)`, value: 'pull' },
628
+ ...(isRemote ? [{ name: `${ACCENT('🌐')} Open in browser ${DIM('view encrypted on storage')}`, value: 'open' }] : []),
629
+ { name: `${ACCENT('⎘')} Copy CID`, value: 'copy-cid' },
630
+ { name: `${chalk.red('×')} Delete from vault`, value: 'delete' },
631
+ { name: `${DIM('←')} Back`, value: 'back' },
632
+ ],
633
+ loop: false,
634
+ }]);
635
+ if (action === 'pull') {
636
+ await pullFileById(fileId);
637
+ }
638
+ else if (action === 'open') {
639
+ await openInBrowser(remoteUrl);
640
+ }
641
+ else if (action === 'copy-cid') {
642
+ await copyToClipboard(f.encryptedCid);
643
+ }
644
+ else if (action === 'delete') {
645
+ await deleteFile(fileId);
646
+ }
647
+ }
648
+ // ─── Pull file by ID ────────────────────────────────────
649
+ async function pullFileById(fileId) {
650
+ try {
651
+ const session = getSession();
652
+ const store = loadStore();
653
+ const f = store.files.find(x => x.id === fileId);
654
+ if (!f) {
655
+ uiError('File not found');
656
+ return;
657
+ }
658
+ const key = store.vaults.find(v => v.id === f.vaultId);
659
+ if (!key) {
660
+ uiError('Key not found');
661
+ return;
662
+ }
663
+ const providerName = f.provider || 'storacha';
664
+ const provider = getProvider(providerName);
665
+ console.log('');
666
+ console.log(` ${providerEmoji(providerName)} ${BOLD(providerName)} ${ACCENT('→')} ${ACCENT('⚷')} ${BOLD(key.name)} ${ACCENT('→')} ${BOLD(f.originalName)}`);
667
+ sep();
668
+ const spinner = ora({ text: 'Connecting to CIFER...', indent: 2 }).start();
669
+ if (session.mode === 'web2')
670
+ await restoreWeb2Session(session);
671
+ spinner.text = `Downloading from ${BOLD(providerName)}...`;
672
+ const encryptedBuffer = await provider.download(f.encryptedCid);
673
+ spinner.succeed(`Downloaded ${DIM(`(${formatBytes(encryptedBuffer.byteLength)})`)}`);
674
+ const decryptSpinner = ora({ text: 'Decrypting...', indent: 2 }).start();
675
+ const decryptedBlob = await decryptFileWeb2(key.secretId, encryptedBuffer, f.originalName, (pct) => {
676
+ decryptSpinner.text = `Decrypting... ${pct}%`;
677
+ });
678
+ decryptSpinner.succeed(`Decrypted with ${ACCENT('ML-KEM-768')} + ${ACCENT('AES-256-GCM')}`);
679
+ const saveTo2 = await promptSaveLocation();
680
+ if (!saveTo2)
681
+ return;
682
+ const outputPath = await resolveSavePath(saveTo2, f.originalName);
683
+ if (!outputPath)
684
+ return;
685
+ fs.writeFileSync(outputPath, Buffer.from(await decryptedBlob.arrayBuffer()));
686
+ console.log('');
687
+ success(`Saved to ${BOLD(outputPath)}`);
688
+ }
689
+ catch (err) {
690
+ uiError(err.message);
691
+ }
692
+ }
693
+ // ─── Delete file ────────────────────────────────────────
694
+ async function deleteFile(fileId) {
695
+ const store = loadStore();
696
+ const idx = store.files.findIndex(f => f.id === fileId);
697
+ if (idx === -1) {
698
+ uiError('File not found');
699
+ return;
700
+ }
701
+ const f = store.files[idx];
702
+ const prov = f.provider || 'storacha';
703
+ // Confirm
704
+ const { confirm } = await inquirer.prompt([{
705
+ type: 'confirm',
706
+ name: 'confirm',
707
+ message: `${chalk.red('Delete')} ${BOLD(f.originalName)} from vault?`,
708
+ default: false,
709
+ }]);
710
+ if (!confirm)
711
+ return;
712
+ // For local provider, also offer to delete the encrypted file on disk
713
+ if (prov === 'local') {
714
+ const { deleteLocal } = await inquirer.prompt([{
715
+ type: 'confirm',
716
+ name: 'deleteLocal',
717
+ message: 'Also delete the encrypted file from disk?',
718
+ default: false,
719
+ }]);
720
+ if (deleteLocal) {
721
+ try {
722
+ const provider = getProvider('local');
723
+ const filePath = provider.getUrl(f.encryptedCid);
724
+ if (fs.existsSync(filePath)) {
725
+ fs.unlinkSync(filePath);
726
+ info(`Deleted encrypted file from disk`);
727
+ }
728
+ }
729
+ catch (err) {
730
+ warn(`Could not delete local file: ${err.message}`);
731
+ }
732
+ }
733
+ }
734
+ // Remove from index
735
+ store.files.splice(idx, 1);
736
+ saveStore(store);
737
+ if (prov !== 'local') {
738
+ success(`${BOLD(f.originalName)} removed from vault index`);
739
+ console.log(` ${DIM(`Encrypted data still exists on ${prov} (CID: ${f.encryptedCid})`)}`);
740
+ }
741
+ else {
742
+ success(`${BOLD(f.originalName)} deleted`);
743
+ }
744
+ }
745
+ // ─── Key Create ─────────────────────────────────────────
746
+ async function interactiveKeyCreate() {
747
+ try {
748
+ const session = getSession();
749
+ const { name } = await inquirer.prompt([{
750
+ type: 'input',
751
+ name: 'name',
752
+ message: ACCENT('⚷') + ' Key name:',
753
+ validate: (val) => val.trim() ? true : 'Name is required',
754
+ }]);
755
+ if (session.mode === 'web2') {
756
+ const spinner = ora({ text: 'Connecting to CIFER...', indent: 2 }).start();
757
+ await restoreWeb2Session(session);
758
+ spinner.text = `Generating ${ACCENT('ML-KEM-768')} key pair...`;
759
+ const secret = await createWeb2Secret();
760
+ spinner.succeed('Key pair generated');
761
+ const store = loadStore();
762
+ const keyId = crypto.randomUUID();
763
+ store.vaults.push({
764
+ id: keyId, name: name.trim(), secretId: secret.secretId,
765
+ authMode: 'web2', owner: session.email, createdAt: new Date().toISOString(),
766
+ });
767
+ saveStore(store);
768
+ console.log('');
769
+ success(`Key ${ACCENT.bold(name.trim())} created`);
770
+ console.log(` ${DIM('Secret:')} ${PURPLE(`#${secret.secretId}`)}`);
771
+ console.log(` ${DIM('Algo:')} ${CYAN('ML-KEM-768')} + ${CYAN('AES-256-GCM')}`);
772
+ }
773
+ else {
774
+ warn('Web3 key creation not yet implemented.');
775
+ }
776
+ }
777
+ catch (err) {
778
+ uiError(err.message);
779
+ }
780
+ }
781
+ // ─── Key Manager ────────────────────────────────────────
782
+ async function interactiveKeyManage() {
783
+ let managing = true;
784
+ while (managing) {
785
+ const store = loadStore();
786
+ const session = store.session;
787
+ if (!session)
788
+ return;
789
+ const owner = session.mode === 'web2' ? session.email : session.walletAddress;
790
+ const keys = store.vaults.filter(v => v.owner === owner);
791
+ if (keys.length === 0) {
792
+ warn('No keys yet.');
793
+ const { create } = await inquirer.prompt([{
794
+ type: 'confirm', name: 'create',
795
+ message: 'Create one now?', default: true,
796
+ }]);
797
+ if (create)
798
+ await interactiveKeyCreate();
799
+ return;
800
+ }
801
+ // Show table
802
+ console.log('');
803
+ const table = createTable(['', 'Name', 'Secret', 'Files', 'Size', 'Created']);
804
+ for (const k of keys) {
805
+ const fileCount = store.files.filter(f => f.vaultId === k.id).length;
806
+ const totalSize = store.files.filter(f => f.vaultId === k.id).reduce((sum, f) => sum + f.originalSize, 0);
807
+ table.push([
808
+ ACCENT('⚷'), BOLD(k.name), PURPLE(`#${k.secretId}`),
809
+ `${progressBar(fileCount, Math.max(fileCount, 5), 8)} ${DIM(String(fileCount))}`,
810
+ DIM(formatBytes(totalSize)), DIM(k.createdAt.slice(0, 10)),
811
+ ]);
812
+ }
813
+ console.log(table.toString().split('\n').map(l => ' ' + l).join('\n'));
814
+ console.log(` ${DIM(`${keys.length} key${keys.length !== 1 ? 's' : ''} • ML-KEM-768`)}`);
815
+ // Select a key or action
816
+ const choices = keys.map(k => {
817
+ const fc = store.files.filter(f => f.vaultId === k.id).length;
818
+ return {
819
+ name: `${ACCENT('⚷')} ${BOLD(k.name)} ${DIM(`#${k.secretId}`)} ${DIM(`(${fc} files)`)}`,
820
+ value: `key:${k.id}`,
821
+ short: k.name,
822
+ };
823
+ });
824
+ choices.push(new inquirer.Separator(' '), { name: `${ACCENT('+')} Create new key`, value: 'create' }, { name: `${DIM('←')} Back`, value: 'back' });
825
+ const { selected } = await inquirer.prompt([{
826
+ type: 'list',
827
+ name: 'selected',
828
+ message: ACCENT('⚷') + ' Select a key or action:',
829
+ choices,
830
+ loop: false,
831
+ pageSize: 15,
832
+ }]);
833
+ if (selected === 'back') {
834
+ managing = false;
835
+ }
836
+ else if (selected === 'create') {
837
+ await interactiveKeyCreate();
838
+ }
839
+ else if (selected.startsWith('key:')) {
840
+ await keyDetailMenu(selected.slice(4));
841
+ }
842
+ }
843
+ }
844
+ // ─── Key Detail + Actions ───────────────────────────────
845
+ async function keyDetailMenu(keyId) {
846
+ const store = loadStore();
847
+ const k = store.vaults.find(v => v.id === keyId);
848
+ if (!k)
849
+ return;
850
+ const files = store.files.filter(f => f.vaultId === k.id);
851
+ const totalSize = files.reduce((sum, f) => sum + f.originalSize, 0);
852
+ console.log('');
853
+ console.log(` ${ACCENT('─── Key Details ───')}`);
854
+ console.log(` ${DIM('Name:')} ${BOLD(k.name)}`);
855
+ console.log(` ${DIM('Secret:')} ${PURPLE(`#${k.secretId}`)}`);
856
+ console.log(` ${DIM('Algo:')} ${CYAN('ML-KEM-768')} + ${CYAN('AES-256-GCM')}`);
857
+ console.log(` ${DIM('Files:')} ${files.length}`);
858
+ console.log(` ${DIM('Total:')} ${formatBytes(totalSize)}`);
859
+ console.log(` ${DIM('Created:')} ${k.createdAt.slice(0, 10)}`);
860
+ console.log(` ${DIM('ID:')} ${DIM(k.id)}`);
861
+ if (files.length > 0) {
862
+ console.log(`\n ${DIM('Files in this key:')}`);
863
+ for (const f of files.slice(0, 5)) {
864
+ const prov = f.provider || 'storacha';
865
+ console.log(` ${providerEmoji(prov)} ${f.originalName} ${DIM(formatBytes(f.originalSize))} ${DIM(f.uploadedAt.slice(0, 10))}`);
866
+ }
867
+ if (files.length > 5) {
868
+ console.log(` ${DIM(`... and ${files.length - 5} more`)}`);
869
+ }
870
+ }
871
+ const { action } = await inquirer.prompt([{
872
+ type: 'list',
873
+ name: 'action',
874
+ message: 'Action:',
875
+ choices: [
876
+ { name: `${ACCENT('↑')} Push a file to this key`, value: 'push' },
877
+ ...(files.length > 0 ? [{ name: `${ACCENT('◆')} Browse files in this key`, value: 'browse' }] : []),
878
+ { name: `${chalk.red('×')} Delete this key`, value: 'delete' },
879
+ { name: `${DIM('←')} Back`, value: 'back' },
880
+ ],
881
+ loop: false,
882
+ }]);
883
+ if (action === 'push') {
884
+ // Push with this key pre-selected
885
+ await interactivePushWithKey(k);
886
+ }
887
+ else if (action === 'browse') {
888
+ // Quick file list for this key
889
+ for (const f of files) {
890
+ const prov = f.provider || 'storacha';
891
+ const { fileAction } = await inquirer.prompt([{
892
+ type: 'list',
893
+ name: 'fileAction',
894
+ message: `${providerEmoji(prov)} ${BOLD(f.originalName)} ${DIM(formatBytes(f.originalSize))}`,
895
+ choices: [
896
+ { name: `${ACCENT('↓')} Pull`, value: 'pull' },
897
+ { name: `${chalk.red('×')} Delete`, value: 'delete' },
898
+ { name: `${DIM('→')} Next file`, value: 'next' },
899
+ { name: `${DIM('←')} Done`, value: 'done' },
900
+ ],
901
+ loop: false,
902
+ }]);
903
+ if (fileAction === 'pull') {
904
+ await pullFileById(f.id);
905
+ }
906
+ else if (fileAction === 'delete') {
907
+ await deleteFile(f.id);
908
+ }
909
+ else if (fileAction === 'done') {
910
+ break;
911
+ }
912
+ }
913
+ }
914
+ else if (action === 'delete') {
915
+ const { confirm } = await inquirer.prompt([{
916
+ type: 'confirm',
917
+ name: 'confirm',
918
+ message: `${chalk.red('Delete')} key ${BOLD(k.name)}?${files.length > 0 ? ` (${files.length} files removed from index)` : ''}`,
919
+ default: false,
920
+ }]);
921
+ if (confirm) {
922
+ const latest = loadStore();
923
+ const idx = latest.vaults.findIndex(v => v.id === keyId);
924
+ if (idx !== -1) {
925
+ latest.vaults.splice(idx, 1);
926
+ latest.files = latest.files.filter(f => f.vaultId !== keyId);
927
+ saveStore(latest);
928
+ success(`Key ${BOLD(k.name)} deleted`);
929
+ }
930
+ }
931
+ }
932
+ }
933
+ // ─── Push with pre-selected key ─────────────────────────
934
+ async function interactivePushWithKey(key) {
935
+ try {
936
+ const session = getSession();
937
+ const store = loadStore();
938
+ const filePath = await pickFile('📄 Select file to encrypt:');
939
+ if (!filePath) {
940
+ info('Cancelled.');
941
+ return;
942
+ }
943
+ const providers = listProviders();
944
+ let providerName = store.settings.defaultProvider || 'storacha';
945
+ if (providers.length > 1) {
946
+ const provDescs = {
947
+ storacha: 'Filecoin + IPFS (decentralized)',
948
+ local: 'Local encrypted storage',
949
+ };
950
+ const { selected } = await inquirer.prompt([{
951
+ type: 'list',
952
+ name: 'selected',
953
+ message: '📦 Storage provider:',
954
+ default: providerName,
955
+ choices: providers.map(name => ({
956
+ name: `${providerEmoji(name)} ${BOLD(name)} ${DIM(provDescs[name] || name)}${name === providerName ? ACCENT(' ★') : ''}`,
957
+ value: name, short: name,
958
+ })),
959
+ loop: false,
960
+ }]);
961
+ providerName = selected;
962
+ }
963
+ const provider = getProvider(providerName);
964
+ const provCheck = await provider.check();
965
+ if (!provCheck.ok) {
966
+ uiError(provCheck.error);
967
+ return;
968
+ }
969
+ const absPath = path.resolve(filePath);
970
+ const fileName = path.basename(absPath);
971
+ const fileBuffer = fs.readFileSync(absPath);
972
+ console.log('');
973
+ console.log(` ${BOLD(fileName)} ${ACCENT('→')} ${ACCENT('⚷')} ${BOLD(key.name)} ${ACCENT('→')} ${providerEmoji(providerName)} ${BOLD(providerName)}`);
974
+ sep();
975
+ const spinner = ora({ text: 'Connecting to CIFER...', indent: 2 }).start();
976
+ if (session.mode === 'web2')
977
+ await restoreWeb2Session(session);
978
+ spinner.text = `Encrypting ${BOLD(fileName)} ${DIM(`(${formatBytes(fileBuffer.byteLength)})`)}...`;
979
+ const { encryptedBlob } = await encryptFileWeb2(key.secretId, fileBuffer, fileName, (pct) => {
980
+ spinner.text = `Encrypting... ${pct}%`;
981
+ });
982
+ spinner.succeed(`Encrypted with ${ACCENT('ML-KEM-768')} + ${ACCENT('AES-256-GCM')}`);
983
+ const uploadSpinner = ora({ text: `Uploading to ${BOLD(providerName)}...`, indent: 2 }).start();
984
+ const blobBuffer = Buffer.from(await encryptedBlob.arrayBuffer());
985
+ const cid = await provider.upload(blobBuffer, fileName);
986
+ uploadSpinner.succeed(`Stored on ${BOLD(providerName)}`);
987
+ const fileId = crypto.randomUUID();
988
+ store.files.push({
989
+ id: fileId, vaultId: key.id, originalName: fileName,
990
+ originalSize: fileBuffer.byteLength, encryptedCid: cid,
991
+ provider: providerName, ciferJobId: '', uploadedAt: new Date().toISOString(),
992
+ });
993
+ saveStore(store);
994
+ console.log('');
995
+ success(`${BOLD(fileName)} pushed`);
996
+ console.log(` ${DIM('Key:')} ${ACCENT('⚷')} ${key.name}`);
997
+ console.log(` ${DIM('Provider:')} ${providerEmoji(providerName)} ${providerName}`);
998
+ console.log(` ${DIM('CID:')} ${CYAN(cid)}`);
999
+ }
1000
+ catch (err) {
1001
+ uiError(err.message);
1002
+ }
1003
+ }
1004
+ // ─── Providers ──────────────────────────────────────────
1005
+ async function interactiveProviders() {
1006
+ const store = loadStore();
1007
+ const current = store.settings.defaultProvider || 'storacha';
1008
+ const provDescs = {
1009
+ storacha: 'Filecoin + IPFS (decentralized, permanent)',
1010
+ local: 'Local encrypted storage (offline)',
1011
+ };
1012
+ let back = false;
1013
+ while (!back) {
1014
+ // Show current providers
1015
+ console.log('');
1016
+ const table = createTable(['', 'Provider', 'Description', 'Files', '']);
1017
+ const latestStore = loadStore();
1018
+ const latestDefault = latestStore.settings.defaultProvider || 'storacha';
1019
+ for (const name of listProviders()) {
1020
+ const fileCount = latestStore.files.filter(f => (f.provider || 'storacha') === name).length;
1021
+ const isDefault = name === latestDefault;
1022
+ table.push([
1023
+ isDefault ? ACCENT('★') : ' ', BOLD(name),
1024
+ DIM(provDescs[name] || name), DIM(String(fileCount)),
1025
+ isDefault ? ACCENT('default') : '',
1026
+ ]);
1027
+ }
1028
+ console.log(table.toString().split('\n').map(l => ' ' + l).join('\n'));
1029
+ const { action } = await inquirer.prompt([{
1030
+ type: 'list',
1031
+ name: 'action',
1032
+ message: ACCENT('☁') + ' Provider actions:',
1033
+ choices: [
1034
+ { name: `${ACCENT('★')} Set default provider`, value: 'set-default' },
1035
+ { name: `${ACCENT('?')} Check provider status`, value: 'check' },
1036
+ { name: `${ACCENT('+')} Setup Storacha ${DIM('Filecoin setup guide')}`, value: 'setup-storacha' },
1037
+ { name: `${DIM('←')} Back`, value: 'back' },
1038
+ ],
1039
+ loop: false,
1040
+ }]);
1041
+ switch (action) {
1042
+ case 'set-default': {
1043
+ const { provider } = await inquirer.prompt([{
1044
+ type: 'list',
1045
+ name: 'provider',
1046
+ message: 'Set default storage provider:',
1047
+ default: latestDefault,
1048
+ choices: listProviders().map(name => ({
1049
+ name: `${providerEmoji(name)} ${BOLD(name)} ${DIM(provDescs[name] || name)}`,
1050
+ value: name, short: name,
1051
+ })),
1052
+ loop: false,
1053
+ }]);
1054
+ const s = loadStore();
1055
+ s.settings.defaultProvider = provider;
1056
+ saveStore(s);
1057
+ success(`Default provider set to ${BOLD(provider)}`);
1058
+ break;
1059
+ }
1060
+ case 'check': {
1061
+ for (const name of listProviders()) {
1062
+ const p = getProvider(name);
1063
+ const spinner = ora({ text: `Checking ${BOLD(name)}...`, indent: 2 }).start();
1064
+ const result = await p.check();
1065
+ if (result.ok) {
1066
+ spinner.succeed(`${BOLD(name)} ${ACCENT('ready')}`);
1067
+ }
1068
+ else {
1069
+ spinner.fail(`${BOLD(name)} ${DIM(result.error || 'not ready')}`);
1070
+ }
1071
+ }
1072
+ break;
1073
+ }
1074
+ case 'setup-storacha': {
1075
+ console.log('');
1076
+ console.log(` ${ACCENT('─── Storacha Setup Guide ───')}`);
1077
+ console.log('');
1078
+ console.log(` ${BOLD('1.')} Install the CLI:`);
1079
+ console.log(` ${CYAN('npm install -g @storacha/cli')}`);
1080
+ console.log('');
1081
+ console.log(` ${BOLD('2.')} Login with your email:`);
1082
+ console.log(` ${CYAN('storacha login you@email.com')}`);
1083
+ console.log('');
1084
+ console.log(` ${BOLD('3.')} Create a storage space:`);
1085
+ console.log(` ${CYAN('storacha space create my-vault')}`);
1086
+ console.log('');
1087
+ console.log(` ${BOLD('4.')} Provision with free plan:`);
1088
+ console.log(` ${CYAN('storacha space provision --customer you@email.com')}`);
1089
+ console.log('');
1090
+ console.log(` ${DIM('After setup, urchin will auto-detect Storacha.')}`);
1091
+ break;
1092
+ }
1093
+ case 'back':
1094
+ back = true;
1095
+ break;
1096
+ }
1097
+ }
1098
+ }
1099
+ // ─── Settings ───────────────────────────────────────────
1100
+ async function interactiveSettings() {
1101
+ const store = loadStore();
1102
+ console.log('');
1103
+ const table = createTable(['Setting', 'Value']);
1104
+ table.push([DIM('Blackbox URL'), store.settings.blackboxUrl], [DIM('Chain ID'), String(store.settings.chainId)], [DIM('Default Provider'), store.settings.defaultProvider || 'storacha'], [DIM('Keys'), String(store.vaults.length)], [DIM('Files'), String(store.files.length)]);
1105
+ console.log(table.toString().split('\n').map(l => ' ' + l).join('\n'));
1106
+ const { action } = await inquirer.prompt([{
1107
+ type: 'list',
1108
+ name: 'action',
1109
+ message: 'Edit a setting?',
1110
+ choices: [
1111
+ { name: `${ACCENT('⚙')} Change Blackbox URL`, value: 'blackboxUrl' },
1112
+ { name: `${ACCENT('⚙')} Change Chain ID`, value: 'chainId' },
1113
+ { name: `${ACCENT('⚙')} Change Default Provider`, value: 'defaultProvider' },
1114
+ { name: `${DIM('←')} Back`, value: 'back' },
1115
+ ],
1116
+ loop: false,
1117
+ }]);
1118
+ if (action === 'back')
1119
+ return;
1120
+ if (action === 'defaultProvider') {
1121
+ const { provider } = await inquirer.prompt([{
1122
+ type: 'list',
1123
+ name: 'provider',
1124
+ message: 'Select default provider:',
1125
+ default: store.settings.defaultProvider,
1126
+ choices: listProviders().map(name => ({
1127
+ name: `${providerEmoji(name)} ${BOLD(name)}`,
1128
+ value: name,
1129
+ })),
1130
+ loop: false,
1131
+ }]);
1132
+ store.settings.defaultProvider = provider;
1133
+ saveStore(store);
1134
+ success(`Default provider → ${BOLD(provider)}`);
1135
+ }
1136
+ else {
1137
+ const current = action === 'blackboxUrl' ? store.settings.blackboxUrl : String(store.settings.chainId);
1138
+ const { value } = await inquirer.prompt([{
1139
+ type: 'input',
1140
+ name: 'value',
1141
+ message: `New value for ${action}:`,
1142
+ default: current,
1143
+ }]);
1144
+ if (action === 'blackboxUrl') {
1145
+ store.settings.blackboxUrl = value;
1146
+ }
1147
+ else if (action === 'chainId') {
1148
+ store.settings.chainId = parseInt(value, 10);
1149
+ }
1150
+ saveStore(store);
1151
+ success(`${action} → ${BOLD(value)}`);
1152
+ }
1153
+ }
1154
+ // ─── Whoami ─────────────────────────────────────────────
1155
+ function interactiveWhoami() {
1156
+ const store = loadStore();
1157
+ if (!store.session) {
1158
+ warn('Not logged in.');
1159
+ return;
1160
+ }
1161
+ const s = store.session;
1162
+ console.log('');
1163
+ console.log(` ${DIM('Mode:')} ${s.mode}`);
1164
+ console.log(` ${DIM('Email:')} ${s.email || '—'}`);
1165
+ console.log(` ${DIM('Wallet:')} ${s.walletAddress || '—'}`);
1166
+ console.log(` ${DIM('Principal:')} ${s.principalId || '—'}`);
1167
+ }
1168
+ // ─── Dashboard renderer ─────────────────────────────────
1169
+ function showDashboard() {
1170
+ const store = loadStore();
1171
+ const s = store.session;
1172
+ const defaultProv = store.settings.defaultProvider || 'storacha';
1173
+ const providerDescs = {
1174
+ storacha: 'Filecoin + IPFS',
1175
+ local: 'Local encrypted storage',
1176
+ };
1177
+ const owner = s.mode === 'web2' ? s.email : s.walletAddress;
1178
+ const keys = store.vaults.filter(v => v.owner === owner);
1179
+ const recentFiles = [...store.files]
1180
+ .sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
1181
+ .slice(0, 5)
1182
+ .map(f => {
1183
+ const key = store.vaults.find(v => v.id === f.vaultId);
1184
+ return {
1185
+ name: f.originalName, size: f.originalSize,
1186
+ keyName: key?.name || '?', provider: f.provider || 'storacha',
1187
+ date: f.uploadedAt.slice(0, 10), cid: f.encryptedCid,
1188
+ };
1189
+ });
1190
+ renderDashboard({
1191
+ user: s.email || s.walletAddress || '—',
1192
+ mode: s.mode,
1193
+ providers: listProviders().map(name => ({
1194
+ name, desc: providerDescs[name] || name,
1195
+ fileCount: store.files.filter(f => (f.provider || 'storacha') === name).length,
1196
+ isDefault: name === defaultProv,
1197
+ })),
1198
+ keys: keys.map(k => ({
1199
+ name: k.name, secretId: k.secretId,
1200
+ fileCount: store.files.filter(f => f.vaultId === k.id).length,
1201
+ totalSize: store.files.filter(f => f.vaultId === k.id).reduce((sum, f) => sum + f.originalSize, 0),
1202
+ })),
1203
+ recentFiles,
1204
+ });
1205
+ }
1206
+ //# sourceMappingURL=cli.js.map