quilltap 4.3.0-dev.88 → 4.3.1
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/bin/quilltap.js +21 -130
- package/lib/db-helpers.js +153 -0
- package/lib/docs-commands.js +629 -0
- package/package.json +1 -1
package/bin/quilltap.js
CHANGED
|
@@ -5,6 +5,7 @@ const { fork, exec, execSync } = require('child_process');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const { getCacheDir, isCacheValid, ensureStandalone } = require('../lib/download-manager');
|
|
8
|
+
const { resolveDataDir, promptPassphrase, loadDbKey } = require('../lib/db-helpers');
|
|
8
9
|
|
|
9
10
|
const PACKAGE_DIR = path.resolve(__dirname, '..');
|
|
10
11
|
|
|
@@ -77,6 +78,7 @@ Usage: quilltap [options]
|
|
|
77
78
|
Subcommands:
|
|
78
79
|
db Query encrypted databases
|
|
79
80
|
themes Manage theme bundles
|
|
81
|
+
docs Inspect, read, and export document mounts
|
|
80
82
|
|
|
81
83
|
Options:
|
|
82
84
|
-p, --port <number> Port to listen on (default: 3000)
|
|
@@ -368,137 +370,10 @@ async function main() {
|
|
|
368
370
|
}
|
|
369
371
|
|
|
370
372
|
// ============================================================================
|
|
371
|
-
// db subcommand
|
|
373
|
+
// db subcommand helpers (resolveDataDir, promptPassphrase, loadDbKey) live in
|
|
374
|
+
// ../lib/db-helpers.js so the docs subcommand can share them.
|
|
372
375
|
// ============================================================================
|
|
373
376
|
|
|
374
|
-
/**
|
|
375
|
-
* Resolve the data directory using the same platform logic as lib/paths.ts.
|
|
376
|
-
*/
|
|
377
|
-
function resolveDataDir(overrideDir) {
|
|
378
|
-
if (overrideDir) {
|
|
379
|
-
const resolved = overrideDir.startsWith('~')
|
|
380
|
-
? path.join(require('os').homedir(), overrideDir.slice(1))
|
|
381
|
-
: overrideDir;
|
|
382
|
-
return path.join(resolved, 'data');
|
|
383
|
-
}
|
|
384
|
-
const os = require('os');
|
|
385
|
-
if (process.env.QUILLTAP_DATA_DIR) {
|
|
386
|
-
return path.join(process.env.QUILLTAP_DATA_DIR, 'data');
|
|
387
|
-
}
|
|
388
|
-
const home = os.homedir();
|
|
389
|
-
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap', 'data');
|
|
390
|
-
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap', 'data');
|
|
391
|
-
return path.join(home, '.quilltap', 'data');
|
|
392
|
-
}
|
|
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
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Read and decrypt the .dbkey file to get the SQLCipher key.
|
|
444
|
-
* If passphrase is needed and not provided, prompts interactively.
|
|
445
|
-
*/
|
|
446
|
-
async function loadDbKey(dataDir, passphrase) {
|
|
447
|
-
const crypto = require('crypto');
|
|
448
|
-
const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
|
|
449
|
-
if (!fs.existsSync(dbkeyPath)) {
|
|
450
|
-
return null; // No .dbkey file — DB may be unencrypted
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
|
|
454
|
-
const INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
|
|
455
|
-
|
|
456
|
-
// Strip legacy hasPassphrase field if present
|
|
457
|
-
if ('hasPassphrase' in data) {
|
|
458
|
-
delete data.hasPassphrase;
|
|
459
|
-
fs.writeFileSync(dbkeyPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Helper to attempt decryption with a given passphrase
|
|
463
|
-
function tryDecrypt(pass) {
|
|
464
|
-
const salt = Buffer.from(data.salt, 'hex');
|
|
465
|
-
const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
|
|
466
|
-
const iv = Buffer.from(data.iv, 'hex');
|
|
467
|
-
const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
468
|
-
decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
|
|
469
|
-
let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
|
|
470
|
-
plaintext += decipher.final('utf8');
|
|
471
|
-
|
|
472
|
-
const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
473
|
-
if (hash !== data.pepperHash) {
|
|
474
|
-
throw new Error('Pepper hash mismatch');
|
|
475
|
-
}
|
|
476
|
-
return plaintext;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Try internal passphrase first (no user passphrase case)
|
|
480
|
-
try {
|
|
481
|
-
return tryDecrypt(INTERNAL_PASSPHRASE);
|
|
482
|
-
} catch {
|
|
483
|
-
// Internal passphrase failed — need user passphrase
|
|
484
|
-
}
|
|
485
|
-
|
|
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
|
|
492
|
-
if (!passphrase) {
|
|
493
|
-
passphrase = await promptPassphrase('Database passphrase: ');
|
|
494
|
-
if (!passphrase) {
|
|
495
|
-
throw new Error('No passphrase provided');
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return tryDecrypt(passphrase);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
377
|
// ============================================================================
|
|
503
378
|
// Instance Lock CLI Commands
|
|
504
379
|
// ============================================================================
|
|
@@ -787,6 +662,7 @@ Options:
|
|
|
787
662
|
--count <table> Show row count for a table
|
|
788
663
|
--repl Interactive SQL prompt
|
|
789
664
|
--llm-logs Target the LLM logs database
|
|
665
|
+
--mount-points Target the document mount-index database
|
|
790
666
|
--data-dir <path> Override data directory
|
|
791
667
|
--passphrase <pass> Provide passphrase for encrypted .dbkey
|
|
792
668
|
-h, --help Show this help
|
|
@@ -806,6 +682,8 @@ Examples:
|
|
|
806
682
|
quilltap db --count messages
|
|
807
683
|
quilltap db --repl
|
|
808
684
|
quilltap db --llm-logs --tables
|
|
685
|
+
quilltap db --mount-points --tables
|
|
686
|
+
quilltap db --mount-points "SELECT id, name FROM doc_mount_points"
|
|
809
687
|
quilltap db --lock-status
|
|
810
688
|
quilltap db --lock-clean
|
|
811
689
|
QUILLTAP_DB_PASSPHRASE=secret quilltap db --tables
|
|
@@ -816,6 +694,7 @@ async function dbCommand(args) {
|
|
|
816
694
|
let dataDirOverride = '';
|
|
817
695
|
let passphrase = '';
|
|
818
696
|
let useLlmLogs = false;
|
|
697
|
+
let useMountPoints = false;
|
|
819
698
|
let showTables = false;
|
|
820
699
|
let countTable = '';
|
|
821
700
|
let repl = false;
|
|
@@ -831,6 +710,7 @@ async function dbCommand(args) {
|
|
|
831
710
|
case '--data-dir': case '-d': dataDirOverride = args[++i]; break;
|
|
832
711
|
case '--passphrase': passphrase = args[++i]; break;
|
|
833
712
|
case '--llm-logs': useLlmLogs = true; break;
|
|
713
|
+
case '--mount-points': useMountPoints = true; break;
|
|
834
714
|
case '--tables': showTables = true; break;
|
|
835
715
|
case '--count': countTable = args[++i]; break;
|
|
836
716
|
case '--repl': repl = true; break;
|
|
@@ -862,7 +742,15 @@ async function dbCommand(args) {
|
|
|
862
742
|
return;
|
|
863
743
|
}
|
|
864
744
|
|
|
865
|
-
|
|
745
|
+
if (useLlmLogs && useMountPoints) {
|
|
746
|
+
console.error('Error: --llm-logs and --mount-points are mutually exclusive');
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
let dbFilename;
|
|
751
|
+
if (useLlmLogs) dbFilename = 'quilltap-llm-logs.db';
|
|
752
|
+
else if (useMountPoints) dbFilename = 'quilltap-mount-index.db';
|
|
753
|
+
else dbFilename = 'quilltap.db';
|
|
866
754
|
const dbPath = path.join(dataDir, dbFilename);
|
|
867
755
|
|
|
868
756
|
if (!fs.existsSync(dbPath)) {
|
|
@@ -978,6 +866,9 @@ if (process.argv[2] === 'db') {
|
|
|
978
866
|
} else if (process.argv[2] === 'themes') {
|
|
979
867
|
const { themesCommand } = require('../lib/theme-commands');
|
|
980
868
|
themesCommand(process.argv.slice(3));
|
|
869
|
+
} else if (process.argv[2] === 'docs') {
|
|
870
|
+
const { docsCommand } = require('../lib/docs-commands');
|
|
871
|
+
docsCommand(process.argv.slice(3));
|
|
981
872
|
} else {
|
|
982
873
|
main();
|
|
983
874
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
const CTRL_C = String.fromCharCode(3);
|
|
7
|
+
const CTRL_D = String.fromCharCode(4);
|
|
8
|
+
const DEL = String.fromCharCode(0x7F);
|
|
9
|
+
|
|
10
|
+
function resolveDataDir(overrideDir) {
|
|
11
|
+
if (overrideDir) {
|
|
12
|
+
const resolved = overrideDir.startsWith('~')
|
|
13
|
+
? path.join(require('os').homedir(), overrideDir.slice(1))
|
|
14
|
+
: overrideDir;
|
|
15
|
+
return path.join(resolved, 'data');
|
|
16
|
+
}
|
|
17
|
+
const os = require('os');
|
|
18
|
+
if (process.env.QUILLTAP_DATA_DIR) {
|
|
19
|
+
return path.join(process.env.QUILLTAP_DATA_DIR, 'data');
|
|
20
|
+
}
|
|
21
|
+
const home = os.homedir();
|
|
22
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap', 'data');
|
|
23
|
+
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap', 'data');
|
|
24
|
+
return path.join(home, '.quilltap', 'data');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function promptPassphrase(prompt) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const readline = require('readline');
|
|
30
|
+
if (!process.stdin.isTTY) {
|
|
31
|
+
reject(new Error('This database requires a passphrase. Use --passphrase <pass> or set QUILLTAP_DB_PASSPHRASE'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write(prompt || 'Passphrase: ');
|
|
35
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
36
|
+
process.stdin.setRawMode(true);
|
|
37
|
+
process.stdin.resume();
|
|
38
|
+
let passphrase = '';
|
|
39
|
+
const onData = (ch) => {
|
|
40
|
+
const c = ch.toString();
|
|
41
|
+
if (c === '\n' || c === '\r' || c === CTRL_D) {
|
|
42
|
+
process.stdin.setRawMode(false);
|
|
43
|
+
process.stdin.removeListener('data', onData);
|
|
44
|
+
process.stdin.pause();
|
|
45
|
+
rl.close();
|
|
46
|
+
process.stdout.write('\n');
|
|
47
|
+
resolve(passphrase);
|
|
48
|
+
} else if (c === CTRL_C) {
|
|
49
|
+
process.stdin.setRawMode(false);
|
|
50
|
+
process.stdin.removeListener('data', onData);
|
|
51
|
+
process.stdin.pause();
|
|
52
|
+
rl.close();
|
|
53
|
+
process.stdout.write('\n');
|
|
54
|
+
process.exit(130);
|
|
55
|
+
} else if (c === DEL || c === '\b') {
|
|
56
|
+
if (passphrase.length > 0) {
|
|
57
|
+
passphrase = passphrase.slice(0, -1);
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
passphrase += c;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
process.stdin.on('data', onData);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadDbKey(dataDir, passphrase) {
|
|
68
|
+
const crypto = require('crypto');
|
|
69
|
+
const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
|
|
70
|
+
if (!fs.existsSync(dbkeyPath)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
|
|
75
|
+
const INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
|
|
76
|
+
|
|
77
|
+
if ('hasPassphrase' in data) {
|
|
78
|
+
delete data.hasPassphrase;
|
|
79
|
+
fs.writeFileSync(dbkeyPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tryDecrypt(pass) {
|
|
83
|
+
const salt = Buffer.from(data.salt, 'hex');
|
|
84
|
+
const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
|
|
85
|
+
const iv = Buffer.from(data.iv, 'hex');
|
|
86
|
+
const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
87
|
+
decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
|
|
88
|
+
let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
|
|
89
|
+
plaintext += decipher.final('utf8');
|
|
90
|
+
|
|
91
|
+
const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
92
|
+
if (hash !== data.pepperHash) {
|
|
93
|
+
throw new Error('Pepper hash mismatch');
|
|
94
|
+
}
|
|
95
|
+
return plaintext;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
return tryDecrypt(INTERNAL_PASSPHRASE);
|
|
100
|
+
} catch {
|
|
101
|
+
// Internal passphrase failed — need user passphrase
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!passphrase && process.env.QUILLTAP_DB_PASSPHRASE) {
|
|
105
|
+
passphrase = process.env.QUILLTAP_DB_PASSPHRASE;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!passphrase) {
|
|
109
|
+
passphrase = await promptPassphrase('Database passphrase: ');
|
|
110
|
+
if (!passphrase) {
|
|
111
|
+
throw new Error('No passphrase provided');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return tryDecrypt(passphrase);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function openMountIndexDb(dataDir, pepper, { readonly = true } = {}) {
|
|
119
|
+
const dbPath = path.join(dataDir, 'quilltap-mount-index.db');
|
|
120
|
+
if (!fs.existsSync(dbPath)) {
|
|
121
|
+
throw new Error(`Mount index database not found: ${dbPath}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let Database;
|
|
125
|
+
try {
|
|
126
|
+
Database = require('better-sqlite3-multiple-ciphers');
|
|
127
|
+
} catch {
|
|
128
|
+
Database = require('better-sqlite3');
|
|
129
|
+
}
|
|
130
|
+
const db = new Database(dbPath, { readonly });
|
|
131
|
+
|
|
132
|
+
if (pepper) {
|
|
133
|
+
const keyHex = Buffer.from(pepper, 'base64').toString('hex');
|
|
134
|
+
db.pragma(`key = "x'${keyHex}'"`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
db.prepare('SELECT 1').get();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
db.close();
|
|
141
|
+
throw new Error(`Cannot open mount index database: ${err.message}\n` +
|
|
142
|
+
'The database may be encrypted with a different key, or the .dbkey file may be missing.');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return db;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
resolveDataDir,
|
|
150
|
+
promptPassphrase,
|
|
151
|
+
loadDbKey,
|
|
152
|
+
openMountIndexDb,
|
|
153
|
+
};
|
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { resolveDataDir, loadDbKey, openMountIndexDb } = require('./db-helpers');
|
|
6
|
+
|
|
7
|
+
const RESET = '\x1b[0m';
|
|
8
|
+
const BOLD = '\x1b[1m';
|
|
9
|
+
const DIM = '\x1b[2m';
|
|
10
|
+
const GREEN = '\x1b[32m';
|
|
11
|
+
const RED = '\x1b[31m';
|
|
12
|
+
const YELLOW = '\x1b[33m';
|
|
13
|
+
const CYAN = '\x1b[36m';
|
|
14
|
+
|
|
15
|
+
const TEXT_FILE_TYPES = new Set(['markdown', 'txt', 'json', 'jsonl']);
|
|
16
|
+
const BINARY_FILE_TYPES = new Set(['pdf', 'docx', 'blob']);
|
|
17
|
+
|
|
18
|
+
function printDocsHelp() {
|
|
19
|
+
console.log(`
|
|
20
|
+
Quilltap Document Store Tool
|
|
21
|
+
|
|
22
|
+
Usage: quilltap docs <subcommand> [options]
|
|
23
|
+
|
|
24
|
+
Subcommands:
|
|
25
|
+
list List all mount points
|
|
26
|
+
show <id> Details for one mount point
|
|
27
|
+
files <id> [--folder <path>] List files in a mount
|
|
28
|
+
read <id> <relativePath> Print file contents to stdout
|
|
29
|
+
read --rendered <id> <relativePath> Print extracted plaintext to stdout
|
|
30
|
+
export <id> <outputDir> Export an entire mount to a directory
|
|
31
|
+
scan <id> Trigger a rescan via the running server
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-d, --data-dir <path> Override data directory
|
|
35
|
+
--passphrase <pass> Decrypt .dbkey if peppered
|
|
36
|
+
--port <number> Server port for API calls (default: 3000)
|
|
37
|
+
--json Machine-readable output (list/show/files/scan)
|
|
38
|
+
--rendered For 'read': output extracted plaintext
|
|
39
|
+
--folder <path> For 'files': narrow to a folder prefix
|
|
40
|
+
--force For 'read': dump binary to TTY anyway
|
|
41
|
+
-h, --help Show this help
|
|
42
|
+
|
|
43
|
+
Read-only operations (list, show, files, read, export) open the mount-index
|
|
44
|
+
database directly. Write operations (scan) require the Quilltap server to be
|
|
45
|
+
running on the chosen --port.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
quilltap docs list
|
|
49
|
+
quilltap docs list --json
|
|
50
|
+
quilltap docs show <mount-id>
|
|
51
|
+
quilltap docs files <mount-id> --folder notes/2026
|
|
52
|
+
quilltap docs read <mount-id> notes/today.md
|
|
53
|
+
quilltap docs read --rendered <mount-id> papers/foo.pdf
|
|
54
|
+
quilltap docs read <mount-id> images/avatar.webp > /tmp/avatar.webp
|
|
55
|
+
quilltap docs export <mount-id> /tmp/quilltap-mount-backup
|
|
56
|
+
quilltap docs scan <mount-id>
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseFlags(args) {
|
|
61
|
+
const flags = {
|
|
62
|
+
dataDir: '',
|
|
63
|
+
passphrase: '',
|
|
64
|
+
port: 3000,
|
|
65
|
+
json: false,
|
|
66
|
+
rendered: false,
|
|
67
|
+
folder: '',
|
|
68
|
+
force: false,
|
|
69
|
+
help: false,
|
|
70
|
+
};
|
|
71
|
+
const positional = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < args.length) {
|
|
74
|
+
const a = args[i];
|
|
75
|
+
switch (a) {
|
|
76
|
+
case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
|
|
77
|
+
case '--passphrase': flags.passphrase = args[++i]; break;
|
|
78
|
+
case '--port': {
|
|
79
|
+
const p = parseInt(args[++i], 10);
|
|
80
|
+
if (isNaN(p) || p < 1 || p > 65535) {
|
|
81
|
+
console.error('Error: --port must be between 1 and 65535');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
flags.port = p;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case '--json': flags.json = true; break;
|
|
88
|
+
case '--rendered': flags.rendered = true; break;
|
|
89
|
+
case '--folder': flags.folder = args[++i]; break;
|
|
90
|
+
case '--force': flags.force = true; break;
|
|
91
|
+
case '-h': case '--help': flags.help = true; break;
|
|
92
|
+
default:
|
|
93
|
+
if (a.startsWith('-')) {
|
|
94
|
+
console.error(`Unknown option: ${a}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
positional.push(a);
|
|
98
|
+
}
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
return { flags, positional };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function openDb(flags) {
|
|
105
|
+
const dataDir = resolveDataDir(flags.dataDir);
|
|
106
|
+
const pepper = await loadDbKey(dataDir, flags.passphrase);
|
|
107
|
+
const db = openMountIndexDb(dataDir, pepper, { readonly: true });
|
|
108
|
+
return { db, dataDir };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function requireMount(db, id) {
|
|
112
|
+
const row = db.prepare('SELECT * FROM doc_mount_points WHERE id = ?').get(id);
|
|
113
|
+
if (!row) {
|
|
114
|
+
console.error(`No mount point found with id ${id}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
return row;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatBytes(n) {
|
|
121
|
+
if (n < 1024) return `${n} B`;
|
|
122
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
123
|
+
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
124
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ----------------------------------------------------------------------------
|
|
128
|
+
// list
|
|
129
|
+
// ----------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async function handleList(flags) {
|
|
132
|
+
const { db } = await openDb(flags);
|
|
133
|
+
try {
|
|
134
|
+
const rows = db.prepare(`
|
|
135
|
+
SELECT id, name, mountType, storeType, basePath, enabled,
|
|
136
|
+
fileCount, chunkCount, totalSizeBytes, scanStatus
|
|
137
|
+
FROM doc_mount_points
|
|
138
|
+
ORDER BY name COLLATE NOCASE
|
|
139
|
+
`).all();
|
|
140
|
+
if (flags.json) {
|
|
141
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
console.log('(no mount points)');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const display = rows.map(r => ({
|
|
149
|
+
id: r.id,
|
|
150
|
+
name: r.name,
|
|
151
|
+
type: r.mountType,
|
|
152
|
+
store: r.storeType,
|
|
153
|
+
enabled: r.enabled ? 'yes' : 'no',
|
|
154
|
+
files: r.fileCount,
|
|
155
|
+
chunks: r.chunkCount,
|
|
156
|
+
size: formatBytes(r.totalSizeBytes || 0),
|
|
157
|
+
status: r.scanStatus,
|
|
158
|
+
}));
|
|
159
|
+
console.table(display);
|
|
160
|
+
} finally {
|
|
161
|
+
db.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ----------------------------------------------------------------------------
|
|
166
|
+
// show
|
|
167
|
+
// ----------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
async function handleShow(flags, id) {
|
|
170
|
+
if (!id) {
|
|
171
|
+
console.error('Usage: quilltap docs show <mount-id>');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const { db } = await openDb(flags);
|
|
175
|
+
try {
|
|
176
|
+
const mount = requireMount(db, id);
|
|
177
|
+
const fileCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_files WHERE mountPointId = ?').get(id).n;
|
|
178
|
+
const chunkCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_chunks WHERE mountPointId = ?').get(id).n;
|
|
179
|
+
const blobCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_blobs WHERE mountPointId = ?').get(id).n;
|
|
180
|
+
const docCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_documents WHERE mountPointId = ?').get(id).n;
|
|
181
|
+
|
|
182
|
+
const liveCounts = {
|
|
183
|
+
filesActual: fileCount,
|
|
184
|
+
chunksActual: chunkCount,
|
|
185
|
+
blobsActual: blobCount,
|
|
186
|
+
documentsActual: docCount,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (flags.json) {
|
|
190
|
+
process.stdout.write(JSON.stringify({ ...mount, ...liveCounts }, null, 2) + '\n');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(`${BOLD}${mount.name}${RESET} ${DIM}${mount.id}${RESET}`);
|
|
195
|
+
console.log(` Type: ${mount.mountType} / ${mount.storeType}`);
|
|
196
|
+
console.log(` Base path: ${mount.basePath || DIM + '(database-backed)' + RESET}`);
|
|
197
|
+
console.log(` Enabled: ${mount.enabled ? GREEN + 'yes' + RESET : RED + 'no' + RESET}`);
|
|
198
|
+
console.log(` Scan status: ${mount.scanStatus}${mount.lastScanError ? ' ' + RED + mount.lastScanError + RESET : ''}`);
|
|
199
|
+
console.log(` Conversion: ${mount.conversionStatus}${mount.conversionError ? ' ' + RED + mount.conversionError + RESET : ''}`);
|
|
200
|
+
console.log(` Last scanned: ${mount.lastScannedAt || DIM + 'never' + RESET}`);
|
|
201
|
+
console.log(` Cached counts: ${mount.fileCount} files, ${mount.chunkCount} chunks, ${formatBytes(mount.totalSizeBytes || 0)}`);
|
|
202
|
+
console.log(` Live counts: ${fileCount} files, ${chunkCount} chunks, ${blobCount} blobs, ${docCount} db-docs`);
|
|
203
|
+
if (fileCount !== mount.fileCount || chunkCount !== mount.chunkCount) {
|
|
204
|
+
console.log(` ${YELLOW}Note: cached counts disagree with live counts; consider 'docs scan'${RESET}`);
|
|
205
|
+
}
|
|
206
|
+
console.log(` Created: ${mount.createdAt}`);
|
|
207
|
+
console.log(` Updated: ${mount.updatedAt}`);
|
|
208
|
+
} finally {
|
|
209
|
+
db.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ----------------------------------------------------------------------------
|
|
214
|
+
// files
|
|
215
|
+
// ----------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
async function handleFiles(flags, id) {
|
|
218
|
+
if (!id) {
|
|
219
|
+
console.error('Usage: quilltap docs files <mount-id> [--folder <path>]');
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
const { db } = await openDb(flags);
|
|
223
|
+
try {
|
|
224
|
+
requireMount(db, id);
|
|
225
|
+
let rows;
|
|
226
|
+
if (flags.folder) {
|
|
227
|
+
const prefix = flags.folder.replace(/\/+$/, '') + '/';
|
|
228
|
+
rows = db.prepare(`
|
|
229
|
+
SELECT relativePath, fileType, source, fileSizeBytes, chunkCount, conversionStatus
|
|
230
|
+
FROM doc_mount_files
|
|
231
|
+
WHERE mountPointId = ? AND relativePath LIKE ?
|
|
232
|
+
ORDER BY relativePath
|
|
233
|
+
`).all(id, prefix + '%');
|
|
234
|
+
} else {
|
|
235
|
+
rows = db.prepare(`
|
|
236
|
+
SELECT relativePath, fileType, source, fileSizeBytes, chunkCount, conversionStatus
|
|
237
|
+
FROM doc_mount_files
|
|
238
|
+
WHERE mountPointId = ?
|
|
239
|
+
ORDER BY relativePath
|
|
240
|
+
`).all(id);
|
|
241
|
+
}
|
|
242
|
+
if (flags.json) {
|
|
243
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (rows.length === 0) {
|
|
247
|
+
console.log('(no files)');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const display = rows.map(r => ({
|
|
251
|
+
relativePath: r.relativePath,
|
|
252
|
+
type: r.fileType,
|
|
253
|
+
source: r.source,
|
|
254
|
+
size: formatBytes(r.fileSizeBytes || 0),
|
|
255
|
+
chunks: r.chunkCount,
|
|
256
|
+
status: r.conversionStatus,
|
|
257
|
+
}));
|
|
258
|
+
console.table(display);
|
|
259
|
+
} finally {
|
|
260
|
+
db.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ----------------------------------------------------------------------------
|
|
265
|
+
// read
|
|
266
|
+
// ----------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
function isBinaryFileType(fileType) {
|
|
269
|
+
return BINARY_FILE_TYPES.has(fileType);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ttyGuard(fileType, flags, label) {
|
|
273
|
+
if (!process.stdout.isTTY) return;
|
|
274
|
+
if (!isBinaryFileType(fileType)) return;
|
|
275
|
+
if (flags.force) return;
|
|
276
|
+
console.error(`${label} is binary (${fileType}); redirect to a file (e.g. > out.${fileType}) or pass --force.`);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function handleRead(flags, id, relativePath) {
|
|
281
|
+
if (!id || !relativePath) {
|
|
282
|
+
console.error('Usage: quilltap docs read [--rendered] <mount-id> <relativePath>');
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const { db } = await openDb(flags);
|
|
287
|
+
try {
|
|
288
|
+
const mount = requireMount(db, id);
|
|
289
|
+
|
|
290
|
+
if (flags.rendered) {
|
|
291
|
+
return readRendered(db, mount, relativePath);
|
|
292
|
+
}
|
|
293
|
+
return readRaw(db, mount, relativePath, flags);
|
|
294
|
+
} finally {
|
|
295
|
+
db.close();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function readRaw(db, mount, relativePath, flags) {
|
|
300
|
+
const file = db.prepare(`
|
|
301
|
+
SELECT id, source, fileType
|
|
302
|
+
FROM doc_mount_files
|
|
303
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
304
|
+
`).get(mount.id, relativePath);
|
|
305
|
+
|
|
306
|
+
if (file) {
|
|
307
|
+
ttyGuard(file.fileType, flags, relativePath);
|
|
308
|
+
|
|
309
|
+
if (file.source === 'filesystem') {
|
|
310
|
+
const fullPath = path.join(mount.basePath, relativePath);
|
|
311
|
+
if (!fs.existsSync(fullPath)) {
|
|
312
|
+
console.error(`File missing on disk: ${fullPath}`);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
// Stream so we don't load huge files into memory.
|
|
316
|
+
const stream = fs.createReadStream(fullPath);
|
|
317
|
+
stream.pipe(process.stdout);
|
|
318
|
+
return new Promise((resolve, reject) => {
|
|
319
|
+
stream.on('end', resolve);
|
|
320
|
+
stream.on('error', reject);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (file.source === 'database') {
|
|
325
|
+
if (TEXT_FILE_TYPES.has(file.fileType)) {
|
|
326
|
+
const doc = db.prepare(`
|
|
327
|
+
SELECT content FROM doc_mount_documents
|
|
328
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
329
|
+
`).get(mount.id, relativePath);
|
|
330
|
+
if (!doc) {
|
|
331
|
+
console.error(`File row exists but no document content for ${relativePath}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
process.stdout.write(doc.content);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Binary stored in doc_mount_blobs
|
|
338
|
+
const blob = db.prepare(`
|
|
339
|
+
SELECT data FROM doc_mount_blobs
|
|
340
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
341
|
+
`).get(mount.id, relativePath);
|
|
342
|
+
if (!blob) {
|
|
343
|
+
console.error(`File row exists but no blob bytes for ${relativePath}`);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
process.stdout.write(blob.data);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
console.error(`Unknown file source: ${file.source}`);
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// No row in doc_mount_files — try blobs directly (some binaries may not be mirrored).
|
|
355
|
+
const blob = db.prepare(`
|
|
356
|
+
SELECT data, originalMimeType FROM doc_mount_blobs
|
|
357
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
358
|
+
`).get(mount.id, relativePath);
|
|
359
|
+
if (blob) {
|
|
360
|
+
ttyGuard('blob', flags, relativePath);
|
|
361
|
+
process.stdout.write(blob.data);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
console.error(`No file at ${relativePath} in mount ${mount.name}`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function readRendered(db, mount, relativePath) {
|
|
370
|
+
// 1. Blob with extractedText wins.
|
|
371
|
+
const blob = db.prepare(`
|
|
372
|
+
SELECT extractedText FROM doc_mount_blobs
|
|
373
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
374
|
+
`).get(mount.id, relativePath);
|
|
375
|
+
if (blob && blob.extractedText) {
|
|
376
|
+
process.stdout.write(blob.extractedText);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 2. Look up the file row.
|
|
381
|
+
const file = db.prepare(`
|
|
382
|
+
SELECT id, source, fileType
|
|
383
|
+
FROM doc_mount_files
|
|
384
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
385
|
+
`).get(mount.id, relativePath);
|
|
386
|
+
|
|
387
|
+
if (!file) {
|
|
388
|
+
console.error(`No file at ${relativePath} in mount ${mount.name}`);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 3. Database-backed text doc — content IS the rendered form.
|
|
393
|
+
if (file.source === 'database' && TEXT_FILE_TYPES.has(file.fileType)) {
|
|
394
|
+
const doc = db.prepare(`
|
|
395
|
+
SELECT content FROM doc_mount_documents
|
|
396
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
397
|
+
`).get(mount.id, relativePath);
|
|
398
|
+
if (doc) {
|
|
399
|
+
process.stdout.write(doc.content);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 4. Plain-text filesystem files render to themselves.
|
|
405
|
+
if (file.source === 'filesystem' && TEXT_FILE_TYPES.has(file.fileType)) {
|
|
406
|
+
const fullPath = path.join(mount.basePath, relativePath);
|
|
407
|
+
if (fs.existsSync(fullPath)) {
|
|
408
|
+
process.stdout.write(fs.readFileSync(fullPath, 'utf8'));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 5. Fall back to concatenated chunks.
|
|
414
|
+
const chunks = db.prepare(`
|
|
415
|
+
SELECT content FROM doc_mount_chunks
|
|
416
|
+
WHERE fileId = ?
|
|
417
|
+
ORDER BY chunkIndex
|
|
418
|
+
`).all(file.id);
|
|
419
|
+
if (chunks.length === 0) {
|
|
420
|
+
console.error(`No rendered text available for ${relativePath}. Run 'quilltap docs scan ${mount.id}' to extract and embed.`);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
process.stdout.write(chunks.map(c => c.content).join('\n\n'));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ----------------------------------------------------------------------------
|
|
427
|
+
// export
|
|
428
|
+
// ----------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
async function handleExport(flags, id, outputDir) {
|
|
431
|
+
if (!id || !outputDir) {
|
|
432
|
+
console.error('Usage: quilltap docs export <mount-id> <outputDir>');
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const resolvedOut = outputDir.startsWith('~')
|
|
437
|
+
? path.join(require('os').homedir(), outputDir.slice(1))
|
|
438
|
+
: path.resolve(outputDir);
|
|
439
|
+
|
|
440
|
+
if (!fs.existsSync(resolvedOut)) {
|
|
441
|
+
fs.mkdirSync(resolvedOut, { recursive: true });
|
|
442
|
+
} else {
|
|
443
|
+
const stat = fs.statSync(resolvedOut);
|
|
444
|
+
if (!stat.isDirectory()) {
|
|
445
|
+
console.error(`Output path exists and is not a directory: ${resolvedOut}`);
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const { db } = await openDb(flags);
|
|
451
|
+
let writtenFiles = 0;
|
|
452
|
+
let writtenBytes = 0;
|
|
453
|
+
const writtenPaths = new Set();
|
|
454
|
+
try {
|
|
455
|
+
const mount = requireMount(db, id);
|
|
456
|
+
|
|
457
|
+
const files = db.prepare(`
|
|
458
|
+
SELECT id, relativePath, source, fileType
|
|
459
|
+
FROM doc_mount_files
|
|
460
|
+
WHERE mountPointId = ?
|
|
461
|
+
ORDER BY relativePath
|
|
462
|
+
`).all(id);
|
|
463
|
+
|
|
464
|
+
for (const file of files) {
|
|
465
|
+
const dest = path.join(resolvedOut, file.relativePath);
|
|
466
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
467
|
+
|
|
468
|
+
if (file.source === 'filesystem') {
|
|
469
|
+
const src = path.join(mount.basePath, file.relativePath);
|
|
470
|
+
if (!fs.existsSync(src)) {
|
|
471
|
+
console.error(`${YELLOW}skip${RESET} ${file.relativePath} (missing on disk: ${src})`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
fs.copyFileSync(src, dest);
|
|
475
|
+
writtenBytes += fs.statSync(dest).size;
|
|
476
|
+
} else if (file.source === 'database') {
|
|
477
|
+
if (TEXT_FILE_TYPES.has(file.fileType)) {
|
|
478
|
+
const doc = db.prepare(`
|
|
479
|
+
SELECT content FROM doc_mount_documents
|
|
480
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
481
|
+
`).get(id, file.relativePath);
|
|
482
|
+
if (!doc) {
|
|
483
|
+
console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no document content)`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
fs.writeFileSync(dest, doc.content, 'utf8');
|
|
487
|
+
writtenBytes += Buffer.byteLength(doc.content, 'utf8');
|
|
488
|
+
} else {
|
|
489
|
+
const blob = db.prepare(`
|
|
490
|
+
SELECT data FROM doc_mount_blobs
|
|
491
|
+
WHERE mountPointId = ? AND relativePath = ?
|
|
492
|
+
`).get(id, file.relativePath);
|
|
493
|
+
if (!blob) {
|
|
494
|
+
console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no blob bytes)`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
fs.writeFileSync(dest, blob.data);
|
|
498
|
+
writtenBytes += blob.data.length;
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
console.error(`${YELLOW}skip${RESET} ${file.relativePath} (unknown source: ${file.source})`);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
writtenFiles += 1;
|
|
505
|
+
writtenPaths.add(file.relativePath);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Catch any blobs not mirrored into doc_mount_files (defensive).
|
|
509
|
+
const blobs = db.prepare(`
|
|
510
|
+
SELECT relativePath, data
|
|
511
|
+
FROM doc_mount_blobs
|
|
512
|
+
WHERE mountPointId = ?
|
|
513
|
+
`).all(id);
|
|
514
|
+
for (const blob of blobs) {
|
|
515
|
+
if (writtenPaths.has(blob.relativePath)) continue;
|
|
516
|
+
const dest = path.join(resolvedOut, blob.relativePath);
|
|
517
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
518
|
+
fs.writeFileSync(dest, blob.data);
|
|
519
|
+
writtenBytes += blob.data.length;
|
|
520
|
+
writtenFiles += 1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
console.log(`${GREEN}Exported${RESET} ${writtenFiles} file(s), ${formatBytes(writtenBytes)} → ${resolvedOut}`);
|
|
524
|
+
} finally {
|
|
525
|
+
db.close();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ----------------------------------------------------------------------------
|
|
530
|
+
// scan
|
|
531
|
+
// ----------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
async function handleScan(flags, id) {
|
|
534
|
+
if (!id) {
|
|
535
|
+
console.error('Usage: quilltap docs scan <mount-id>');
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
const url = `http://localhost:${flags.port}/api/v1/mount-points/${encodeURIComponent(id)}?action=scan`;
|
|
539
|
+
let res;
|
|
540
|
+
try {
|
|
541
|
+
res = await fetch(url, { method: 'POST' });
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error(`Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}`);
|
|
544
|
+
console.error('Start the server with `quilltap` (or pass --port to match a non-default port).');
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
let body;
|
|
548
|
+
try {
|
|
549
|
+
body = await res.json();
|
|
550
|
+
} catch {
|
|
551
|
+
console.error(`Server returned status ${res.status} with no JSON body`);
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
if (!res.ok) {
|
|
555
|
+
console.error(`Scan failed (${res.status}): ${body && body.error ? body.error : JSON.stringify(body)}`);
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (flags.json) {
|
|
560
|
+
process.stdout.write(JSON.stringify(body, null, 2) + '\n');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const data = body.data || body;
|
|
565
|
+
const r = data.scanResult || {};
|
|
566
|
+
console.log(`${GREEN}Scan complete${RESET}`);
|
|
567
|
+
console.log(` Files scanned: ${r.filesScanned ?? '?'}`);
|
|
568
|
+
console.log(` New: ${r.filesNew ?? '?'}`);
|
|
569
|
+
console.log(` Modified: ${r.filesModified ?? '?'}`);
|
|
570
|
+
console.log(` Deleted: ${r.filesDeleted ?? '?'}`);
|
|
571
|
+
console.log(` Chunks created: ${r.chunksCreated ?? '?'}`);
|
|
572
|
+
console.log(` Errors: ${(r.errors && r.errors.length) || 0}`);
|
|
573
|
+
console.log(` Embed jobs: ${data.embeddingJobsEnqueued ?? '?'}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ----------------------------------------------------------------------------
|
|
577
|
+
// dispatch
|
|
578
|
+
// ----------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
async function docsCommand(args) {
|
|
581
|
+
if (args.length === 0) {
|
|
582
|
+
printDocsHelp();
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
if (args[0] === '-h' || args[0] === '--help') {
|
|
586
|
+
printDocsHelp();
|
|
587
|
+
process.exit(0);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const verb = args[0];
|
|
591
|
+
const { flags, positional } = parseFlags(args.slice(1));
|
|
592
|
+
|
|
593
|
+
if (flags.help) {
|
|
594
|
+
printDocsHelp();
|
|
595
|
+
process.exit(0);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
switch (verb) {
|
|
600
|
+
case 'list':
|
|
601
|
+
await handleList(flags);
|
|
602
|
+
break;
|
|
603
|
+
case 'show':
|
|
604
|
+
await handleShow(flags, positional[0]);
|
|
605
|
+
break;
|
|
606
|
+
case 'files':
|
|
607
|
+
await handleFiles(flags, positional[0]);
|
|
608
|
+
break;
|
|
609
|
+
case 'read':
|
|
610
|
+
await handleRead(flags, positional[0], positional[1]);
|
|
611
|
+
break;
|
|
612
|
+
case 'export':
|
|
613
|
+
await handleExport(flags, positional[0], positional[1]);
|
|
614
|
+
break;
|
|
615
|
+
case 'scan':
|
|
616
|
+
await handleScan(flags, positional[0]);
|
|
617
|
+
break;
|
|
618
|
+
default:
|
|
619
|
+
console.error(`Unknown docs subcommand: ${verb}`);
|
|
620
|
+
printDocsHelp();
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
} catch (err) {
|
|
624
|
+
console.error(`Error: ${err.message}`);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
module.exports = { docsCommand };
|