quilltap 3.3.0-dev.17 → 3.3.0-dev.176

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/README.md CHANGED
@@ -89,6 +89,23 @@ Quilltap stores its database, files, and logs in a platform-specific directory:
89
89
 
90
90
  Override with `--data-dir` or the `QUILLTAP_DATA_DIR` environment variable.
91
91
 
92
+ ## Theme Management
93
+
94
+ The CLI includes theme management commands:
95
+
96
+ ```bash
97
+ quilltap themes list # List all installed themes
98
+ quilltap themes install my.qtap-theme # Install a .qtap-theme bundle
99
+ quilltap themes validate my.qtap-theme # Validate a bundle
100
+ quilltap themes uninstall my-theme # Uninstall a bundle theme
101
+ quilltap themes export earl-grey # Export any theme as a bundle
102
+ quilltap themes create sunset # Scaffold a new theme
103
+ quilltap themes search "dark" # Search registries
104
+ quilltap themes update # Check for theme updates
105
+ quilltap themes registry list # List configured registries
106
+ quilltap themes registry add <url> # Add a registry source
107
+ ```
108
+
92
109
  ## Requirements
93
110
 
94
111
  - Node.js 18 or later
@@ -96,7 +113,7 @@ Override with `--data-dir` or the `QUILLTAP_DATA_DIR` environment variable.
96
113
  ## Other Ways to Run Quilltap
97
114
 
98
115
  - **Electron desktop app** (macOS, Windows) - [Download](https://github.com/foundry-9/quilltap/releases)
99
- - **Docker** - `docker run -d -p 3000:3000 -v /path/to/data:/app/quilltap csebold/quilltap`
116
+ - **Docker** - `docker run -d -p 3000:3000 -v /path/to/data:/app/quilltap foundry9/quilltap`
100
117
 
101
118
  ## Links
102
119
 
package/bin/quilltap.js CHANGED
@@ -391,10 +391,59 @@ function resolveDataDir(overrideDir) {
391
391
  return path.join(home, '.quilltap', 'data');
392
392
  }
393
393
 
394
+ /**
395
+ * Prompt for a passphrase interactively with hidden input.
396
+ * Returns a promise that resolves to the entered passphrase.
397
+ */
398
+ function promptPassphrase(prompt) {
399
+ return new Promise((resolve, reject) => {
400
+ const readline = require('readline');
401
+ if (!process.stdin.isTTY) {
402
+ reject(new Error('This database requires a passphrase. Use --passphrase <pass> or set QUILLTAP_DB_PASSPHRASE'));
403
+ return;
404
+ }
405
+ process.stdout.write(prompt || 'Passphrase: ');
406
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
407
+ // Disable echo by switching stdin to raw mode
408
+ process.stdin.setRawMode(true);
409
+ process.stdin.resume();
410
+ let passphrase = '';
411
+ const onData = (ch) => {
412
+ const c = ch.toString();
413
+ if (c === '\n' || c === '\r' || c === '\u0004') {
414
+ // Enter or Ctrl+D — done
415
+ process.stdin.setRawMode(false);
416
+ process.stdin.removeListener('data', onData);
417
+ process.stdin.pause();
418
+ rl.close();
419
+ process.stdout.write('\n');
420
+ resolve(passphrase);
421
+ } else if (c === '\u0003') {
422
+ // Ctrl+C — abort
423
+ process.stdin.setRawMode(false);
424
+ process.stdin.removeListener('data', onData);
425
+ process.stdin.pause();
426
+ rl.close();
427
+ process.stdout.write('\n');
428
+ process.exit(130);
429
+ } else if (c === '\u007F' || c === '\b') {
430
+ // Backspace
431
+ if (passphrase.length > 0) {
432
+ passphrase = passphrase.slice(0, -1);
433
+ }
434
+ } else {
435
+ passphrase += c;
436
+ }
437
+ };
438
+ process.stdin.on('data', onData);
439
+ });
440
+ }
441
+
394
442
  /**
395
443
  * Read and decrypt the .dbkey file to get the SQLCipher key.
444
+ * If passphrase is needed and not provided, prompts interactively.
396
445
  */
397
- function loadDbKey(dataDir, passphrase) {
446
+ async function loadDbKey(dataDir, passphrase) {
398
447
  const crypto = require('crypto');
399
448
  const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
400
449
  if (!fs.existsSync(dbkeyPath)) {
@@ -434,14 +483,297 @@ function loadDbKey(dataDir, passphrase) {
434
483
  // Internal passphrase failed — need user passphrase
435
484
  }
436
485
 
437
- // User passphrase required
486
+ // Check environment variable if no CLI passphrase provided
487
+ if (!passphrase && process.env.QUILLTAP_DB_PASSPHRASE) {
488
+ passphrase = process.env.QUILLTAP_DB_PASSPHRASE;
489
+ }
490
+
491
+ // Prompt interactively if still no passphrase
438
492
  if (!passphrase) {
439
- throw new Error('This database requires a passphrase. Use --passphrase <pass>');
493
+ passphrase = await promptPassphrase('Database passphrase: ');
494
+ if (!passphrase) {
495
+ throw new Error('No passphrase provided');
496
+ }
440
497
  }
441
498
 
442
499
  return tryDecrypt(passphrase);
443
500
  }
444
501
 
502
+ // ============================================================================
503
+ // Instance Lock CLI Commands
504
+ // ============================================================================
505
+
506
+ /**
507
+ * Check whether a PID is alive using signal 0.
508
+ */
509
+ function isPidAlive(pid) {
510
+ try {
511
+ process.kill(pid, 0);
512
+ return true;
513
+ } catch (err) {
514
+ return err.code === 'EPERM'; // EPERM = exists but no permission
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Verify a PID looks like a Node/Quilltap process (best-effort).
520
+ */
521
+ function verifyPidIsNode(pid, expectedArgv0) {
522
+ try {
523
+ if (process.platform === 'linux') {
524
+ try {
525
+ const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
526
+ const cmd = cmdline.split('\0')[0] || '';
527
+ return /node|electron|quilltap|next-server/i.test(cmd);
528
+ } catch {
529
+ return true; // Can't read — assume match
530
+ }
531
+ }
532
+ if (process.platform === 'darwin') {
533
+ const { execSync } = require('child_process');
534
+ const output = execSync(`ps -p ${pid} -o comm=`, {
535
+ encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
536
+ }).trim();
537
+ return /node|electron|quilltap|next-server/i.test(output);
538
+ }
539
+ if (process.platform === 'win32') {
540
+ const { execSync } = require('child_process');
541
+ const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
542
+ encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
543
+ }).trim();
544
+ return /node|electron|quilltap|next-server/i.test(output);
545
+ }
546
+ return true;
547
+ } catch {
548
+ return true;
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Handle --lock-status, --lock-clean, --lock-override commands.
554
+ */
555
+ function handleLockCommand(dataDir, opts) {
556
+ const lockPath = path.join(dataDir, 'quilltap.lock');
557
+ const hostname = require('os').hostname();
558
+
559
+ // Read lock file
560
+ let lock = null;
561
+ try {
562
+ if (fs.existsSync(lockPath)) {
563
+ const raw = fs.readFileSync(lockPath, 'utf8');
564
+ lock = JSON.parse(raw);
565
+ }
566
+ } catch (err) {
567
+ if (opts.lockStatus) {
568
+ console.log('Lock file exists but is corrupt or unreadable.');
569
+ console.log(` Path: ${lockPath}`);
570
+ if (opts.lockClean) {
571
+ fs.unlinkSync(lockPath);
572
+ console.log(' Removed corrupt lock file.');
573
+ }
574
+ }
575
+ return;
576
+ }
577
+
578
+ // --lock-status: display current lock state
579
+ if (opts.lockStatus) {
580
+ if (!lock) {
581
+ console.log('No instance lock found. Database is not currently claimed.');
582
+ console.log(` Lock path: ${lockPath}`);
583
+ return;
584
+ }
585
+
586
+ const sameHost = lock.hostname === hostname;
587
+ const alive = sameHost && isPidAlive(lock.pid);
588
+ const isNode = alive ? verifyPidIsNode(lock.pid, lock.processArgv0 || '') : false;
589
+
590
+ // Status line
591
+ let status;
592
+ if (alive && isNode) {
593
+ status = '\x1b[32mACTIVE\x1b[0m (process confirmed running)';
594
+ } else if (alive && !isNode) {
595
+ status = '\x1b[33mSUSPECT\x1b[0m (PID alive but does not look like Quilltap — possible PID reuse)';
596
+ } else if (!sameHost) {
597
+ // Different hostname — could be a VM/container on this machine
598
+ const isVMOrContainer = ['docker', 'lima', 'wsl2'].includes(lock.environment);
599
+ const heartbeatAgeMs = lock.lastHeartbeat
600
+ ? Date.now() - new Date(lock.lastHeartbeat).getTime()
601
+ : Infinity;
602
+ const heartbeatFreshMs = 5 * 60 * 1000;
603
+
604
+ if (isVMOrContainer && heartbeatAgeMs < heartbeatFreshMs) {
605
+ const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
606
+ status = `\x1b[32mACTIVE (${lock.environment}, heartbeat ${ageStr} ago)\x1b[0m`;
607
+ } else if (isVMOrContainer) {
608
+ status = `\x1b[33mSTALE (${lock.environment}, no recent heartbeat)\x1b[0m — will be auto-claimed on next startup`;
609
+ } else {
610
+ status = '\x1b[33mSTALE (different host)\x1b[0m — will be auto-claimed on next startup';
611
+ }
612
+ } else {
613
+ status = '\x1b[31mSTALE (process dead)\x1b[0m — will be auto-claimed on next startup';
614
+ }
615
+
616
+ console.log(`Instance Lock Status: ${status}`);
617
+ console.log();
618
+ console.log(` PID: ${lock.pid}`);
619
+ console.log(` Hostname: ${lock.hostname}${sameHost ? ' (this host)' : ' (different host)'}`);
620
+ console.log(` Environment: ${lock.environment || 'unknown'}`);
621
+ console.log(` Process: ${lock.processTitle || 'unknown'}`);
622
+ console.log(` Started: ${lock.startedAt || 'unknown'}`);
623
+
624
+ // Show heartbeat age if available
625
+ if (lock.lastHeartbeat) {
626
+ const heartbeatAge = Math.round((Date.now() - new Date(lock.lastHeartbeat).getTime()) / 1000);
627
+ let heartbeatDisplay;
628
+ if (heartbeatAge < 120) {
629
+ heartbeatDisplay = `${heartbeatAge}s ago`;
630
+ } else if (heartbeatAge < 7200) {
631
+ heartbeatDisplay = `${Math.round(heartbeatAge / 60)}m ago`;
632
+ } else {
633
+ heartbeatDisplay = `${Math.round(heartbeatAge / 3600)}h ago`;
634
+ }
635
+ // Warn if heartbeat is older than 5 minutes (process may be hung)
636
+ if (alive && heartbeatAge > 300) {
637
+ heartbeatDisplay = `\x1b[33m${heartbeatDisplay} (stale — process may be hung)\x1b[0m`;
638
+ }
639
+ console.log(` Heartbeat: ${heartbeatDisplay}`);
640
+ }
641
+
642
+ console.log(` Lock file: ${lockPath}`);
643
+
644
+ if (lock.history && lock.history.length > 0) {
645
+ console.log();
646
+ console.log(` Recent history (${lock.history.length} entries):`);
647
+ const recent = lock.history.slice(-10);
648
+ for (const entry of recent) {
649
+ const ts = entry.timestamp ? entry.timestamp.replace('T', ' ').replace(/\.\d+Z$/, 'Z') : '?';
650
+ const detail = entry.detail ? ` — ${entry.detail}` : '';
651
+ console.log(` [${ts}] ${entry.event} (PID ${entry.pid})${detail}`);
652
+ }
653
+ if (lock.history.length > 10) {
654
+ console.log(` ... and ${lock.history.length - 10} earlier entries`);
655
+ }
656
+ }
657
+ return;
658
+ }
659
+
660
+ // --lock-clean: remove stale locks only
661
+ if (opts.lockClean) {
662
+ if (!lock) {
663
+ console.log('No lock file found. Nothing to clean.');
664
+ return;
665
+ }
666
+
667
+ const sameHost = lock.hostname === hostname;
668
+ const alive = sameHost && isPidAlive(lock.pid);
669
+
670
+ if (alive) {
671
+ const isNode = verifyPidIsNode(lock.pid, lock.processArgv0 || '');
672
+ if (isNode) {
673
+ console.log(`Lock is held by a live Quilltap process (PID ${lock.pid}). Cannot clean.`);
674
+ console.log('Stop the running instance first, or use --lock-override to force.');
675
+ process.exit(1);
676
+ } else {
677
+ console.log(`Lock references PID ${lock.pid} which is alive but does NOT look like a Quilltap process.`);
678
+ console.log('This is likely a stale lock with a reused PID. Removing.');
679
+ }
680
+ } else if (!sameHost) {
681
+ // Different hostname — check if it's a VM/container with a recent heartbeat
682
+ const isVMOrContainer = ['docker', 'lima', 'wsl2'].includes(lock.environment);
683
+ const heartbeatAgeMs = lock.lastHeartbeat
684
+ ? Date.now() - new Date(lock.lastHeartbeat).getTime()
685
+ : Infinity;
686
+ const heartbeatFreshMs = 5 * 60 * 1000;
687
+
688
+ if (isVMOrContainer && heartbeatAgeMs < heartbeatFreshMs) {
689
+ const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
690
+ console.log(`Lock is held by a live ${lock.environment} instance (heartbeat ${ageStr} ago). Cannot clean.`);
691
+ console.log('Stop the other instance first, or use --lock-override to force.');
692
+ process.exit(1);
693
+ } else if (isVMOrContainer) {
694
+ console.log(`Lock was held by ${lock.environment} (${lock.hostname}) with no recent heartbeat. Removing stale lock.`);
695
+ } else {
696
+ console.log(`Lock was held by a different host (${lock.hostname}). Removing stale lock.`);
697
+ }
698
+ } else {
699
+ console.log(`Lock was held by PID ${lock.pid} which is no longer running. Removing stale lock.`);
700
+ }
701
+
702
+ // Write a final history entry before deleting
703
+ if (!lock.history) lock.history = [];
704
+ lock.history.push({
705
+ event: 'stale-claimed',
706
+ pid: process.pid,
707
+ hostname: hostname,
708
+ timestamp: new Date().toISOString(),
709
+ detail: `Cleaned via CLI (quilltap db --lock-clean)`,
710
+ });
711
+
712
+ // Write final state then remove
713
+ try {
714
+ fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
715
+ } catch { /* best effort */ }
716
+
717
+ try {
718
+ fs.unlinkSync(lockPath);
719
+ console.log('Lock file removed.');
720
+ } catch (err) {
721
+ console.error(`Failed to remove lock file: ${err.message}`);
722
+ process.exit(1);
723
+ }
724
+ return;
725
+ }
726
+
727
+ // --lock-override: forcibly claim the lock
728
+ if (opts.lockOverride) {
729
+ if (!lock) {
730
+ console.log('No lock file found. Nothing to override.');
731
+ return;
732
+ }
733
+
734
+ const sameHost = lock.hostname === hostname;
735
+ const alive = sameHost && isPidAlive(lock.pid);
736
+
737
+ if (alive) {
738
+ const isNode = verifyPidIsNode(lock.pid, lock.processArgv0 || '');
739
+ if (!isNode) {
740
+ console.error(`Lock override rejected: PID ${lock.pid} is alive but does not appear to be`);
741
+ console.error('a Quilltap/Node process. The PID may have been reused. Verify manually.');
742
+ process.exit(1);
743
+ }
744
+ console.log(`WARNING: Overriding lock held by live process (PID ${lock.pid}, ${lock.environment || 'unknown'}).`);
745
+ console.log('The other instance may corrupt the database if it is still writing.');
746
+ } else {
747
+ console.log(`Overriding stale lock (PID ${lock.pid} is no longer running).`);
748
+ }
749
+
750
+ // Record override in history
751
+ if (!lock.history) lock.history = [];
752
+ lock.history.push({
753
+ event: 'override',
754
+ pid: process.pid,
755
+ hostname: hostname,
756
+ timestamp: new Date().toISOString(),
757
+ detail: `Manual override via CLI (quilltap db --lock-override)` +
758
+ (alive ? ` — overriding live PID ${lock.pid}` : ` — PID ${lock.pid} was dead`),
759
+ });
760
+
761
+ // Write final state then remove so next startup gets a clean acquire
762
+ try {
763
+ fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
764
+ } catch { /* best effort */ }
765
+
766
+ try {
767
+ fs.unlinkSync(lockPath);
768
+ console.log('Lock file removed. Next Quilltap startup will acquire a fresh lock.');
769
+ } catch (err) {
770
+ console.error(`Failed to remove lock file: ${err.message}`);
771
+ process.exit(1);
772
+ }
773
+ return;
774
+ }
775
+ }
776
+
445
777
  function printDbHelp() {
446
778
  console.log(`
447
779
  Quilltap Database Tool
@@ -459,12 +791,24 @@ Options:
459
791
  --passphrase <pass> Provide passphrase for encrypted .dbkey
460
792
  -h, --help Show this help
461
793
 
794
+ Instance Lock Commands:
795
+ --lock-status Show the current instance lock state
796
+ --lock-clean Remove stale locks (dead processes only)
797
+ --lock-override Forcibly claim the lock for this process
798
+
799
+ If a passphrase is required and not provided via --passphrase, the tool
800
+ will check the QUILLTAP_DB_PASSPHRASE environment variable, then prompt
801
+ interactively (with hidden input) if a TTY is available.
802
+
462
803
  Examples:
463
804
  quilltap db --tables
464
805
  quilltap db "SELECT count(*) FROM characters"
465
806
  quilltap db --count messages
466
807
  quilltap db --repl
467
808
  quilltap db --llm-logs --tables
809
+ quilltap db --lock-status
810
+ quilltap db --lock-clean
811
+ QUILLTAP_DB_PASSPHRASE=secret quilltap db --tables
468
812
  `);
469
813
  }
470
814
 
@@ -477,6 +821,9 @@ async function dbCommand(args) {
477
821
  let repl = false;
478
822
  let sql = '';
479
823
  let showHelp = false;
824
+ let lockStatus = false;
825
+ let lockClean = false;
826
+ let lockOverride = false;
480
827
 
481
828
  let i = 0;
482
829
  while (i < args.length) {
@@ -488,6 +835,9 @@ async function dbCommand(args) {
488
835
  case '--count': countTable = args[++i]; break;
489
836
  case '--repl': repl = true; break;
490
837
  case '--help': case '-h': showHelp = true; break;
838
+ case '--lock-status': lockStatus = true; break;
839
+ case '--lock-clean': lockClean = true; break;
840
+ case '--lock-override': lockOverride = true; break;
491
841
  default:
492
842
  if (args[i].startsWith('-')) {
493
843
  console.error(`Unknown option: ${args[i]}`);
@@ -505,6 +855,13 @@ async function dbCommand(args) {
505
855
  }
506
856
 
507
857
  const dataDir = resolveDataDir(dataDirOverride);
858
+
859
+ // ---- Instance lock commands (no database open required) ----
860
+ if (lockStatus || lockClean || lockOverride) {
861
+ handleLockCommand(dataDir, { lockStatus, lockClean, lockOverride });
862
+ return;
863
+ }
864
+
508
865
  const dbFilename = useLlmLogs ? 'quilltap-llm-logs.db' : 'quilltap.db';
509
866
  const dbPath = path.join(dataDir, dbFilename);
510
867
 
@@ -516,7 +873,7 @@ async function dbCommand(args) {
516
873
  // Load encryption key
517
874
  let pepper;
518
875
  try {
519
- pepper = loadDbKey(dataDir, passphrase);
876
+ pepper = await loadDbKey(dataDir, passphrase);
520
877
  } catch (err) {
521
878
  console.error(`Error: ${err.message}`);
522
879
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "3.3.0-dev.17",
3
+ "version": "3.3.0-dev.176",
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,12 +33,12 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "better-sqlite3-multiple-ciphers": "^12.6.2",
36
+ "better-sqlite3-multiple-ciphers": "^12.8.0",
37
37
  "sharp": "^0.34.5",
38
- "tar": "^7.4.3",
39
- "yauzl": "^3.2.0"
38
+ "tar": "^7.5.13",
39
+ "yauzl": "^3.2.1"
40
40
  },
41
41
  "engines": {
42
- "node": ">=18.0.0"
42
+ "node": ">=22.0.0"
43
43
  }
44
44
  }