quilltap 3.2.0-dev.18 → 3.2.0-dev.38

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 (2) hide show
  1. package/bin/quilltap.js +249 -1
  2. package/package.json +2 -2
package/bin/quilltap.js CHANGED
@@ -359,4 +359,252 @@ async function main() {
359
359
  });
360
360
  }
361
361
 
362
- main();
362
+ // ============================================================================
363
+ // db subcommand — query encrypted databases directly
364
+ // ============================================================================
365
+
366
+ /**
367
+ * Resolve the data directory using the same platform logic as lib/paths.ts.
368
+ */
369
+ function resolveDataDir(overrideDir) {
370
+ if (overrideDir) {
371
+ const resolved = overrideDir.startsWith('~')
372
+ ? path.join(require('os').homedir(), overrideDir.slice(1))
373
+ : overrideDir;
374
+ return path.join(resolved, 'data');
375
+ }
376
+ const os = require('os');
377
+ if (process.env.QUILLTAP_DATA_DIR) {
378
+ return path.join(process.env.QUILLTAP_DATA_DIR, 'data');
379
+ }
380
+ const home = os.homedir();
381
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap', 'data');
382
+ if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap', 'data');
383
+ return path.join(home, '.quilltap', 'data');
384
+ }
385
+
386
+ /**
387
+ * Read and decrypt the .dbkey file to get the SQLCipher key.
388
+ */
389
+ function loadDbKey(dataDir, passphrase) {
390
+ const crypto = require('crypto');
391
+ const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
392
+ if (!fs.existsSync(dbkeyPath)) {
393
+ return null; // No .dbkey file — DB may be unencrypted
394
+ }
395
+
396
+ const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
397
+ const INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
398
+
399
+ // Strip legacy hasPassphrase field if present
400
+ if ('hasPassphrase' in data) {
401
+ delete data.hasPassphrase;
402
+ fs.writeFileSync(dbkeyPath, JSON.stringify(data, null, 2), { mode: 0o600 });
403
+ }
404
+
405
+ // Helper to attempt decryption with a given passphrase
406
+ function tryDecrypt(pass) {
407
+ const salt = Buffer.from(data.salt, 'hex');
408
+ const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
409
+ const iv = Buffer.from(data.iv, 'hex');
410
+ const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
411
+ decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
412
+ let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
413
+ plaintext += decipher.final('utf8');
414
+
415
+ const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
416
+ if (hash !== data.pepperHash) {
417
+ throw new Error('Pepper hash mismatch');
418
+ }
419
+ return plaintext;
420
+ }
421
+
422
+ // Try internal passphrase first (no user passphrase case)
423
+ try {
424
+ return tryDecrypt(INTERNAL_PASSPHRASE);
425
+ } catch {
426
+ // Internal passphrase failed — need user passphrase
427
+ }
428
+
429
+ // User passphrase required
430
+ if (!passphrase) {
431
+ throw new Error('This database requires a passphrase. Use --passphrase <pass>');
432
+ }
433
+
434
+ return tryDecrypt(passphrase);
435
+ }
436
+
437
+ function printDbHelp() {
438
+ console.log(`
439
+ Quilltap Database Tool
440
+
441
+ Usage: quilltap db [options] [sql]
442
+
443
+ Query your encrypted Quilltap database directly.
444
+
445
+ Options:
446
+ --tables List all tables
447
+ --count <table> Show row count for a table
448
+ --repl Interactive SQL prompt
449
+ --llm-logs Target the LLM logs database
450
+ --data-dir <path> Override data directory
451
+ --passphrase <pass> Provide passphrase for encrypted .dbkey
452
+ -h, --help Show this help
453
+
454
+ Examples:
455
+ quilltap db --tables
456
+ quilltap db "SELECT count(*) FROM characters"
457
+ quilltap db --count messages
458
+ quilltap db --repl
459
+ quilltap db --llm-logs --tables
460
+ `);
461
+ }
462
+
463
+ async function dbCommand(args) {
464
+ let dataDirOverride = '';
465
+ let passphrase = '';
466
+ let useLlmLogs = false;
467
+ let showTables = false;
468
+ let countTable = '';
469
+ let repl = false;
470
+ let sql = '';
471
+ let showHelp = false;
472
+
473
+ let i = 0;
474
+ while (i < args.length) {
475
+ switch (args[i]) {
476
+ case '--data-dir': case '-d': dataDirOverride = args[++i]; break;
477
+ case '--passphrase': passphrase = args[++i]; break;
478
+ case '--llm-logs': useLlmLogs = true; break;
479
+ case '--tables': showTables = true; break;
480
+ case '--count': countTable = args[++i]; break;
481
+ case '--repl': repl = true; break;
482
+ case '--help': case '-h': showHelp = true; break;
483
+ default:
484
+ if (args[i].startsWith('-')) {
485
+ console.error(`Unknown option: ${args[i]}`);
486
+ process.exit(1);
487
+ }
488
+ sql = args[i];
489
+ break;
490
+ }
491
+ i++;
492
+ }
493
+
494
+ if (showHelp) {
495
+ printDbHelp();
496
+ process.exit(0);
497
+ }
498
+
499
+ const dataDir = resolveDataDir(dataDirOverride);
500
+ const dbFilename = useLlmLogs ? 'quilltap-llm-logs.db' : 'quilltap.db';
501
+ const dbPath = path.join(dataDir, dbFilename);
502
+
503
+ if (!fs.existsSync(dbPath)) {
504
+ console.error(`Database not found: ${dbPath}`);
505
+ process.exit(1);
506
+ }
507
+
508
+ // Load encryption key
509
+ let pepper;
510
+ try {
511
+ pepper = loadDbKey(dataDir, passphrase);
512
+ } catch (err) {
513
+ console.error(`Error: ${err.message}`);
514
+ process.exit(1);
515
+ }
516
+
517
+ // Open database
518
+ const Database = require('better-sqlite3');
519
+ const db = new Database(dbPath, { readonly: !repl });
520
+
521
+ if (pepper) {
522
+ const keyHex = Buffer.from(pepper, 'base64').toString('hex');
523
+ db.pragma(`key = "x'${keyHex}'"`);
524
+ }
525
+
526
+ try {
527
+ // Verify database is readable
528
+ db.prepare('SELECT 1').get();
529
+ } catch (err) {
530
+ console.error(`Cannot open database: ${err.message}`);
531
+ console.error('The database may be encrypted with a different key, or the .dbkey file may be missing.');
532
+ db.close();
533
+ process.exit(1);
534
+ }
535
+
536
+ try {
537
+ if (showTables) {
538
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
539
+ for (const t of tables) console.log(t.name);
540
+ } else if (countTable) {
541
+ const row = db.prepare(`SELECT count(*) as count FROM "${countTable}"`).get();
542
+ console.log(row.count);
543
+ } else if (sql) {
544
+ const stmt = db.prepare(sql);
545
+ if (stmt.reader) {
546
+ const rows = stmt.all();
547
+ if (rows.length === 0) {
548
+ console.log('(no results)');
549
+ } else {
550
+ console.table(rows);
551
+ }
552
+ } else {
553
+ const info = stmt.run();
554
+ console.log(`Changes: ${info.changes}`);
555
+ }
556
+ } else if (repl) {
557
+ const readline = require('readline');
558
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'quilltap> ' });
559
+ console.log(`Connected to ${dbPath}`);
560
+ console.log('Type .tables, .schema <table>, or SQL. Ctrl+D to exit.\n');
561
+ rl.prompt();
562
+ rl.on('line', (line) => {
563
+ const trimmed = line.trim();
564
+ if (!trimmed) { rl.prompt(); return; }
565
+ try {
566
+ if (trimmed === '.tables') {
567
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
568
+ for (const t of tables) console.log(t.name);
569
+ } else if (trimmed.startsWith('.schema')) {
570
+ const table = trimmed.split(/\s+/)[1];
571
+ if (!table) { console.log('Usage: .schema <table>'); }
572
+ else {
573
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE name = ?").get(table);
574
+ console.log(row ? row.sql : `Table '${table}' not found`);
575
+ }
576
+ } else {
577
+ const stmt = db.prepare(trimmed);
578
+ if (stmt.reader) {
579
+ const rows = stmt.all();
580
+ if (rows.length === 0) console.log('(no results)');
581
+ else console.table(rows);
582
+ } else {
583
+ const info = stmt.run();
584
+ console.log(`Changes: ${info.changes}`);
585
+ }
586
+ }
587
+ } catch (err) {
588
+ console.error(`Error: ${err.message}`);
589
+ }
590
+ rl.prompt();
591
+ });
592
+ rl.on('close', () => {
593
+ db.close();
594
+ process.exit(0);
595
+ });
596
+ return; // Don't close db yet — REPL is interactive
597
+ } else {
598
+ printDbHelp();
599
+ }
600
+ } finally {
601
+ if (!repl) db.close();
602
+ }
603
+ }
604
+
605
+ // Route to subcommand or main
606
+ if (process.argv[2] === 'db') {
607
+ dbCommand(process.argv.slice(3));
608
+ } else {
609
+ main();
610
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "3.2.0-dev.18",
3
+ "version": "3.2.0-dev.38",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "better-sqlite3": "^12.6.2",
36
+ "better-sqlite3": "npm:better-sqlite3-multiple-ciphers@^12.6.2",
37
37
  "sharp": "^0.34.5",
38
38
  "tar": "^7.4.3"
39
39
  },