quilltap 4.5.0-dev → 4.6.0-dev
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 +178 -0
- package/bin/quilltap.js +226 -20
- package/lib/completion/bash.template +121 -0
- package/lib/completion/fish.template +93 -0
- package/lib/completion/zsh.template +209 -0
- package/lib/completion-commands.js +77 -0
- package/lib/db-commands.js +1142 -0
- package/lib/db-helpers.js +173 -4
- package/lib/docs-commands.js +2157 -172
- package/lib/graph-integrity.js +105 -0
- package/lib/instances-commands.js +342 -0
- package/lib/instances.js +335 -0
- package/lib/lock-helpers.js +117 -0
- package/lib/logs-commands.js +383 -0
- package/lib/memories-commands.js +1374 -0
- package/lib/memory-diff-command.js +19 -3
- package/lib/migrations-commands.js +324 -0
- package/lib/theme-commands.js +18 -0
- package/package.json +1 -1
package/lib/docs-commands.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
-
const
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const {
|
|
7
|
+
resolveDataDirAndPassphrase,
|
|
8
|
+
printDefaultInstanceHint,
|
|
9
|
+
loadDbKey,
|
|
10
|
+
openMountIndexDb,
|
|
11
|
+
} = require('./db-helpers');
|
|
6
12
|
|
|
7
13
|
const RESET = '\x1b[0m';
|
|
8
14
|
const BOLD = '\x1b[1m';
|
|
@@ -15,58 +21,159 @@ const CYAN = '\x1b[36m';
|
|
|
15
21
|
const TEXT_FILE_TYPES = new Set(['markdown', 'txt', 'json', 'jsonl']);
|
|
16
22
|
const BINARY_FILE_TYPES = new Set(['pdf', 'docx', 'blob']);
|
|
17
23
|
|
|
24
|
+
// Single-character markers for the `ls` "text" column:
|
|
25
|
+
// = raw bytes are already textual (markdown/txt/json/jsonl)
|
|
26
|
+
// T separately-extracted plaintext is stored on the link row
|
|
27
|
+
// ~ extraction queued or in progress
|
|
28
|
+
// ! extraction attempted and failed
|
|
29
|
+
// - no extracted text and the file is not text-native
|
|
30
|
+
function textColumnMarker(fileType, extractionStatus) {
|
|
31
|
+
if (TEXT_FILE_TYPES.has(fileType)) return '=';
|
|
32
|
+
switch (extractionStatus) {
|
|
33
|
+
case 'converted': return 'T';
|
|
34
|
+
case 'pending': return '~';
|
|
35
|
+
case 'failed': return '!';
|
|
36
|
+
case 'skipped':
|
|
37
|
+
case 'none':
|
|
38
|
+
default: return '-';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Single-character markers for the `ls` "emb" column:
|
|
43
|
+
// Y every chunk on this file has an embedding
|
|
44
|
+
// ~ chunks exist but none / only some have an embedding (queued or partial)
|
|
45
|
+
// - no chunks at all
|
|
46
|
+
function embedColumnMarker(chunkCount, embeddedChunkCount) {
|
|
47
|
+
if (!chunkCount) return '-';
|
|
48
|
+
if (embeddedChunkCount === chunkCount) return 'Y';
|
|
49
|
+
return '~';
|
|
50
|
+
}
|
|
51
|
+
|
|
18
52
|
function printDocsHelp() {
|
|
19
53
|
console.log(`
|
|
20
54
|
Quilltap Document Store Tool
|
|
21
55
|
|
|
22
56
|
Usage: quilltap docs <subcommand> [options]
|
|
23
57
|
|
|
24
|
-
|
|
58
|
+
Read subcommands:
|
|
25
59
|
list List all mount points
|
|
26
|
-
show <
|
|
27
|
-
files <
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
show <mount> Details for one mount point
|
|
61
|
+
files <mount> [--folder <path>] List files in a mount
|
|
62
|
+
ls|dir <mount> [path] [options] ls-style listing of one folder (or file)
|
|
63
|
+
tree <mount> [path] [options] ASCII tree view of a folder hierarchy
|
|
64
|
+
read <mount> <relativePath> Print file contents to stdout
|
|
65
|
+
read --rendered <mount> <relativePath> Print extracted plaintext to stdout
|
|
66
|
+
export <mount> <outputDir> Export an entire mount to a directory
|
|
67
|
+
scan <mount> Trigger a rescan via the running server
|
|
68
|
+
find [--mount <name|id|all>] [--type file|folder] [--ext <ext>] [--limit N] <pattern>
|
|
69
|
+
Substring search on file (or folder) names
|
|
70
|
+
grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] <pattern>
|
|
71
|
+
Substring search inside extracted text
|
|
72
|
+
status [--mount <name|id>] [--top N] Per-mount extraction + embedding rollup
|
|
73
|
+
|
|
74
|
+
Server-required subcommands (background-job queue lives in the running server):
|
|
75
|
+
reindex <mount> [path] [--force] Re-extract text + re-chunk affected files
|
|
76
|
+
embed <mount> [path] [--force] [--wait]
|
|
77
|
+
Enqueue embedding jobs for un-embedded chunks
|
|
78
|
+
|
|
79
|
+
Write subcommands (server required for database-backed mounts):
|
|
80
|
+
write [--force] <mount> <path> [file] Write a file from <file> or stdin
|
|
81
|
+
delete <mount> <path> Idempotent file delete
|
|
82
|
+
mkdir <mount> <path> Idempotent folder create
|
|
83
|
+
move <srcMount> <srcPath> <dstMount> <dstPath> Move file (hard-link when possible)
|
|
84
|
+
copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (hard-link unless --force)
|
|
85
|
+
|
|
86
|
+
<mount> may be a mount name or UUID. Names are case-insensitive; ambiguous
|
|
87
|
+
names print candidates and exit non-zero.
|
|
32
88
|
|
|
33
89
|
Options:
|
|
34
90
|
-d, --data-dir <path> Override data directory
|
|
91
|
+
-i, --instance <name> Use a registered instance (see 'quilltap instances')
|
|
35
92
|
--passphrase <pass> Decrypt .dbkey if peppered
|
|
36
93
|
--port <number> Server port for API calls (default: 3000)
|
|
37
|
-
--json Machine-readable output
|
|
94
|
+
--json Machine-readable output
|
|
38
95
|
--rendered For 'read': output extracted plaintext
|
|
39
96
|
--folder <path> For 'files': narrow to a folder prefix
|
|
97
|
+
--recursive, -R For 'ls': list all files recursively, grouped by folder
|
|
98
|
+
--sort name|time|size|links For 'ls': sort by name (default), modification time,
|
|
99
|
+
file size, or hard-link count
|
|
100
|
+
-r, --reverse Reverse sort order
|
|
101
|
+
--links For 'ls' / 'dir': under each file with more than
|
|
102
|
+
one hard link, list the other mount/path entries
|
|
103
|
+
--depth N For 'tree': maximum nesting depth (default: 20)
|
|
104
|
+
--max-nodes N For 'tree': maximum nodes to render (default: 1000)
|
|
105
|
+
--long For 'tree': include text/emb columns (reserved for future)
|
|
40
106
|
--force For 'read': dump binary to TTY anyway
|
|
107
|
+
For 'write': overwrite existing destination
|
|
108
|
+
For 'copy': overwrite + force a real byte copy
|
|
109
|
+
(skips the default hard-link path)
|
|
41
110
|
-h, --help Show this help
|
|
42
111
|
|
|
43
112
|
Read-only operations (list, show, files, read, export) open the mount-index
|
|
44
|
-
database directly. Write operations
|
|
45
|
-
|
|
113
|
+
database directly. Write operations talk to the running Quilltap server when
|
|
114
|
+
available (so reindex/embed kicks off automatically); they fall back to
|
|
115
|
+
filesystem-only writes when the server is down, and report what the index
|
|
116
|
+
will see after the next 'docs scan'.
|
|
117
|
+
|
|
118
|
+
Verification: every write computes a SHA-256 on both ends and compares them
|
|
119
|
+
before reporting success. Hard-linked files match trivially.
|
|
46
120
|
|
|
47
121
|
Examples:
|
|
48
|
-
quilltap docs
|
|
49
|
-
quilltap docs
|
|
50
|
-
quilltap docs
|
|
51
|
-
quilltap docs
|
|
52
|
-
quilltap docs
|
|
53
|
-
quilltap docs
|
|
54
|
-
quilltap docs
|
|
55
|
-
quilltap docs
|
|
56
|
-
quilltap docs
|
|
122
|
+
quilltap docs ls notes
|
|
123
|
+
quilltap docs ls notes 2026/may --links
|
|
124
|
+
quilltap docs dir notes today.md --json
|
|
125
|
+
quilltap docs write notes today.md < draft.md
|
|
126
|
+
quilltap docs write --force notes today.md draft.md
|
|
127
|
+
quilltap docs delete notes today.md
|
|
128
|
+
quilltap docs mkdir notes 2026/may
|
|
129
|
+
quilltap docs move drafts foo.md notes 2026/foo.md
|
|
130
|
+
quilltap docs copy notes today.md archive 2026-05/today.md
|
|
131
|
+
quilltap docs copy --force notes today.md archive copy.md
|
|
132
|
+
quilltap docs find Manifesto
|
|
133
|
+
quilltap docs find --mount notes --ext md Knowledge
|
|
134
|
+
quilltap docs grep --mount notes --ignore-case "five-point Calvinist"
|
|
135
|
+
quilltap docs grep --mount notes -l "TODO"
|
|
136
|
+
quilltap docs status
|
|
137
|
+
quilltap docs status --mount notes --top 10
|
|
138
|
+
quilltap docs reindex notes Knowledge --force
|
|
139
|
+
quilltap docs embed notes --wait
|
|
57
140
|
`);
|
|
58
141
|
}
|
|
59
142
|
|
|
60
143
|
function parseFlags(args) {
|
|
61
144
|
const flags = {
|
|
62
145
|
dataDir: '',
|
|
146
|
+
instance: '',
|
|
63
147
|
passphrase: '',
|
|
64
148
|
port: 3000,
|
|
65
149
|
json: false,
|
|
66
150
|
rendered: false,
|
|
67
151
|
folder: '',
|
|
68
152
|
force: false,
|
|
153
|
+
links: false,
|
|
69
154
|
help: false,
|
|
155
|
+
// find / grep / reindex / embed / status flags
|
|
156
|
+
mount: '',
|
|
157
|
+
type: '',
|
|
158
|
+
ext: '',
|
|
159
|
+
limit: 0,
|
|
160
|
+
max: 0,
|
|
161
|
+
context: 0,
|
|
162
|
+
top: -1,
|
|
163
|
+
ignoreCase: false,
|
|
164
|
+
pathsOnly: false,
|
|
165
|
+
wait: false,
|
|
166
|
+
// ls flags: recursive, sort, reverse
|
|
167
|
+
recursive: false,
|
|
168
|
+
sort: 'name',
|
|
169
|
+
reverse: false,
|
|
170
|
+
// tree flags: depth, max-nodes, long
|
|
171
|
+
depth: 20,
|
|
172
|
+
maxNodes: 1000,
|
|
173
|
+
long: false,
|
|
174
|
+
// semantic search
|
|
175
|
+
semantic: false,
|
|
176
|
+
threshold: -1,
|
|
70
177
|
};
|
|
71
178
|
const positional = [];
|
|
72
179
|
let i = 0;
|
|
@@ -74,6 +181,7 @@ function parseFlags(args) {
|
|
|
74
181
|
const a = args[i];
|
|
75
182
|
switch (a) {
|
|
76
183
|
case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
|
|
184
|
+
case '-i': case '--instance': flags.instance = args[++i]; break;
|
|
77
185
|
case '--passphrase': flags.passphrase = args[++i]; break;
|
|
78
186
|
case '--port': {
|
|
79
187
|
const p = parseInt(args[++i], 10);
|
|
@@ -88,6 +196,38 @@ function parseFlags(args) {
|
|
|
88
196
|
case '--rendered': flags.rendered = true; break;
|
|
89
197
|
case '--folder': flags.folder = args[++i]; break;
|
|
90
198
|
case '--force': flags.force = true; break;
|
|
199
|
+
case '--links': flags.links = true; break;
|
|
200
|
+
case '--mount': flags.mount = args[++i]; break;
|
|
201
|
+
case '--type': flags.type = args[++i]; break;
|
|
202
|
+
case '--ext': flags.ext = args[++i]; break;
|
|
203
|
+
case '--limit': flags.limit = parseInt(args[++i], 10) || 0; break;
|
|
204
|
+
case '--max': flags.max = parseInt(args[++i], 10) || 0; break;
|
|
205
|
+
case '--context': flags.context = parseInt(args[++i], 10) || 0; break;
|
|
206
|
+
case '--top': {
|
|
207
|
+
const n = parseInt(args[++i], 10);
|
|
208
|
+
flags.top = isNaN(n) ? -1 : n;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case '--ignore-case': flags.ignoreCase = true; break;
|
|
212
|
+
case '-l': flags.pathsOnly = true; break;
|
|
213
|
+
case '--wait': flags.wait = true; break;
|
|
214
|
+
case '-R': case '--recursive': flags.recursive = true; break;
|
|
215
|
+
case '--sort': flags.sort = args[++i] || 'name'; break;
|
|
216
|
+
case '-r': case '--reverse': flags.reverse = true; break;
|
|
217
|
+
case '--depth': flags.depth = parseInt(args[++i], 10) || 20; break;
|
|
218
|
+
case '--max-nodes': flags.maxNodes = parseInt(args[++i], 10) || 1000; break;
|
|
219
|
+
case '--long': flags.long = true; break;
|
|
220
|
+
case '--names-only': flags.namesOnly = true; break;
|
|
221
|
+
case '--semantic': flags.semantic = true; break;
|
|
222
|
+
case '--threshold': {
|
|
223
|
+
const v = parseFloat(args[++i]);
|
|
224
|
+
if (isNaN(v) || v < 0 || v > 1) {
|
|
225
|
+
console.error('Error: --threshold must be a number between 0 and 1');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
flags.threshold = v;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
91
231
|
case '-h': case '--help': flags.help = true; break;
|
|
92
232
|
default:
|
|
93
233
|
if (a.startsWith('-')) {
|
|
@@ -102,19 +242,94 @@ function parseFlags(args) {
|
|
|
102
242
|
}
|
|
103
243
|
|
|
104
244
|
async function openDb(flags) {
|
|
105
|
-
const
|
|
106
|
-
|
|
245
|
+
const resolved = resolveDataDirAndPassphrase({
|
|
246
|
+
dataDir: flags.dataDir,
|
|
247
|
+
instance: flags.instance,
|
|
248
|
+
passphrase: flags.passphrase,
|
|
249
|
+
});
|
|
250
|
+
printDefaultInstanceHint(resolved);
|
|
251
|
+
const { dataDir, passphrase } = resolved;
|
|
252
|
+
const pepper = await loadDbKey(dataDir, passphrase);
|
|
107
253
|
const db = openMountIndexDb(dataDir, pepper, { readonly: true });
|
|
254
|
+
assertDocsSchema(db, dataDir);
|
|
108
255
|
return { db, dataDir };
|
|
109
256
|
}
|
|
110
257
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
258
|
+
// Every docs subcommand assumes the post-link-table mount-index schema
|
|
259
|
+
// (doc_mount_file_links plus the columns it expects on neighbouring tables).
|
|
260
|
+
// Older instances opened by an out-of-date CLI — or, more often, by a fresh
|
|
261
|
+
// CLI pointed at an instance that hasn't been booted since the migration
|
|
262
|
+
// landed — fail deep inside a prepared statement with `no such table:
|
|
263
|
+
// doc_mount_file_links`. Catch the missing table up front and tell the
|
|
264
|
+
// operator exactly what to do.
|
|
265
|
+
function assertDocsSchema(db, dataDir) {
|
|
266
|
+
const row = db.prepare(
|
|
267
|
+
"SELECT 1 AS ok FROM sqlite_master WHERE type='table' AND name='doc_mount_file_links'"
|
|
268
|
+
).get();
|
|
269
|
+
if (row && row.ok === 1) return;
|
|
270
|
+
|
|
271
|
+
db.close();
|
|
272
|
+
|
|
273
|
+
let registered = [];
|
|
274
|
+
try {
|
|
275
|
+
const { listInstances } = require('./instances');
|
|
276
|
+
registered = listInstances();
|
|
277
|
+
} catch {
|
|
278
|
+
// Registry unreadable or absent — keep going with an empty list.
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const lines = [
|
|
282
|
+
'Error: this instance has not been migrated to the post-link-table mount-index schema.',
|
|
283
|
+
` Database: ${path.join(dataDir, 'quilltap-mount-index.db')}`,
|
|
284
|
+
' Missing table: doc_mount_file_links',
|
|
285
|
+
'',
|
|
286
|
+
'Start Quilltap against this data directory once (npm run dev / npx quilltap)',
|
|
287
|
+
'to run the pending migrations, or target a different instance:',
|
|
288
|
+
];
|
|
289
|
+
if (registered.length > 0) {
|
|
290
|
+
for (const inst of registered) {
|
|
291
|
+
lines.push(` --instance ${inst.name} (${inst.path})`);
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
lines.push(' (no instances registered — see `quilltap instances --help`)');
|
|
295
|
+
}
|
|
296
|
+
process.stderr.write(lines.join('\n') + '\n');
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
301
|
+
|
|
302
|
+
function requireMount(db, spec) {
|
|
303
|
+
if (!spec) {
|
|
304
|
+
console.error('Error: mount name or id is required');
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
if (UUID_RE.test(spec)) {
|
|
308
|
+
const row = db.prepare('SELECT * FROM doc_mount_points WHERE id = ?').get(spec);
|
|
309
|
+
if (!row) {
|
|
310
|
+
console.error(`No mount point found with id ${spec}`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
return row;
|
|
314
|
+
}
|
|
315
|
+
const rows = db.prepare(
|
|
316
|
+
`SELECT * FROM doc_mount_points
|
|
317
|
+
WHERE LOWER(name) = LOWER(?)
|
|
318
|
+
ORDER BY name COLLATE NOCASE`
|
|
319
|
+
).all(spec);
|
|
320
|
+
if (rows.length === 0) {
|
|
321
|
+
console.error(`No mount point found with name "${spec}"`);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
if (rows.length > 1) {
|
|
325
|
+
console.error(`Ambiguous mount name "${spec}" matches multiple mounts:`);
|
|
326
|
+
for (const r of rows) {
|
|
327
|
+
console.error(` ${r.id} ${r.name} (${r.mountType})`);
|
|
328
|
+
}
|
|
329
|
+
console.error('Pass the UUID instead.');
|
|
115
330
|
process.exit(1);
|
|
116
331
|
}
|
|
117
|
-
return
|
|
332
|
+
return rows[0];
|
|
118
333
|
}
|
|
119
334
|
|
|
120
335
|
function formatBytes(n) {
|
|
@@ -137,6 +352,13 @@ async function handleList(flags) {
|
|
|
137
352
|
FROM doc_mount_points
|
|
138
353
|
ORDER BY name COLLATE NOCASE
|
|
139
354
|
`).all();
|
|
355
|
+
if (flags.namesOnly) {
|
|
356
|
+
// Hidden flag for completion: print one name per line
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
console.log(row.name);
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
140
362
|
if (flags.json) {
|
|
141
363
|
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
142
364
|
return;
|
|
@@ -174,10 +396,22 @@ async function handleShow(flags, id) {
|
|
|
174
396
|
const { db } = await openDb(flags);
|
|
175
397
|
try {
|
|
176
398
|
const mount = requireMount(db, id);
|
|
177
|
-
const fileCount = db.prepare(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
399
|
+
const fileCount = db.prepare(
|
|
400
|
+
'SELECT COUNT(*) AS n FROM doc_mount_file_links WHERE mountPointId = ?'
|
|
401
|
+
).get(mount.id).n;
|
|
402
|
+
const chunkCount = db.prepare(
|
|
403
|
+
'SELECT COUNT(*) AS n FROM doc_mount_chunks WHERE mountPointId = ?'
|
|
404
|
+
).get(mount.id).n;
|
|
405
|
+
const blobCount = db.prepare(`
|
|
406
|
+
SELECT COUNT(*) AS n FROM doc_mount_file_links l
|
|
407
|
+
JOIN doc_mount_blobs b ON b.fileId = l.fileId
|
|
408
|
+
WHERE l.mountPointId = ?
|
|
409
|
+
`).get(mount.id).n;
|
|
410
|
+
const docCount = db.prepare(`
|
|
411
|
+
SELECT COUNT(*) AS n FROM doc_mount_file_links l
|
|
412
|
+
JOIN doc_mount_documents d ON d.fileId = l.fileId
|
|
413
|
+
WHERE l.mountPointId = ?
|
|
414
|
+
`).get(mount.id).n;
|
|
181
415
|
|
|
182
416
|
const liveCounts = {
|
|
183
417
|
filesActual: fileCount,
|
|
@@ -221,23 +455,27 @@ async function handleFiles(flags, id) {
|
|
|
221
455
|
}
|
|
222
456
|
const { db } = await openDb(flags);
|
|
223
457
|
try {
|
|
224
|
-
requireMount(db, id);
|
|
458
|
+
const mount = requireMount(db, id);
|
|
225
459
|
let rows;
|
|
226
460
|
if (flags.folder) {
|
|
227
461
|
const prefix = flags.folder.replace(/\/+$/, '') + '/';
|
|
228
462
|
rows = db.prepare(`
|
|
229
|
-
SELECT relativePath, fileType, source, fileSizeBytes,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
463
|
+
SELECT l.relativePath, f.fileType, f.source, f.fileSizeBytes,
|
|
464
|
+
l.chunkCount, l.conversionStatus
|
|
465
|
+
FROM doc_mount_file_links l
|
|
466
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
467
|
+
WHERE l.mountPointId = ? AND l.relativePath LIKE ?
|
|
468
|
+
ORDER BY l.relativePath
|
|
469
|
+
`).all(mount.id, prefix + '%');
|
|
234
470
|
} else {
|
|
235
471
|
rows = db.prepare(`
|
|
236
|
-
SELECT relativePath, fileType, source, fileSizeBytes,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
472
|
+
SELECT l.relativePath, f.fileType, f.source, f.fileSizeBytes,
|
|
473
|
+
l.chunkCount, l.conversionStatus
|
|
474
|
+
FROM doc_mount_file_links l
|
|
475
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
476
|
+
WHERE l.mountPointId = ?
|
|
477
|
+
ORDER BY l.relativePath
|
|
478
|
+
`).all(mount.id);
|
|
241
479
|
}
|
|
242
480
|
if (flags.json) {
|
|
243
481
|
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
@@ -261,6 +499,567 @@ async function handleFiles(flags, id) {
|
|
|
261
499
|
}
|
|
262
500
|
}
|
|
263
501
|
|
|
502
|
+
// ----------------------------------------------------------------------------
|
|
503
|
+
// ls / dir
|
|
504
|
+
// ----------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
function normalizeLsPath(p) {
|
|
507
|
+
if (!p) return '';
|
|
508
|
+
// Strip leading/trailing slashes; treat '.' and '/' as root.
|
|
509
|
+
const trimmed = p.replace(/^\/+|\/+$/g, '');
|
|
510
|
+
if (trimmed === '.' || trimmed === '') return '';
|
|
511
|
+
return trimmed;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function formatLsDate(iso) {
|
|
515
|
+
if (!iso) return '-';
|
|
516
|
+
const d = new Date(iso);
|
|
517
|
+
if (isNaN(d.getTime())) return iso.slice(0, 16);
|
|
518
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
519
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const LS_FILE_COLUMNS = `
|
|
523
|
+
l.id AS linkId, l.fileId, l.relativePath, l.fileName, l.lastModified,
|
|
524
|
+
l.extractionStatus, l.extractedTextSha256, l.chunkCount,
|
|
525
|
+
f.fileType, f.fileSizeBytes, f.source, f.sha256,
|
|
526
|
+
(SELECT COUNT(*) FROM doc_mount_file_links WHERE fileId = l.fileId) AS linkCount,
|
|
527
|
+
(SELECT COUNT(*) FROM doc_mount_chunks
|
|
528
|
+
WHERE linkId = l.id AND embedding IS NOT NULL) AS embeddedChunkCount
|
|
529
|
+
`;
|
|
530
|
+
|
|
531
|
+
function resolveLsTarget(db, mountId, normalizedPath) {
|
|
532
|
+
if (!normalizedPath) return { kind: 'root', path: '' };
|
|
533
|
+
|
|
534
|
+
// Exact file match wins — handles the single-file display mode.
|
|
535
|
+
const file = db.prepare(`
|
|
536
|
+
SELECT ${LS_FILE_COLUMNS}
|
|
537
|
+
FROM doc_mount_file_links l
|
|
538
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
539
|
+
WHERE l.mountPointId = ? AND l.relativePath = ?
|
|
540
|
+
`).get(mountId, normalizedPath);
|
|
541
|
+
if (file) return { kind: 'file', file };
|
|
542
|
+
|
|
543
|
+
// Explicit folder row.
|
|
544
|
+
const folder = db.prepare(
|
|
545
|
+
`SELECT id, name, path, parentId, createdAt, updatedAt
|
|
546
|
+
FROM doc_mount_folders WHERE mountPointId = ? AND path = ?`
|
|
547
|
+
).get(mountId, normalizedPath);
|
|
548
|
+
if (folder) return { kind: 'folder', folder, path: normalizedPath };
|
|
549
|
+
|
|
550
|
+
// Implicit folder — files or subfolders live under this path even though
|
|
551
|
+
// no doc_mount_folders row exists for it (or the folderId on links is
|
|
552
|
+
// null due to upstream drift). Mirrors how `docs files --folder` matches.
|
|
553
|
+
const hasFiles = db.prepare(`
|
|
554
|
+
SELECT 1 FROM doc_mount_file_links
|
|
555
|
+
WHERE mountPointId = ? AND relativePath LIKE ? LIMIT 1
|
|
556
|
+
`).get(mountId, normalizedPath + '/%');
|
|
557
|
+
const hasFolders = db.prepare(`
|
|
558
|
+
SELECT 1 FROM doc_mount_folders
|
|
559
|
+
WHERE mountPointId = ? AND path LIKE ? LIMIT 1
|
|
560
|
+
`).get(mountId, normalizedPath + '/%');
|
|
561
|
+
if (hasFiles || hasFolders) return { kind: 'folder', folder: null, path: normalizedPath };
|
|
562
|
+
|
|
563
|
+
return { kind: 'none' };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function fetchLsRows(db, mountId, parentPath) {
|
|
567
|
+
// parentPath: '' = mount root, else "Knowledge" or "foo/bar" etc.
|
|
568
|
+
// We filter by path-prefix rather than folderId / parentId so we stay
|
|
569
|
+
// honest about what the filesystem (or docs read/files) actually sees,
|
|
570
|
+
// even when folderId on a link row drifts to NULL behind our back.
|
|
571
|
+
const folders = parentPath === ''
|
|
572
|
+
? db.prepare(`
|
|
573
|
+
SELECT id, name, path, createdAt, updatedAt
|
|
574
|
+
FROM doc_mount_folders
|
|
575
|
+
WHERE mountPointId = ?
|
|
576
|
+
AND path NOT LIKE '%/%'
|
|
577
|
+
ORDER BY name COLLATE NOCASE
|
|
578
|
+
`).all(mountId)
|
|
579
|
+
: db.prepare(`
|
|
580
|
+
SELECT id, name, path, createdAt, updatedAt
|
|
581
|
+
FROM doc_mount_folders
|
|
582
|
+
WHERE mountPointId = ?
|
|
583
|
+
AND path LIKE ?
|
|
584
|
+
AND path NOT LIKE ?
|
|
585
|
+
ORDER BY name COLLATE NOCASE
|
|
586
|
+
`).all(mountId, parentPath + '/%', parentPath + '/%/%');
|
|
587
|
+
|
|
588
|
+
const files = parentPath === ''
|
|
589
|
+
? db.prepare(`
|
|
590
|
+
SELECT ${LS_FILE_COLUMNS}
|
|
591
|
+
FROM doc_mount_file_links l
|
|
592
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
593
|
+
WHERE l.mountPointId = ?
|
|
594
|
+
AND l.relativePath NOT LIKE '%/%'
|
|
595
|
+
ORDER BY l.fileName COLLATE NOCASE
|
|
596
|
+
`).all(mountId)
|
|
597
|
+
: db.prepare(`
|
|
598
|
+
SELECT ${LS_FILE_COLUMNS}
|
|
599
|
+
FROM doc_mount_file_links l
|
|
600
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
601
|
+
WHERE l.mountPointId = ?
|
|
602
|
+
AND l.relativePath LIKE ?
|
|
603
|
+
AND l.relativePath NOT LIKE ?
|
|
604
|
+
ORDER BY l.fileName COLLATE NOCASE
|
|
605
|
+
`).all(mountId, parentPath + '/%', parentPath + '/%/%');
|
|
606
|
+
|
|
607
|
+
return { folders, files };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function fetchLinksForFiles(db, fileIds) {
|
|
611
|
+
if (fileIds.length === 0) return new Map();
|
|
612
|
+
const placeholders = fileIds.map(() => '?').join(',');
|
|
613
|
+
const rows = db.prepare(`
|
|
614
|
+
SELECT l.fileId, l.relativePath, l.mountPointId, m.name AS mountName
|
|
615
|
+
FROM doc_mount_file_links l
|
|
616
|
+
JOIN doc_mount_points m ON m.id = l.mountPointId
|
|
617
|
+
WHERE l.fileId IN (${placeholders})
|
|
618
|
+
ORDER BY m.name COLLATE NOCASE, l.relativePath COLLATE NOCASE
|
|
619
|
+
`).all(...fileIds);
|
|
620
|
+
const byFile = new Map();
|
|
621
|
+
for (const r of rows) {
|
|
622
|
+
if (!byFile.has(r.fileId)) byFile.set(r.fileId, []);
|
|
623
|
+
byFile.get(r.fileId).push({
|
|
624
|
+
mountPointId: r.mountPointId,
|
|
625
|
+
mountName: r.mountName,
|
|
626
|
+
relativePath: r.relativePath,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return byFile;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function sortLsFiles(files, sortType, reverse) {
|
|
633
|
+
const sorted = [...files];
|
|
634
|
+
switch (sortType) {
|
|
635
|
+
case 'name':
|
|
636
|
+
sorted.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
|
|
637
|
+
break;
|
|
638
|
+
case 'time':
|
|
639
|
+
sorted.sort((a, b) => {
|
|
640
|
+
const aTime = new Date(a.lastModified || 0).getTime();
|
|
641
|
+
const bTime = new Date(b.lastModified || 0).getTime();
|
|
642
|
+
return bTime - aTime; // newest first by default
|
|
643
|
+
});
|
|
644
|
+
break;
|
|
645
|
+
case 'size':
|
|
646
|
+
sorted.sort((a, b) => (b.fileSizeBytes || 0) - (a.fileSizeBytes || 0)); // largest first
|
|
647
|
+
break;
|
|
648
|
+
case 'links':
|
|
649
|
+
sorted.sort((a, b) => (b.linkCount || 0) - (a.linkCount || 0)); // most linked first
|
|
650
|
+
break;
|
|
651
|
+
default:
|
|
652
|
+
sorted.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
|
|
653
|
+
}
|
|
654
|
+
if (reverse) sorted.reverse();
|
|
655
|
+
return sorted;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function handleLs(flags, mountSpec, rawPath) {
|
|
659
|
+
if (!mountSpec) {
|
|
660
|
+
console.error('Usage: quilltap docs ls <mount> [path] [--recursive|-R] [--sort name|time|size|links] [-r] [--links]');
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
const { db } = await openDb(flags);
|
|
664
|
+
try {
|
|
665
|
+
const mount = requireMount(db, mountSpec);
|
|
666
|
+
const normalizedPath = normalizeLsPath(rawPath);
|
|
667
|
+
|
|
668
|
+
let folders = [];
|
|
669
|
+
let files = [];
|
|
670
|
+
let singleFile = false;
|
|
671
|
+
let allFilesByFolder = {};
|
|
672
|
+
|
|
673
|
+
if (flags.recursive) {
|
|
674
|
+
// Recursive mode: fetch all files under the path prefix
|
|
675
|
+
const prefix = normalizedPath ? normalizedPath.replace(/\/+$/, '') + '/' : '';
|
|
676
|
+
const allFiles = normalizedPath
|
|
677
|
+
? db.prepare(`
|
|
678
|
+
SELECT ${LS_FILE_COLUMNS}
|
|
679
|
+
FROM doc_mount_file_links l
|
|
680
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
681
|
+
WHERE l.mountPointId = ? AND l.relativePath LIKE ?
|
|
682
|
+
ORDER BY l.relativePath
|
|
683
|
+
`).all(mount.id, prefix + '%')
|
|
684
|
+
: db.prepare(`
|
|
685
|
+
SELECT ${LS_FILE_COLUMNS}
|
|
686
|
+
FROM doc_mount_file_links l
|
|
687
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
688
|
+
WHERE l.mountPointId = ?
|
|
689
|
+
ORDER BY l.relativePath
|
|
690
|
+
`).all(mount.id);
|
|
691
|
+
|
|
692
|
+
// Group by folder
|
|
693
|
+
for (const file of allFiles) {
|
|
694
|
+
const lastSlash = file.relativePath.lastIndexOf('/');
|
|
695
|
+
const folderPath = lastSlash === -1 ? '' : file.relativePath.substring(0, lastSlash);
|
|
696
|
+
if (!allFilesByFolder[folderPath]) allFilesByFolder[folderPath] = [];
|
|
697
|
+
allFilesByFolder[folderPath].push(file);
|
|
698
|
+
}
|
|
699
|
+
files = allFiles;
|
|
700
|
+
} else {
|
|
701
|
+
// Non-recursive: single folder listing
|
|
702
|
+
const target = resolveLsTarget(db, mount.id, normalizedPath);
|
|
703
|
+
|
|
704
|
+
if (target.kind === 'none') {
|
|
705
|
+
console.error(`No file or folder at "${normalizedPath || '/'}" in mount ${mount.name}`);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
} else if (target.kind === 'root') {
|
|
708
|
+
({ folders, files } = fetchLsRows(db, mount.id, ''));
|
|
709
|
+
} else if (target.kind === 'folder') {
|
|
710
|
+
({ folders, files } = fetchLsRows(db, mount.id, target.path));
|
|
711
|
+
} else {
|
|
712
|
+
files = [target.file];
|
|
713
|
+
singleFile = true;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Apply sort
|
|
718
|
+
files = sortLsFiles(files, flags.sort, flags.reverse);
|
|
719
|
+
|
|
720
|
+
// Fetch links for JSON or --links flag
|
|
721
|
+
const wantLinks = flags.json || flags.links;
|
|
722
|
+
const multiLinkFileIds = wantLinks
|
|
723
|
+
? files.filter((f) => f.linkCount > 1).map((f) => f.fileId)
|
|
724
|
+
: [];
|
|
725
|
+
const linksByFile = fetchLinksForFiles(db, multiLinkFileIds);
|
|
726
|
+
|
|
727
|
+
// JSON output
|
|
728
|
+
if (flags.json) {
|
|
729
|
+
const out = [];
|
|
730
|
+
if (!flags.recursive) {
|
|
731
|
+
for (const folder of folders) {
|
|
732
|
+
out.push({
|
|
733
|
+
type: 'folder',
|
|
734
|
+
name: folder.name,
|
|
735
|
+
path: folder.path,
|
|
736
|
+
createdAt: folder.createdAt,
|
|
737
|
+
updatedAt: folder.updatedAt,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
for (const file of files) {
|
|
742
|
+
const others = linksByFile.get(file.fileId);
|
|
743
|
+
const links = others && others.length > 0
|
|
744
|
+
? others
|
|
745
|
+
: [{
|
|
746
|
+
mountPointId: mount.id,
|
|
747
|
+
mountName: mount.name,
|
|
748
|
+
relativePath: file.relativePath,
|
|
749
|
+
}];
|
|
750
|
+
out.push({
|
|
751
|
+
type: 'file',
|
|
752
|
+
name: file.fileName,
|
|
753
|
+
relativePath: file.relativePath,
|
|
754
|
+
fileType: file.fileType,
|
|
755
|
+
source: file.source,
|
|
756
|
+
fileSizeBytes: file.fileSizeBytes,
|
|
757
|
+
sha256: file.sha256,
|
|
758
|
+
lastModified: file.lastModified,
|
|
759
|
+
linkCount: file.linkCount,
|
|
760
|
+
textRepresentation: {
|
|
761
|
+
kind: TEXT_FILE_TYPES.has(file.fileType) ? 'inline' : 'extracted',
|
|
762
|
+
extractionStatus: file.extractionStatus,
|
|
763
|
+
hasExtractedText: !!file.extractedTextSha256,
|
|
764
|
+
},
|
|
765
|
+
embedding: {
|
|
766
|
+
chunkCount: file.chunkCount || 0,
|
|
767
|
+
embeddedChunkCount: file.embeddedChunkCount || 0,
|
|
768
|
+
fullyEmbedded: (file.chunkCount || 0) > 0
|
|
769
|
+
&& file.chunkCount === file.embeddedChunkCount,
|
|
770
|
+
},
|
|
771
|
+
links,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Recursive text output: grouped by folder
|
|
779
|
+
if (flags.recursive) {
|
|
780
|
+
const folderPaths = Object.keys(allFilesByFolder).sort();
|
|
781
|
+
let anyOutput = false;
|
|
782
|
+
for (const folderPath of folderPaths) {
|
|
783
|
+
const folderFiles = allFilesByFolder[folderPath].sort((a, b) =>
|
|
784
|
+
a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' })
|
|
785
|
+
);
|
|
786
|
+
if (folderFiles.length > 0) {
|
|
787
|
+
anyOutput = true;
|
|
788
|
+
const displayPath = folderPath || '/';
|
|
789
|
+
console.log(`${BOLD}${displayPath}${RESET}`);
|
|
790
|
+
for (const file of folderFiles) {
|
|
791
|
+
const cells = [
|
|
792
|
+
'-',
|
|
793
|
+
String(file.linkCount).padStart(5),
|
|
794
|
+
formatBytes(file.fileSizeBytes || 0).padStart(8),
|
|
795
|
+
formatLsDate(file.lastModified),
|
|
796
|
+
textColumnMarker(file.fileType, file.extractionStatus),
|
|
797
|
+
embedColumnMarker(file.chunkCount, file.embeddedChunkCount),
|
|
798
|
+
file.fileName,
|
|
799
|
+
];
|
|
800
|
+
console.log(' ' + cells.join(' '));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (!anyOutput) console.log('(no files)');
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Non-recursive text output
|
|
809
|
+
if (!singleFile && folders.length === 0 && files.length === 0) {
|
|
810
|
+
console.log('(empty)');
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const headerRow = {
|
|
815
|
+
type: 'T',
|
|
816
|
+
links: 'links',
|
|
817
|
+
size: 'size',
|
|
818
|
+
modified: 'modified',
|
|
819
|
+
text: 'text',
|
|
820
|
+
emb: 'emb',
|
|
821
|
+
name: 'name',
|
|
822
|
+
isHeader: true,
|
|
823
|
+
};
|
|
824
|
+
const dataRows = [];
|
|
825
|
+
for (const folder of folders) {
|
|
826
|
+
dataRows.push({
|
|
827
|
+
type: 'd',
|
|
828
|
+
links: '-',
|
|
829
|
+
size: '-',
|
|
830
|
+
modified: formatLsDate(folder.updatedAt || folder.createdAt),
|
|
831
|
+
text: '-',
|
|
832
|
+
emb: '-',
|
|
833
|
+
name: folder.name + '/',
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
for (const file of files) {
|
|
837
|
+
dataRows.push({
|
|
838
|
+
type: '-',
|
|
839
|
+
links: String(file.linkCount),
|
|
840
|
+
size: formatBytes(file.fileSizeBytes || 0),
|
|
841
|
+
modified: formatLsDate(file.lastModified),
|
|
842
|
+
text: textColumnMarker(file.fileType, file.extractionStatus),
|
|
843
|
+
emb: embedColumnMarker(file.chunkCount, file.embeddedChunkCount),
|
|
844
|
+
name: singleFile ? file.relativePath : file.fileName,
|
|
845
|
+
fileId: file.fileId,
|
|
846
|
+
relativePath: file.relativePath,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const widths = {
|
|
851
|
+
type: 1,
|
|
852
|
+
links: Math.max(headerRow.links.length, ...dataRows.map((r) => r.links.length)),
|
|
853
|
+
size: Math.max(headerRow.size.length, ...dataRows.map((r) => r.size.length)),
|
|
854
|
+
modified: Math.max(headerRow.modified.length, ...dataRows.map((r) => r.modified.length)),
|
|
855
|
+
text: Math.max(headerRow.text.length, ...dataRows.map((r) => r.text.length)),
|
|
856
|
+
emb: Math.max(headerRow.emb.length, ...dataRows.map((r) => r.emb.length)),
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const renderLine = (r, dim) => {
|
|
860
|
+
const cells = [
|
|
861
|
+
r.type,
|
|
862
|
+
r.links.padStart(widths.links),
|
|
863
|
+
r.size.padStart(widths.size),
|
|
864
|
+
r.modified.padEnd(widths.modified),
|
|
865
|
+
r.text.padStart(widths.text),
|
|
866
|
+
r.emb.padStart(widths.emb),
|
|
867
|
+
r.name,
|
|
868
|
+
];
|
|
869
|
+
const line = cells.join(' ');
|
|
870
|
+
return dim ? `${DIM}${line}${RESET}` : line;
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
console.log(renderLine(headerRow, true));
|
|
874
|
+
for (const r of dataRows) {
|
|
875
|
+
console.log(renderLine(r, false));
|
|
876
|
+
if (flags.links && r.type === '-') {
|
|
877
|
+
const others = (linksByFile.get(r.fileId) || []).filter(
|
|
878
|
+
(l) => !(l.mountPointId === mount.id && l.relativePath === r.relativePath)
|
|
879
|
+
);
|
|
880
|
+
if (others.length > 0) {
|
|
881
|
+
const indentWidth = widths.type + widths.links + widths.size
|
|
882
|
+
+ widths.modified + widths.text + widths.emb + 6 * 2;
|
|
883
|
+
const indent = ' '.repeat(indentWidth);
|
|
884
|
+
for (const link of others) {
|
|
885
|
+
const sameMount = link.mountPointId === mount.id;
|
|
886
|
+
const display = sameMount ? link.relativePath : `${link.mountName}:${link.relativePath}`;
|
|
887
|
+
console.log(`${indent}${DIM}→ ${display}${RESET}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
} finally {
|
|
893
|
+
db.close();
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ----------------------------------------------------------------------------
|
|
898
|
+
// tree
|
|
899
|
+
// ----------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
function buildFolderTree(db, mountId, startPath, maxDepth) {
|
|
902
|
+
// Build a folder/file tree starting from startPath
|
|
903
|
+
const tree = { name: startPath || '', type: 'folder', children: [] };
|
|
904
|
+
|
|
905
|
+
function addChildren(node, parentPath, currentDepth) {
|
|
906
|
+
if (currentDepth >= maxDepth) return;
|
|
907
|
+
|
|
908
|
+
// Get immediate child folders
|
|
909
|
+
const folderQuery = parentPath === ''
|
|
910
|
+
? `SELECT id, name, path FROM doc_mount_folders
|
|
911
|
+
WHERE mountPointId = ? AND path NOT LIKE '%/%'
|
|
912
|
+
ORDER BY name COLLATE NOCASE`
|
|
913
|
+
: `SELECT id, name, path FROM doc_mount_folders
|
|
914
|
+
WHERE mountPointId = ? AND path LIKE ? AND path NOT LIKE ?
|
|
915
|
+
ORDER BY name COLLATE NOCASE`;
|
|
916
|
+
|
|
917
|
+
const folders = parentPath === ''
|
|
918
|
+
? db.prepare(folderQuery).all(mountId)
|
|
919
|
+
: db.prepare(folderQuery).all(mountId, parentPath + '/%', parentPath + '/%/%');
|
|
920
|
+
|
|
921
|
+
// Get immediate child files
|
|
922
|
+
const fileQuery = parentPath === ''
|
|
923
|
+
? `SELECT fileName, relativePath, fileSizeBytes, chunkCount, extractedText
|
|
924
|
+
FROM doc_mount_file_links l
|
|
925
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
926
|
+
WHERE l.mountPointId = ? AND l.relativePath NOT LIKE '%/%'
|
|
927
|
+
ORDER BY fileName COLLATE NOCASE`
|
|
928
|
+
: `SELECT fileName, relativePath, fileSizeBytes, chunkCount, extractedText
|
|
929
|
+
FROM doc_mount_file_links l
|
|
930
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
931
|
+
WHERE l.mountPointId = ? AND l.relativePath LIKE ? AND l.relativePath NOT LIKE ?
|
|
932
|
+
ORDER BY fileName COLLATE NOCASE`;
|
|
933
|
+
|
|
934
|
+
const files = parentPath === ''
|
|
935
|
+
? db.prepare(fileQuery).all(mountId)
|
|
936
|
+
: db.prepare(fileQuery).all(mountId, parentPath + '/%', parentPath + '/%/%');
|
|
937
|
+
|
|
938
|
+
// Add folders first (alphabetically)
|
|
939
|
+
for (const folder of folders) {
|
|
940
|
+
const childNode = { name: folder.name, type: 'folder', path: folder.path, children: [] };
|
|
941
|
+
addChildren(childNode, folder.path, currentDepth + 1);
|
|
942
|
+
node.children.push(childNode);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Then add files (alphabetically)
|
|
946
|
+
for (const file of files) {
|
|
947
|
+
node.children.push({
|
|
948
|
+
name: file.fileName,
|
|
949
|
+
type: 'file',
|
|
950
|
+
relativePath: file.relativePath,
|
|
951
|
+
size: file.fileSizeBytes || 0,
|
|
952
|
+
chunkCount: file.chunkCount || 0,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
addChildren(tree, startPath, 0);
|
|
958
|
+
return tree;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function renderTreeAscii(node, prefix, isLast, isRoot, maxNodes, nodeCount) {
|
|
962
|
+
if (nodeCount.count >= maxNodes.max) {
|
|
963
|
+
nodeCount.truncated = true;
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (!isRoot) {
|
|
968
|
+
nodeCount.count++;
|
|
969
|
+
if (nodeCount.count > maxNodes.max) {
|
|
970
|
+
nodeCount.truncated = true;
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const connector = isLast ? '└─' : '├─';
|
|
974
|
+
const typeMarker = node.type === 'folder' ? 'd' : '-';
|
|
975
|
+
const displayName = node.type === 'folder' ? node.name + '/' : node.name;
|
|
976
|
+
console.log(`${prefix}${connector} ${typeMarker} ${displayName}`);
|
|
977
|
+
prefix = prefix + (isLast ? ' ' : '│ ');
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const children = node.children || [];
|
|
981
|
+
for (let i = 0; i < children.length; i++) {
|
|
982
|
+
if (nodeCount.count >= maxNodes.max) {
|
|
983
|
+
nodeCount.truncated = true;
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
const child = children[i];
|
|
987
|
+
const last = i === children.length - 1;
|
|
988
|
+
renderTreeAscii(child, prefix, last, false, maxNodes, nodeCount);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function treeToJson(node) {
|
|
993
|
+
return {
|
|
994
|
+
name: node.name,
|
|
995
|
+
type: node.type,
|
|
996
|
+
path: node.path || undefined,
|
|
997
|
+
relativePath: node.relativePath || undefined,
|
|
998
|
+
size: node.size !== undefined ? node.size : undefined,
|
|
999
|
+
chunkCount: node.chunkCount !== undefined ? node.chunkCount : undefined,
|
|
1000
|
+
children: node.children && node.children.length > 0
|
|
1001
|
+
? node.children.map(treeToJson)
|
|
1002
|
+
: undefined,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function handleTree(flags, mountSpec, rawPath) {
|
|
1007
|
+
if (!mountSpec) {
|
|
1008
|
+
console.error('Usage: quilltap docs tree <mount> [path] [--depth N] [--max-nodes N] [--long] [--json]');
|
|
1009
|
+
process.exit(1);
|
|
1010
|
+
}
|
|
1011
|
+
const { db } = await openDb(flags);
|
|
1012
|
+
try {
|
|
1013
|
+
const mount = requireMount(db, mountSpec);
|
|
1014
|
+
const normalizedPath = normalizeLsPath(rawPath);
|
|
1015
|
+
|
|
1016
|
+
// Validate the path exists
|
|
1017
|
+
const target = resolveLsTarget(db, mount.id, normalizedPath);
|
|
1018
|
+
if (target.kind === 'none') {
|
|
1019
|
+
console.error(`No file or folder at "${normalizedPath || '/'}" in mount ${mount.name}`);
|
|
1020
|
+
process.exit(1);
|
|
1021
|
+
}
|
|
1022
|
+
if (target.kind === 'file') {
|
|
1023
|
+
console.error(`"${normalizedPath}" is a file, not a folder`);
|
|
1024
|
+
process.exit(1);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const startPath = target.kind === 'folder' ? target.path : '';
|
|
1028
|
+
const maxDepth = Math.min(flags.depth || 20, 50); // Cap at 50
|
|
1029
|
+
const maxNodes = Math.max(flags.maxNodes || 1000, 1);
|
|
1030
|
+
|
|
1031
|
+
const tree = buildFolderTree(db, mount.id, startPath, maxDepth);
|
|
1032
|
+
|
|
1033
|
+
if (flags.json) {
|
|
1034
|
+
const output = treeToJson(tree);
|
|
1035
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ASCII render
|
|
1040
|
+
const nodeCount = { count: 0, truncated: false };
|
|
1041
|
+
const displayRoot = startPath || '/';
|
|
1042
|
+
console.log(`${BOLD}${displayRoot}${RESET}`);
|
|
1043
|
+
if (tree.children && tree.children.length > 0) {
|
|
1044
|
+
for (let i = 0; i < tree.children.length; i++) {
|
|
1045
|
+
if (nodeCount.count >= maxNodes) {
|
|
1046
|
+
nodeCount.truncated = true;
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
const child = tree.children[i];
|
|
1050
|
+
const last = i === tree.children.length - 1;
|
|
1051
|
+
renderTreeAscii(child, '', last, false, { max: maxNodes }, nodeCount);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (nodeCount.truncated) {
|
|
1056
|
+
console.log(`${DIM}… (truncated at ${maxNodes} nodes; use --max-nodes <N> for more)${RESET}`);
|
|
1057
|
+
}
|
|
1058
|
+
} finally {
|
|
1059
|
+
db.close();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
264
1063
|
// ----------------------------------------------------------------------------
|
|
265
1064
|
// read
|
|
266
1065
|
// ----------------------------------------------------------------------------
|
|
@@ -298,90 +1097,70 @@ async function handleRead(flags, id, relativePath) {
|
|
|
298
1097
|
|
|
299
1098
|
function readRaw(db, mount, relativePath, flags) {
|
|
300
1099
|
const file = db.prepare(`
|
|
301
|
-
SELECT
|
|
302
|
-
FROM
|
|
303
|
-
|
|
1100
|
+
SELECT l.fileId, f.source, f.fileType
|
|
1101
|
+
FROM doc_mount_file_links l
|
|
1102
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
1103
|
+
WHERE l.mountPointId = ? AND l.relativePath = ?
|
|
304
1104
|
`).get(mount.id, relativePath);
|
|
305
1105
|
|
|
306
|
-
if (file) {
|
|
307
|
-
|
|
1106
|
+
if (!file) {
|
|
1107
|
+
console.error(`No file at ${relativePath} in mount ${mount.name}`);
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
ttyGuard(file.fileType, flags, relativePath);
|
|
308
1112
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
});
|
|
1113
|
+
if (file.source === 'filesystem') {
|
|
1114
|
+
const fullPath = path.join(mount.basePath, relativePath);
|
|
1115
|
+
if (!fs.existsSync(fullPath)) {
|
|
1116
|
+
console.error(`File missing on disk: ${fullPath}`);
|
|
1117
|
+
process.exit(1);
|
|
322
1118
|
}
|
|
1119
|
+
// Stream so we don't load huge files into memory.
|
|
1120
|
+
const stream = fs.createReadStream(fullPath);
|
|
1121
|
+
stream.pipe(process.stdout);
|
|
1122
|
+
return new Promise((resolve, reject) => {
|
|
1123
|
+
stream.on('end', resolve);
|
|
1124
|
+
stream.on('error', reject);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
323
1127
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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}`);
|
|
1128
|
+
if (file.source === 'database') {
|
|
1129
|
+
if (TEXT_FILE_TYPES.has(file.fileType)) {
|
|
1130
|
+
const doc = db.prepare(
|
|
1131
|
+
`SELECT content FROM doc_mount_documents WHERE fileId = ?`
|
|
1132
|
+
).get(file.fileId);
|
|
1133
|
+
if (!doc) {
|
|
1134
|
+
console.error(`File row exists but no document content for ${relativePath}`);
|
|
344
1135
|
process.exit(1);
|
|
345
1136
|
}
|
|
346
|
-
process.stdout.write(
|
|
1137
|
+
process.stdout.write(doc.content);
|
|
347
1138
|
return;
|
|
348
1139
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
WHERE mountPointId = ? AND relativePath = ?
|
|
358
|
-
`).get(mount.id, relativePath);
|
|
359
|
-
if (blob) {
|
|
360
|
-
ttyGuard('blob', flags, relativePath);
|
|
1140
|
+
// Binary stored in doc_mount_blobs
|
|
1141
|
+
const blob = db.prepare(
|
|
1142
|
+
`SELECT data FROM doc_mount_blobs WHERE fileId = ?`
|
|
1143
|
+
).get(file.fileId);
|
|
1144
|
+
if (!blob) {
|
|
1145
|
+
console.error(`File row exists but no blob bytes for ${relativePath}`);
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
}
|
|
361
1148
|
process.stdout.write(blob.data);
|
|
362
1149
|
return;
|
|
363
1150
|
}
|
|
364
1151
|
|
|
365
|
-
console.error(`
|
|
1152
|
+
console.error(`Unknown file source: ${file.source}`);
|
|
366
1153
|
process.exit(1);
|
|
367
1154
|
}
|
|
368
1155
|
|
|
369
1156
|
function readRendered(db, mount, relativePath) {
|
|
370
|
-
// 1.
|
|
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.
|
|
1157
|
+
// 1. Look up the link row (it carries extractedText now).
|
|
381
1158
|
const file = db.prepare(`
|
|
382
|
-
SELECT id,
|
|
383
|
-
|
|
384
|
-
|
|
1159
|
+
SELECT l.id AS linkId, l.fileId, l.extractedText,
|
|
1160
|
+
f.source, f.fileType
|
|
1161
|
+
FROM doc_mount_file_links l
|
|
1162
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
1163
|
+
WHERE l.mountPointId = ? AND l.relativePath = ?
|
|
385
1164
|
`).get(mount.id, relativePath);
|
|
386
1165
|
|
|
387
1166
|
if (!file) {
|
|
@@ -389,12 +1168,17 @@ function readRendered(db, mount, relativePath) {
|
|
|
389
1168
|
process.exit(1);
|
|
390
1169
|
}
|
|
391
1170
|
|
|
1171
|
+
// 2. Extracted text on the link wins.
|
|
1172
|
+
if (file.extractedText) {
|
|
1173
|
+
process.stdout.write(file.extractedText);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
392
1177
|
// 3. Database-backed text doc — content IS the rendered form.
|
|
393
1178
|
if (file.source === 'database' && TEXT_FILE_TYPES.has(file.fileType)) {
|
|
394
|
-
const doc = db.prepare(
|
|
395
|
-
SELECT content FROM doc_mount_documents
|
|
396
|
-
|
|
397
|
-
`).get(mount.id, relativePath);
|
|
1179
|
+
const doc = db.prepare(
|
|
1180
|
+
`SELECT content FROM doc_mount_documents WHERE fileId = ?`
|
|
1181
|
+
).get(file.fileId);
|
|
398
1182
|
if (doc) {
|
|
399
1183
|
process.stdout.write(doc.content);
|
|
400
1184
|
return;
|
|
@@ -410,12 +1194,12 @@ function readRendered(db, mount, relativePath) {
|
|
|
410
1194
|
}
|
|
411
1195
|
}
|
|
412
1196
|
|
|
413
|
-
// 5. Fall back to concatenated chunks.
|
|
1197
|
+
// 5. Fall back to concatenated chunks (now keyed by linkId).
|
|
414
1198
|
const chunks = db.prepare(`
|
|
415
1199
|
SELECT content FROM doc_mount_chunks
|
|
416
|
-
WHERE
|
|
1200
|
+
WHERE linkId = ?
|
|
417
1201
|
ORDER BY chunkIndex
|
|
418
|
-
`).all(file.
|
|
1202
|
+
`).all(file.linkId);
|
|
419
1203
|
if (chunks.length === 0) {
|
|
420
1204
|
console.error(`No rendered text available for ${relativePath}. Run 'quilltap docs scan ${mount.id}' to extract and embed.`);
|
|
421
1205
|
process.exit(1);
|
|
@@ -450,16 +1234,16 @@ async function handleExport(flags, id, outputDir) {
|
|
|
450
1234
|
const { db } = await openDb(flags);
|
|
451
1235
|
let writtenFiles = 0;
|
|
452
1236
|
let writtenBytes = 0;
|
|
453
|
-
const writtenPaths = new Set();
|
|
454
1237
|
try {
|
|
455
1238
|
const mount = requireMount(db, id);
|
|
456
1239
|
|
|
457
1240
|
const files = db.prepare(`
|
|
458
|
-
SELECT
|
|
459
|
-
FROM
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
1241
|
+
SELECT l.fileId, l.relativePath, f.source, f.fileType
|
|
1242
|
+
FROM doc_mount_file_links l
|
|
1243
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
1244
|
+
WHERE l.mountPointId = ?
|
|
1245
|
+
ORDER BY l.relativePath
|
|
1246
|
+
`).all(mount.id);
|
|
463
1247
|
|
|
464
1248
|
for (const file of files) {
|
|
465
1249
|
const dest = path.join(resolvedOut, file.relativePath);
|
|
@@ -475,10 +1259,9 @@ async function handleExport(flags, id, outputDir) {
|
|
|
475
1259
|
writtenBytes += fs.statSync(dest).size;
|
|
476
1260
|
} else if (file.source === 'database') {
|
|
477
1261
|
if (TEXT_FILE_TYPES.has(file.fileType)) {
|
|
478
|
-
const doc = db.prepare(
|
|
479
|
-
SELECT content FROM doc_mount_documents
|
|
480
|
-
|
|
481
|
-
`).get(id, file.relativePath);
|
|
1262
|
+
const doc = db.prepare(
|
|
1263
|
+
`SELECT content FROM doc_mount_documents WHERE fileId = ?`
|
|
1264
|
+
).get(file.fileId);
|
|
482
1265
|
if (!doc) {
|
|
483
1266
|
console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no document content)`);
|
|
484
1267
|
continue;
|
|
@@ -486,10 +1269,9 @@ async function handleExport(flags, id, outputDir) {
|
|
|
486
1269
|
fs.writeFileSync(dest, doc.content, 'utf8');
|
|
487
1270
|
writtenBytes += Buffer.byteLength(doc.content, 'utf8');
|
|
488
1271
|
} else {
|
|
489
|
-
const blob = db.prepare(
|
|
490
|
-
SELECT data FROM doc_mount_blobs
|
|
491
|
-
|
|
492
|
-
`).get(id, file.relativePath);
|
|
1272
|
+
const blob = db.prepare(
|
|
1273
|
+
`SELECT data FROM doc_mount_blobs WHERE fileId = ?`
|
|
1274
|
+
).get(file.fileId);
|
|
493
1275
|
if (!blob) {
|
|
494
1276
|
console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no blob bytes)`);
|
|
495
1277
|
continue;
|
|
@@ -502,22 +1284,6 @@ async function handleExport(flags, id, outputDir) {
|
|
|
502
1284
|
continue;
|
|
503
1285
|
}
|
|
504
1286
|
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
1287
|
}
|
|
522
1288
|
|
|
523
1289
|
console.log(`${GREEN}Exported${RESET} ${writtenFiles} file(s), ${formatBytes(writtenBytes)} → ${resolvedOut}`);
|
|
@@ -574,47 +1340,1266 @@ async function handleScan(flags, id) {
|
|
|
574
1340
|
}
|
|
575
1341
|
|
|
576
1342
|
// ----------------------------------------------------------------------------
|
|
577
|
-
//
|
|
1343
|
+
// Helpers shared by write/delete/mkdir/move/copy
|
|
578
1344
|
// ----------------------------------------------------------------------------
|
|
579
1345
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1346
|
+
function sha256OfBuffer(buf) {
|
|
1347
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function isConnectionRefused(err) {
|
|
1351
|
+
if (!err) return false;
|
|
1352
|
+
const code = err.cause && err.cause.code ? err.cause.code : err.code;
|
|
1353
|
+
return (
|
|
1354
|
+
code === 'ECONNREFUSED' ||
|
|
1355
|
+
code === 'ENOTFOUND' ||
|
|
1356
|
+
code === 'EHOSTUNREACH' ||
|
|
1357
|
+
code === 'ECONNRESET'
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async function tryFetch(url, init) {
|
|
1362
|
+
try {
|
|
1363
|
+
return { ok: true, res: await fetch(url, init) };
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
if (isConnectionRefused(err)) {
|
|
1366
|
+
return { ok: false, err };
|
|
1367
|
+
}
|
|
1368
|
+
throw err;
|
|
584
1369
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function readBodyJson(res) {
|
|
1373
|
+
try {
|
|
1374
|
+
return await res.json();
|
|
1375
|
+
} catch {
|
|
1376
|
+
return null;
|
|
588
1377
|
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function actionUrl(port, mountId, action) {
|
|
1381
|
+
return `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}?action=${action}`;
|
|
1382
|
+
}
|
|
589
1383
|
|
|
590
|
-
|
|
591
|
-
|
|
1384
|
+
function unwrap(body) {
|
|
1385
|
+
if (body && typeof body === 'object' && 'data' in body) return body.data;
|
|
1386
|
+
return body;
|
|
1387
|
+
}
|
|
592
1388
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1389
|
+
// ----------------------------------------------------------------------------
|
|
1390
|
+
// Direct (offline) helpers — filesystem-only fallback used when the server is
|
|
1391
|
+
// unreachable. Database-mount writes always require the server because the
|
|
1392
|
+
// index updates need the server's reindex / embed pipeline.
|
|
1393
|
+
// ----------------------------------------------------------------------------
|
|
1394
|
+
|
|
1395
|
+
function requireServerForDb(mount, action) {
|
|
1396
|
+
if (mount.mountType === 'database') {
|
|
1397
|
+
console.error(
|
|
1398
|
+
`Cannot ${action} on database-backed mount "${mount.name}" without the Quilltap server.`
|
|
1399
|
+
);
|
|
1400
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
1401
|
+
process.exit(1);
|
|
596
1402
|
}
|
|
1403
|
+
}
|
|
597
1404
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1405
|
+
function loadMountBasePath(db, mountId) {
|
|
1406
|
+
const row = db.prepare(
|
|
1407
|
+
'SELECT basePath FROM doc_mount_points WHERE id = ?'
|
|
1408
|
+
).get(mountId);
|
|
1409
|
+
if (!row || !row.basePath) {
|
|
1410
|
+
throw new Error(`Mount ${mountId} has no basePath`);
|
|
1411
|
+
}
|
|
1412
|
+
return row.basePath;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function fsAbsolute(basePath, relativePath) {
|
|
1416
|
+
const abs = path.resolve(basePath, relativePath);
|
|
1417
|
+
const base = path.resolve(basePath);
|
|
1418
|
+
const withSep = base.endsWith(path.sep) ? base : base + path.sep;
|
|
1419
|
+
if (abs !== base && !abs.startsWith(withSep)) {
|
|
1420
|
+
throw new Error(`Path escapes mount boundary: ${relativePath}`);
|
|
1421
|
+
}
|
|
1422
|
+
return abs;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ----------------------------------------------------------------------------
|
|
1426
|
+
// write
|
|
1427
|
+
// ----------------------------------------------------------------------------
|
|
1428
|
+
|
|
1429
|
+
async function readWriteSource(filename) {
|
|
1430
|
+
if (filename) {
|
|
1431
|
+
const resolved = filename.startsWith('~')
|
|
1432
|
+
? path.join(require('os').homedir(), filename.slice(1))
|
|
1433
|
+
: path.resolve(filename);
|
|
1434
|
+
if (!fs.existsSync(resolved)) {
|
|
1435
|
+
throw new Error(`Source file not found: ${resolved}`);
|
|
1436
|
+
}
|
|
1437
|
+
return fs.readFileSync(resolved);
|
|
1438
|
+
}
|
|
1439
|
+
if (process.stdin.isTTY) {
|
|
1440
|
+
throw new Error('No source file given and stdin is a TTY. Pipe content or pass a filename.');
|
|
1441
|
+
}
|
|
1442
|
+
const chunks = [];
|
|
1443
|
+
for await (const chunk of process.stdin) {
|
|
1444
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1445
|
+
}
|
|
1446
|
+
if (chunks.length === 0) {
|
|
1447
|
+
throw new Error('Empty stdin payload');
|
|
1448
|
+
}
|
|
1449
|
+
return Buffer.concat(chunks);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
async function writeViaHttp(port, mountId, relativePath, data, force) {
|
|
1453
|
+
const form = new FormData();
|
|
1454
|
+
const blob = new Blob([data]);
|
|
1455
|
+
form.append('file', blob, path.posix.basename(relativePath));
|
|
1456
|
+
form.append('path', relativePath);
|
|
1457
|
+
form.append('force', force ? 'true' : 'false');
|
|
1458
|
+
const attempt = await tryFetch(actionUrl(port, mountId, 'write-file'), {
|
|
1459
|
+
method: 'POST',
|
|
1460
|
+
body: form,
|
|
1461
|
+
});
|
|
1462
|
+
if (!attempt.ok) return { reachable: false };
|
|
1463
|
+
const body = await readBodyJson(attempt.res);
|
|
1464
|
+
if (!attempt.res.ok) {
|
|
1465
|
+
const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
|
|
1466
|
+
const code = body && body.code ? body.code : null;
|
|
1467
|
+
return { reachable: true, ok: false, status: attempt.res.status, error: msg, code };
|
|
1468
|
+
}
|
|
1469
|
+
return { reachable: true, ok: true, result: unwrap(body) };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
async function handleWrite(flags, positional) {
|
|
1473
|
+
const force = flags.force;
|
|
1474
|
+
const [mountSpec, relativePath, filename] = positional;
|
|
1475
|
+
if (!mountSpec || !relativePath) {
|
|
1476
|
+
console.error('Usage: quilltap docs write [--force] <mount> <path> [filename]');
|
|
1477
|
+
process.exit(1);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const data = await readWriteSource(filename);
|
|
1481
|
+
const sourceSha = sha256OfBuffer(data);
|
|
1482
|
+
|
|
1483
|
+
const { db } = await openDb(flags);
|
|
1484
|
+
let mount;
|
|
1485
|
+
try {
|
|
1486
|
+
mount = requireMount(db, mountSpec);
|
|
1487
|
+
} finally {
|
|
1488
|
+
db.close();
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const http = await writeViaHttp(flags.port, mount.id, relativePath, data, force);
|
|
1492
|
+
if (http.reachable) {
|
|
1493
|
+
if (!http.ok) {
|
|
1494
|
+
console.error(`Write failed: ${http.error}`);
|
|
1495
|
+
process.exit(http.code === 'DEST_EXISTS' ? 2 : 1);
|
|
1496
|
+
}
|
|
1497
|
+
const r = http.result;
|
|
1498
|
+
if (r.sha256 !== sourceSha) {
|
|
1499
|
+
console.error(`Checksum mismatch: source ${sourceSha} != dest ${r.sha256}`);
|
|
1500
|
+
process.exit(1);
|
|
1501
|
+
}
|
|
1502
|
+
if (flags.json) {
|
|
1503
|
+
process.stdout.write(JSON.stringify({ ...r, sourceSha256: sourceSha }, null, 2) + '\n');
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
console.log(`${GREEN}Wrote${RESET} ${mount.name}:${r.destPath} (${formatBytes(r.sizeBytes)}, sha=${r.sha256.slice(0, 12)}…)`);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Direct fallback — filesystem mounts only.
|
|
1511
|
+
requireServerForDb(mount, 'write');
|
|
1512
|
+
|
|
1513
|
+
const { db: db2 } = await openDb(flags);
|
|
1514
|
+
let basePath;
|
|
1515
|
+
try {
|
|
1516
|
+
basePath = loadMountBasePath(db2, mount.id);
|
|
1517
|
+
} finally {
|
|
1518
|
+
db2.close();
|
|
1519
|
+
}
|
|
1520
|
+
const abs = fsAbsolute(basePath, relativePath);
|
|
1521
|
+
if (fs.existsSync(abs) && !force) {
|
|
1522
|
+
console.error(`Destination already exists: ${relativePath}. Use --force to overwrite.`);
|
|
1523
|
+
process.exit(2);
|
|
1524
|
+
}
|
|
1525
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
1526
|
+
fs.writeFileSync(abs, data);
|
|
1527
|
+
const destSha = sha256OfBuffer(fs.readFileSync(abs));
|
|
1528
|
+
if (destSha !== sourceSha) {
|
|
1529
|
+
console.error(`Checksum mismatch after write: source ${sourceSha} != dest ${destSha}`);
|
|
1530
|
+
process.exit(1);
|
|
1531
|
+
}
|
|
1532
|
+
if (flags.json) {
|
|
1533
|
+
process.stdout.write(JSON.stringify({
|
|
1534
|
+
sha256: destSha,
|
|
1535
|
+
sourceSha256: sourceSha,
|
|
1536
|
+
sizeBytes: data.length,
|
|
1537
|
+
destPath: relativePath,
|
|
1538
|
+
mountPointId: mount.id,
|
|
1539
|
+
mode: 'direct',
|
|
1540
|
+
}, null, 2) + '\n');
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
console.log(`${GREEN}Wrote${RESET} ${mount.name}:${relativePath} (${formatBytes(data.length)}, sha=${destSha.slice(0, 12)}…) ${DIM}[direct mode — run 'quilltap docs scan' once the server is back]${RESET}`);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// ----------------------------------------------------------------------------
|
|
1547
|
+
// delete
|
|
1548
|
+
// ----------------------------------------------------------------------------
|
|
1549
|
+
|
|
1550
|
+
async function deleteViaHttp(port, mountId, relativePath) {
|
|
1551
|
+
const attempt = await tryFetch(actionUrl(port, mountId, 'delete-file'), {
|
|
1552
|
+
method: 'POST',
|
|
1553
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1554
|
+
body: JSON.stringify({ path: relativePath }),
|
|
1555
|
+
});
|
|
1556
|
+
if (!attempt.ok) return { reachable: false };
|
|
1557
|
+
const body = await readBodyJson(attempt.res);
|
|
1558
|
+
if (!attempt.res.ok) {
|
|
1559
|
+
const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
|
|
1560
|
+
return { reachable: true, ok: false, status: attempt.res.status, error: msg };
|
|
1561
|
+
}
|
|
1562
|
+
return { reachable: true, ok: true, result: unwrap(body) };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
async function handleDelete(flags, positional) {
|
|
1566
|
+
const [mountSpec, relativePath] = positional;
|
|
1567
|
+
if (!mountSpec || !relativePath) {
|
|
1568
|
+
console.error('Usage: quilltap docs delete <mount> <path>');
|
|
1569
|
+
process.exit(1);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const { db } = await openDb(flags);
|
|
1573
|
+
let mount;
|
|
1574
|
+
try {
|
|
1575
|
+
mount = requireMount(db, mountSpec);
|
|
1576
|
+
} finally {
|
|
1577
|
+
db.close();
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const http = await deleteViaHttp(flags.port, mount.id, relativePath);
|
|
1581
|
+
if (http.reachable) {
|
|
1582
|
+
if (!http.ok) {
|
|
1583
|
+
console.error(`Delete failed: ${http.error}`);
|
|
1584
|
+
process.exit(1);
|
|
1585
|
+
}
|
|
1586
|
+
const r = http.result;
|
|
1587
|
+
if (flags.json) {
|
|
1588
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (r.deleted) {
|
|
1592
|
+
console.log(`${GREEN}Deleted${RESET} ${mount.name}:${r.path}`);
|
|
1593
|
+
} else {
|
|
1594
|
+
console.log(`${DIM}No-op${RESET} ${mount.name}:${r.path} (did not exist)`);
|
|
1595
|
+
}
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
requireServerForDb(mount, 'delete');
|
|
1600
|
+
|
|
1601
|
+
const { db: db2 } = await openDb(flags);
|
|
1602
|
+
let basePath;
|
|
1603
|
+
try {
|
|
1604
|
+
basePath = loadMountBasePath(db2, mount.id);
|
|
1605
|
+
} finally {
|
|
1606
|
+
db2.close();
|
|
1607
|
+
}
|
|
1608
|
+
const abs = fsAbsolute(basePath, relativePath);
|
|
1609
|
+
let existed = false;
|
|
1610
|
+
try {
|
|
1611
|
+
fs.unlinkSync(abs);
|
|
1612
|
+
existed = true;
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
if (err.code !== 'ENOENT') throw err;
|
|
1615
|
+
}
|
|
1616
|
+
if (fs.existsSync(abs)) {
|
|
1617
|
+
console.error(`Delete verification failed: path still present at ${abs}`);
|
|
1618
|
+
process.exit(1);
|
|
1619
|
+
}
|
|
1620
|
+
if (flags.json) {
|
|
1621
|
+
process.stdout.write(JSON.stringify({
|
|
1622
|
+
deleted: existed,
|
|
1623
|
+
path: relativePath,
|
|
1624
|
+
mountPointId: mount.id,
|
|
1625
|
+
mode: 'direct',
|
|
1626
|
+
}, null, 2) + '\n');
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (existed) {
|
|
1630
|
+
console.log(`${GREEN}Deleted${RESET} ${mount.name}:${relativePath} ${DIM}[direct mode]${RESET}`);
|
|
1631
|
+
} else {
|
|
1632
|
+
console.log(`${DIM}No-op${RESET} ${mount.name}:${relativePath} (did not exist) ${DIM}[direct mode]${RESET}`);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// ----------------------------------------------------------------------------
|
|
1637
|
+
// mkdir
|
|
1638
|
+
// ----------------------------------------------------------------------------
|
|
1639
|
+
|
|
1640
|
+
async function mkdirViaHttp(port, mountId, relativePath) {
|
|
1641
|
+
const url = `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}/folders`;
|
|
1642
|
+
const attempt = await tryFetch(url, {
|
|
1643
|
+
method: 'POST',
|
|
1644
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1645
|
+
body: JSON.stringify({ path: relativePath }),
|
|
1646
|
+
});
|
|
1647
|
+
if (!attempt.ok) return { reachable: false };
|
|
1648
|
+
const body = await readBodyJson(attempt.res);
|
|
1649
|
+
if (!attempt.res.ok) {
|
|
1650
|
+
const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
|
|
1651
|
+
return { reachable: true, ok: false, status: attempt.res.status, error: msg };
|
|
1652
|
+
}
|
|
1653
|
+
return { reachable: true, ok: true, result: unwrap(body) };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
async function handleMkdir(flags, positional) {
|
|
1657
|
+
const [mountSpec, relativePath] = positional;
|
|
1658
|
+
if (!mountSpec || !relativePath) {
|
|
1659
|
+
console.error('Usage: quilltap docs mkdir <mount> <path>');
|
|
1660
|
+
process.exit(1);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const { db } = await openDb(flags);
|
|
1664
|
+
let mount;
|
|
1665
|
+
try {
|
|
1666
|
+
mount = requireMount(db, mountSpec);
|
|
1667
|
+
} finally {
|
|
1668
|
+
db.close();
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const http = await mkdirViaHttp(flags.port, mount.id, relativePath);
|
|
1672
|
+
if (http.reachable) {
|
|
1673
|
+
if (!http.ok) {
|
|
1674
|
+
console.error(`mkdir failed: ${http.error}`);
|
|
1675
|
+
process.exit(1);
|
|
1676
|
+
}
|
|
1677
|
+
if (flags.json) {
|
|
1678
|
+
process.stdout.write(JSON.stringify(http.result, null, 2) + '\n');
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
console.log(`${GREEN}Folder ready${RESET} ${mount.name}:${http.result.path ?? relativePath}`);
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
requireServerForDb(mount, 'mkdir');
|
|
1686
|
+
|
|
1687
|
+
const { db: db2 } = await openDb(flags);
|
|
1688
|
+
let basePath;
|
|
1689
|
+
try {
|
|
1690
|
+
basePath = loadMountBasePath(db2, mount.id);
|
|
1691
|
+
} finally {
|
|
1692
|
+
db2.close();
|
|
1693
|
+
}
|
|
1694
|
+
const abs = fsAbsolute(basePath, relativePath);
|
|
1695
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
1696
|
+
if (!fs.existsSync(abs)) {
|
|
1697
|
+
console.error(`mkdir verification failed: ${abs} does not exist after creation`);
|
|
1698
|
+
process.exit(1);
|
|
1699
|
+
}
|
|
1700
|
+
if (flags.json) {
|
|
1701
|
+
process.stdout.write(JSON.stringify({
|
|
1702
|
+
path: relativePath,
|
|
1703
|
+
mountPointId: mount.id,
|
|
1704
|
+
mode: 'direct',
|
|
1705
|
+
}, null, 2) + '\n');
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
console.log(`${GREEN}Folder ready${RESET} ${mount.name}:${relativePath} ${DIM}[direct mode]${RESET}`);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// ----------------------------------------------------------------------------
|
|
1712
|
+
// move / copy
|
|
1713
|
+
// ----------------------------------------------------------------------------
|
|
1714
|
+
|
|
1715
|
+
async function fileOpViaHttp(port, action, sourceMountId, body) {
|
|
1716
|
+
const attempt = await tryFetch(actionUrl(port, sourceMountId, action), {
|
|
1717
|
+
method: 'POST',
|
|
1718
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1719
|
+
body: JSON.stringify(body),
|
|
1720
|
+
});
|
|
1721
|
+
if (!attempt.ok) return { reachable: false };
|
|
1722
|
+
const resBody = await readBodyJson(attempt.res);
|
|
1723
|
+
if (!attempt.res.ok) {
|
|
1724
|
+
const msg = resBody && resBody.error ? resBody.error : `HTTP ${attempt.res.status}`;
|
|
1725
|
+
const code = resBody && resBody.code ? resBody.code : null;
|
|
1726
|
+
return { reachable: true, ok: false, status: attempt.res.status, error: msg, code };
|
|
1727
|
+
}
|
|
1728
|
+
return { reachable: true, ok: true, result: unwrap(resBody) };
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async function directFsFileOp({ flags, sourceMount, srcPath, destMount, dstPath, action, force }) {
|
|
1732
|
+
if (sourceMount.mountType === 'database' || destMount.mountType === 'database') {
|
|
1733
|
+
console.error(
|
|
1734
|
+
`Cannot ${action} between/with database-backed mounts without the Quilltap server.`
|
|
1735
|
+
);
|
|
1736
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
1737
|
+
process.exit(1);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
const { db } = await openDb(flags);
|
|
1741
|
+
let srcBase, dstBase;
|
|
1742
|
+
try {
|
|
1743
|
+
srcBase = loadMountBasePath(db, sourceMount.id);
|
|
1744
|
+
dstBase = loadMountBasePath(db, destMount.id);
|
|
1745
|
+
} finally {
|
|
1746
|
+
db.close();
|
|
1747
|
+
}
|
|
1748
|
+
const srcAbs = fsAbsolute(srcBase, srcPath);
|
|
1749
|
+
const dstAbs = fsAbsolute(dstBase, dstPath);
|
|
1750
|
+
|
|
1751
|
+
if (!fs.existsSync(srcAbs)) {
|
|
1752
|
+
console.error(`Source not found: ${srcPath}`);
|
|
1753
|
+
process.exit(1);
|
|
1754
|
+
}
|
|
1755
|
+
if (fs.existsSync(dstAbs)) {
|
|
1756
|
+
if (action === 'copy' && !force) {
|
|
1757
|
+
console.error(`Destination already exists: ${dstPath}. Use --force to overwrite.`);
|
|
1758
|
+
process.exit(2);
|
|
1759
|
+
}
|
|
1760
|
+
if (action === 'move') {
|
|
1761
|
+
console.error(`Destination already exists: ${dstPath}. Move will not overwrite.`);
|
|
1762
|
+
process.exit(2);
|
|
1763
|
+
}
|
|
1764
|
+
fs.unlinkSync(dstAbs);
|
|
1765
|
+
}
|
|
1766
|
+
fs.mkdirSync(path.dirname(dstAbs), { recursive: true });
|
|
1767
|
+
|
|
1768
|
+
const sourceSha = sha256OfBuffer(fs.readFileSync(srcAbs));
|
|
1769
|
+
let strategy;
|
|
1770
|
+
if (action === 'move') {
|
|
1771
|
+
try {
|
|
1772
|
+
fs.renameSync(srcAbs, dstAbs);
|
|
1773
|
+
strategy = 'rename';
|
|
1774
|
+
} catch (err) {
|
|
1775
|
+
if (err.code !== 'EXDEV') throw err;
|
|
1776
|
+
fs.copyFileSync(srcAbs, dstAbs);
|
|
1777
|
+
fs.unlinkSync(srcAbs);
|
|
1778
|
+
strategy = 'byte-copy';
|
|
1779
|
+
}
|
|
1780
|
+
} else if (force) {
|
|
1781
|
+
fs.copyFileSync(srcAbs, dstAbs);
|
|
1782
|
+
strategy = 'byte-copy';
|
|
1783
|
+
} else {
|
|
1784
|
+
try {
|
|
1785
|
+
fs.linkSync(srcAbs, dstAbs);
|
|
1786
|
+
strategy = 'fs-link';
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
if (err.code !== 'EXDEV') throw err;
|
|
1789
|
+
fs.copyFileSync(srcAbs, dstAbs);
|
|
1790
|
+
strategy = 'byte-copy';
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const destSha = sha256OfBuffer(fs.readFileSync(dstAbs));
|
|
1795
|
+
if (destSha !== sourceSha) {
|
|
1796
|
+
console.error(`Checksum mismatch after ${action}: ${sourceSha} != ${destSha}`);
|
|
1797
|
+
process.exit(1);
|
|
1798
|
+
}
|
|
1799
|
+
return {
|
|
1800
|
+
strategy,
|
|
1801
|
+
sourceSha256: sourceSha,
|
|
1802
|
+
destSha256: destSha,
|
|
1803
|
+
sizeBytes: fs.statSync(dstAbs).size,
|
|
1804
|
+
sourcePath: srcPath,
|
|
1805
|
+
destPath: dstPath,
|
|
1806
|
+
sourceMountPointId: sourceMount.id,
|
|
1807
|
+
destMountPointId: destMount.id,
|
|
1808
|
+
mode: 'direct',
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async function handleFileOp(flags, positional, action) {
|
|
1813
|
+
const [srcMountSpec, srcPath, dstMountSpec, dstPath] = positional;
|
|
1814
|
+
if (!srcMountSpec || !srcPath || !dstMountSpec || !dstPath) {
|
|
1815
|
+
const flagHint = action === 'copy' ? '[--force] ' : '';
|
|
1816
|
+
console.error(`Usage: quilltap docs ${action} ${flagHint}<srcMount> <srcPath> <dstMount> <dstPath>`);
|
|
1817
|
+
process.exit(1);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const { db } = await openDb(flags);
|
|
1821
|
+
let sourceMount, destMount;
|
|
1822
|
+
try {
|
|
1823
|
+
sourceMount = requireMount(db, srcMountSpec);
|
|
1824
|
+
destMount = requireMount(db, dstMountSpec);
|
|
1825
|
+
} finally {
|
|
1826
|
+
db.close();
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
const http = await fileOpViaHttp(flags.port, `${action}-file`, sourceMount.id, {
|
|
1830
|
+
sourcePath: srcPath,
|
|
1831
|
+
destMountPointId: destMount.id,
|
|
1832
|
+
destPath: dstPath,
|
|
1833
|
+
...(action === 'copy' ? { force: !!flags.force } : {}),
|
|
1834
|
+
});
|
|
1835
|
+
if (http.reachable) {
|
|
1836
|
+
if (!http.ok) {
|
|
1837
|
+
console.error(`${action} failed: ${http.error}`);
|
|
1838
|
+
process.exit(http.code === 'DEST_EXISTS' ? 2 : 1);
|
|
1839
|
+
}
|
|
1840
|
+
const r = http.result;
|
|
1841
|
+
if (r.sourceSha256 !== r.destSha256) {
|
|
1842
|
+
console.error(`Checksum mismatch: source ${r.sourceSha256} != dest ${r.destSha256}`);
|
|
1843
|
+
process.exit(1);
|
|
1844
|
+
}
|
|
1845
|
+
if (flags.json) {
|
|
1846
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
const verb = action === 'move' ? 'Moved' : 'Copied';
|
|
1850
|
+
console.log(`${GREEN}${verb}${RESET} ${sourceMount.name}:${r.sourcePath} → ${destMount.name}:${r.destPath} ${DIM}(${r.strategy}, ${formatBytes(r.sizeBytes)}, sha=${r.destSha256.slice(0, 12)}…)${RESET}`);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
const result = await directFsFileOp({
|
|
1855
|
+
flags,
|
|
1856
|
+
sourceMount,
|
|
1857
|
+
srcPath,
|
|
1858
|
+
destMount,
|
|
1859
|
+
dstPath,
|
|
1860
|
+
action,
|
|
1861
|
+
force: !!flags.force,
|
|
1862
|
+
});
|
|
1863
|
+
if (flags.json) {
|
|
1864
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const verb = action === 'move' ? 'Moved' : 'Copied';
|
|
1868
|
+
console.log(`${GREEN}${verb}${RESET} ${sourceMount.name}:${result.sourcePath} → ${destMount.name}:${result.destPath} ${DIM}(${result.strategy}, ${formatBytes(result.sizeBytes)}, sha=${result.destSha256.slice(0, 12)}…) [direct mode — run 'quilltap docs scan' once the server is back]${RESET}`);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// ----------------------------------------------------------------------------
|
|
1872
|
+
// dispatch
|
|
1873
|
+
// ----------------------------------------------------------------------------
|
|
1874
|
+
|
|
1875
|
+
// ----------------------------------------------------------------------------
|
|
1876
|
+
// find — substring search on relativePath
|
|
1877
|
+
// ----------------------------------------------------------------------------
|
|
1878
|
+
|
|
1879
|
+
function escapeLike(s) {
|
|
1880
|
+
// SQLite LIKE uses % and _ as wildcards; we treat the pattern as a substring
|
|
1881
|
+
// literal so the user's % / _ don't have surprise semantics.
|
|
1882
|
+
return s.replace(/[%_\\]/g, ch => '\\' + ch);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function resolveSearchMounts(db, mountSpec) {
|
|
1886
|
+
if (!mountSpec || mountSpec === 'all') {
|
|
1887
|
+
return db.prepare(
|
|
1888
|
+
`SELECT * FROM doc_mount_points ORDER BY name COLLATE NOCASE`
|
|
1889
|
+
).all();
|
|
1890
|
+
}
|
|
1891
|
+
return [requireMount(db, mountSpec)];
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
async function handleFind(flags, positional) {
|
|
1895
|
+
const pattern = positional[0];
|
|
1896
|
+
if (!pattern) {
|
|
1897
|
+
console.error('Usage: quilltap docs find [--mount <name|id|all>] [--type file|folder] [--ext <ext>] [--limit N] [--json] <pattern>');
|
|
1898
|
+
process.exit(1);
|
|
1899
|
+
}
|
|
1900
|
+
const limit = flags.limit > 0 ? flags.limit : 100;
|
|
1901
|
+
const type = flags.type || 'file';
|
|
1902
|
+
if (type !== 'file' && type !== 'folder') {
|
|
1903
|
+
console.error(`Error: --type must be file or folder, got "${flags.type}"`);
|
|
1904
|
+
process.exit(1);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
const { db } = await openDb(flags);
|
|
1908
|
+
try {
|
|
1909
|
+
const mounts = resolveSearchMounts(db, flags.mount);
|
|
1910
|
+
const showMountColumn = !flags.mount || flags.mount === 'all';
|
|
1911
|
+
|
|
1912
|
+
const liked = `%${escapeLike(pattern)}%`;
|
|
1913
|
+
|
|
1914
|
+
const out = [];
|
|
1915
|
+
if (type === 'file') {
|
|
1916
|
+
// LIKE is case-insensitive on NOCASE collation; use COLLATE NOCASE on
|
|
1917
|
+
// the comparison to make match independent of stored case.
|
|
1918
|
+
const stmt = db.prepare(`
|
|
1919
|
+
SELECT l.relativePath, l.lastModified, l.updatedAt,
|
|
1920
|
+
f.fileSizeBytes, f.fileType
|
|
1921
|
+
FROM doc_mount_file_links l
|
|
1922
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
1923
|
+
WHERE l.mountPointId = ?
|
|
1924
|
+
AND l.relativePath LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
1925
|
+
ORDER BY l.relativePath COLLATE NOCASE
|
|
1926
|
+
`);
|
|
1927
|
+
for (const mount of mounts) {
|
|
1928
|
+
const rows = stmt.all(mount.id, liked);
|
|
1929
|
+
for (const r of rows) {
|
|
1930
|
+
if (flags.ext) {
|
|
1931
|
+
const ext = flags.ext.startsWith('.') ? flags.ext.slice(1) : flags.ext;
|
|
1932
|
+
if (!r.relativePath.toLowerCase().endsWith('.' + ext.toLowerCase())) continue;
|
|
1933
|
+
}
|
|
1934
|
+
out.push({
|
|
1935
|
+
mount: { id: mount.id, name: mount.name },
|
|
1936
|
+
kind: 'file',
|
|
1937
|
+
relativePath: r.relativePath,
|
|
1938
|
+
size: r.fileSizeBytes,
|
|
1939
|
+
fileType: r.fileType,
|
|
1940
|
+
lastModified: r.lastModified,
|
|
1941
|
+
updatedAt: r.updatedAt,
|
|
1942
|
+
});
|
|
1943
|
+
if (out.length >= limit) break;
|
|
1944
|
+
}
|
|
1945
|
+
if (out.length >= limit) break;
|
|
1946
|
+
}
|
|
1947
|
+
} else {
|
|
1948
|
+
// folder search (database-backed mounts only — doc_mount_folders is
|
|
1949
|
+
// populated by the database scan path; filesystem mounts derive
|
|
1950
|
+
// hierarchy from the OS at request time).
|
|
1951
|
+
const stmt = db.prepare(`
|
|
1952
|
+
SELECT path, name, updatedAt
|
|
1953
|
+
FROM doc_mount_folders
|
|
1954
|
+
WHERE mountPointId = ?
|
|
1955
|
+
AND path LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
1956
|
+
ORDER BY path COLLATE NOCASE
|
|
1957
|
+
`);
|
|
1958
|
+
for (const mount of mounts) {
|
|
1959
|
+
const rows = stmt.all(mount.id, liked);
|
|
1960
|
+
for (const r of rows) {
|
|
1961
|
+
out.push({
|
|
1962
|
+
mount: { id: mount.id, name: mount.name },
|
|
1963
|
+
kind: 'folder',
|
|
1964
|
+
relativePath: r.path,
|
|
1965
|
+
name: r.name,
|
|
1966
|
+
updatedAt: r.updatedAt,
|
|
1967
|
+
});
|
|
1968
|
+
if (out.length >= limit) break;
|
|
1969
|
+
}
|
|
1970
|
+
if (out.length >= limit) break;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (flags.json) {
|
|
1975
|
+
process.stdout.write(JSON.stringify({ pattern, type, results: out }, null, 2) + '\n');
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (out.length === 0) {
|
|
1979
|
+
console.log('(no matches)');
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
// Plain text columns.
|
|
1983
|
+
const headers = showMountColumn
|
|
1984
|
+
? ['mount', 'path', 'size', 'modified']
|
|
1985
|
+
: ['path', 'size', 'modified'];
|
|
1986
|
+
const rows = out.map(r => {
|
|
1987
|
+
const size = r.size != null ? formatBytes(r.size) : '-';
|
|
1988
|
+
const mod = r.lastModified || r.updatedAt || '';
|
|
1989
|
+
return showMountColumn
|
|
1990
|
+
? [r.mount.name, r.relativePath, size, mod]
|
|
1991
|
+
: [r.relativePath, size, mod];
|
|
1992
|
+
});
|
|
1993
|
+
printColumnar(headers, rows);
|
|
1994
|
+
if (out.length >= limit) {
|
|
1995
|
+
console.log(`${DIM}(limit ${limit} reached — use --limit to widen)${RESET}`);
|
|
1996
|
+
}
|
|
1997
|
+
} finally {
|
|
1998
|
+
db.close();
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function printColumnar(headers, rows) {
|
|
2003
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => String(r[i] || '').length)));
|
|
2004
|
+
const fmt = (cols) => cols.map((c, i) => String(c || '').padEnd(widths[i])).join(' ');
|
|
2005
|
+
console.log(`${BOLD}${fmt(headers)}${RESET}`);
|
|
2006
|
+
for (const r of rows) console.log(fmt(r));
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// ----------------------------------------------------------------------------
|
|
2010
|
+
// grep — substring search inside extracted text
|
|
2011
|
+
// ----------------------------------------------------------------------------
|
|
2012
|
+
|
|
2013
|
+
async function handleSemanticGrep(flags, query) {
|
|
2014
|
+
let mountPointIds;
|
|
2015
|
+
if (flags.mount && flags.mount !== 'all') {
|
|
2016
|
+
const { db } = await openDb(flags);
|
|
2017
|
+
try {
|
|
2018
|
+
const mount = requireMount(db, flags.mount);
|
|
2019
|
+
mountPointIds = [mount.id];
|
|
2020
|
+
} finally {
|
|
2021
|
+
db.close();
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const top = flags.top > 0 ? flags.top : 20;
|
|
2026
|
+
const threshold = flags.threshold >= 0 ? flags.threshold : 0.5;
|
|
2027
|
+
const port = flags.port || 3000;
|
|
2028
|
+
const url = `http://localhost:${port}/api/v1/mount-points?action=semantic-search`;
|
|
2029
|
+
|
|
2030
|
+
let res;
|
|
2031
|
+
try {
|
|
2032
|
+
res = await fetch(url, {
|
|
2033
|
+
method: 'POST',
|
|
2034
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2035
|
+
body: JSON.stringify({ query, mountPointIds, top, threshold }),
|
|
2036
|
+
});
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
console.error(`Could not reach Quilltap server at http://localhost:${port}: ${err.message}`);
|
|
2039
|
+
console.error('Semantic search requires the running server (the embedding provider lives there).');
|
|
2040
|
+
console.error('Start it with: npm run dev');
|
|
2041
|
+
process.exit(1);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
let payload = null;
|
|
2045
|
+
try {
|
|
2046
|
+
const text = await res.text();
|
|
2047
|
+
payload = text ? JSON.parse(text) : null;
|
|
2048
|
+
} catch { /* leave payload null */ }
|
|
2049
|
+
|
|
2050
|
+
if (!res.ok) {
|
|
2051
|
+
if (payload && payload.code === 'EMBEDDING_DIMENSION_MISMATCH') {
|
|
2052
|
+
console.error(`Error: ${payload.error}`);
|
|
2053
|
+
console.error(` Query embedding: ${payload.queryDimensions}-d`);
|
|
2054
|
+
console.error(` Stored chunks: ${payload.storedDimensions}-d`);
|
|
2055
|
+
console.error('Re-apply the embedding profile via "quilltap docs reindex" + "quilltap docs embed",');
|
|
2056
|
+
console.error('or switch the active embedding profile back to one that matches the corpus.');
|
|
2057
|
+
process.exit(1);
|
|
2058
|
+
}
|
|
2059
|
+
if (payload && payload.code === 'EMBEDDING_FAILED') {
|
|
2060
|
+
console.error(`Error: ${payload.error}`);
|
|
2061
|
+
console.error('Configure an embedding provider in /settings (Embeddings tab) and try again.');
|
|
2062
|
+
process.exit(1);
|
|
2063
|
+
}
|
|
2064
|
+
const msg = (payload && payload.error) || `HTTP ${res.status}`;
|
|
2065
|
+
console.error(`Error: ${msg}`);
|
|
2066
|
+
process.exit(1);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
if (flags.json) {
|
|
2070
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
const results = (payload && payload.results) || [];
|
|
2075
|
+
if (results.length === 0) {
|
|
2076
|
+
console.log('(no matches)');
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
for (const r of results) {
|
|
2081
|
+
const score = r.score.toFixed(3);
|
|
2082
|
+
const heading = r.headingContext ? ` [${r.headingContext}]` : '';
|
|
2083
|
+
console.log(`${score} ${r.mountPointName}:${r.relativePath}${heading}`);
|
|
2084
|
+
const snippet = (r.content || '').replace(/\s+/g, ' ').trim();
|
|
2085
|
+
const trimmed = snippet.length > 240 ? snippet.slice(0, 240) + '…' : snippet;
|
|
2086
|
+
console.log(` ${trimmed}`);
|
|
2087
|
+
}
|
|
2088
|
+
console.log('');
|
|
2089
|
+
console.log(`${results.length} match${results.length === 1 ? '' : 'es'}` +
|
|
2090
|
+
(payload.embeddingModel ? ` (model: ${payload.embeddingModel}, ${payload.embeddingDimensions}-d)` : ''));
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
async function handleGrep(flags, positional) {
|
|
2094
|
+
const pattern = positional[0];
|
|
2095
|
+
if (!pattern) {
|
|
2096
|
+
console.error('Usage: quilltap docs grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] [--json] <pattern>');
|
|
2097
|
+
console.error(' quilltap docs grep --semantic [--mount <name|id|all>] [--top N] [--threshold 0..1] [--json] <query>');
|
|
2098
|
+
process.exit(1);
|
|
2099
|
+
}
|
|
2100
|
+
if (flags.semantic) {
|
|
2101
|
+
return handleSemanticGrep(flags, pattern);
|
|
2102
|
+
}
|
|
2103
|
+
const max = flags.max > 0 ? flags.max : 5;
|
|
2104
|
+
const contextLines = flags.context > 0 ? flags.context : 0;
|
|
2105
|
+
|
|
2106
|
+
const { db } = await openDb(flags);
|
|
2107
|
+
try {
|
|
2108
|
+
const mounts = resolveSearchMounts(db, flags.mount);
|
|
2109
|
+
const showMountColumn = !flags.mount || flags.mount === 'all';
|
|
2110
|
+
|
|
2111
|
+
const linksByMount = db.prepare(`
|
|
2112
|
+
SELECT l.id AS linkId, l.fileId, l.relativePath, l.extractedText,
|
|
2113
|
+
f.source, f.fileType
|
|
2114
|
+
FROM doc_mount_file_links l
|
|
2115
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
2116
|
+
WHERE l.mountPointId = ?
|
|
2117
|
+
ORDER BY l.relativePath COLLATE NOCASE
|
|
2118
|
+
`);
|
|
2119
|
+
const docStmt = db.prepare(`SELECT content FROM doc_mount_documents WHERE fileId = ?`);
|
|
2120
|
+
const chunkStmt = db.prepare(`SELECT content FROM doc_mount_chunks WHERE linkId = ? ORDER BY chunkIndex`);
|
|
2121
|
+
|
|
2122
|
+
const needle = flags.ignoreCase ? pattern.toLowerCase() : pattern;
|
|
2123
|
+
|
|
2124
|
+
const results = [];
|
|
2125
|
+
for (const mount of mounts) {
|
|
2126
|
+
const links = linksByMount.all(mount.id);
|
|
2127
|
+
for (const link of links) {
|
|
2128
|
+
const text = resolveTextForLink(db, mount, link, docStmt, chunkStmt);
|
|
2129
|
+
if (text == null || text.length === 0) continue;
|
|
2130
|
+
const haystack = flags.ignoreCase ? text.toLowerCase() : text;
|
|
2131
|
+
if (haystack.indexOf(needle) === -1) continue;
|
|
2132
|
+
|
|
2133
|
+
const matches = [];
|
|
2134
|
+
if (!flags.pathsOnly) {
|
|
2135
|
+
const lines = text.split(/\r?\n/);
|
|
2136
|
+
for (let lineNum = 0; lineNum < lines.length && matches.length < max; lineNum++) {
|
|
2137
|
+
const hay = flags.ignoreCase ? lines[lineNum].toLowerCase() : lines[lineNum];
|
|
2138
|
+
if (hay.indexOf(needle) === -1) continue;
|
|
2139
|
+
let snippet = lines[lineNum];
|
|
2140
|
+
if (contextLines > 0) {
|
|
2141
|
+
const start = Math.max(0, lineNum - contextLines);
|
|
2142
|
+
const end = Math.min(lines.length, lineNum + contextLines + 1);
|
|
2143
|
+
snippet = lines.slice(start, end).join('\n');
|
|
2144
|
+
}
|
|
2145
|
+
matches.push({ line: lineNum + 1, snippet });
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
results.push({
|
|
2149
|
+
mount: { id: mount.id, name: mount.name },
|
|
2150
|
+
relativePath: link.relativePath,
|
|
2151
|
+
matches,
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
if (flags.json) {
|
|
2157
|
+
process.stdout.write(JSON.stringify({ pattern, results }, null, 2) + '\n');
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (results.length === 0) {
|
|
2161
|
+
console.log('(no matches)');
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
for (const r of results) {
|
|
2166
|
+
const prefix = showMountColumn ? `${r.mount.name}:` : '';
|
|
2167
|
+
if (flags.pathsOnly) {
|
|
2168
|
+
console.log(`${prefix}${r.relativePath}`);
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
for (const m of r.matches) {
|
|
2172
|
+
const oneLine = m.snippet.replace(/\n/g, ' ⏎ ');
|
|
2173
|
+
console.log(`${prefix}${r.relativePath}:${m.line}: ${oneLine}`);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
} finally {
|
|
2177
|
+
db.close();
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Resolve text for a single link row using the same decision tree as
|
|
2182
|
+
// readRendered — so grep sees everything the user can `docs read --rendered`.
|
|
2183
|
+
function resolveTextForLink(db, mount, link, docStmt, chunkStmt) {
|
|
2184
|
+
if (link.extractedText) return link.extractedText;
|
|
2185
|
+
if (link.source === 'database' && TEXT_FILE_TYPES.has(link.fileType)) {
|
|
2186
|
+
const doc = docStmt.get(link.fileId);
|
|
2187
|
+
if (doc) return doc.content;
|
|
2188
|
+
}
|
|
2189
|
+
if (link.source === 'filesystem' && TEXT_FILE_TYPES.has(link.fileType)) {
|
|
2190
|
+
const fullPath = path.join(mount.basePath, link.relativePath);
|
|
2191
|
+
try {
|
|
2192
|
+
if (fs.existsSync(fullPath)) {
|
|
2193
|
+
return fs.readFileSync(fullPath, 'utf8');
|
|
2194
|
+
}
|
|
2195
|
+
} catch { /* unreadable file — skip */ }
|
|
2196
|
+
}
|
|
2197
|
+
const chunks = chunkStmt.all(link.linkId);
|
|
2198
|
+
if (chunks.length > 0) return chunks.map(c => c.content).join('\n\n');
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// ----------------------------------------------------------------------------
|
|
2203
|
+
// reindex / embed — server-required, talks to /api/v1/mount-points/[id]
|
|
2204
|
+
// ----------------------------------------------------------------------------
|
|
2205
|
+
|
|
2206
|
+
async function callMountAction(port, mountId, action, body) {
|
|
2207
|
+
const url = `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}?action=${action}`;
|
|
2208
|
+
let res;
|
|
2209
|
+
try {
|
|
2210
|
+
res = await fetch(url, {
|
|
2211
|
+
method: 'POST',
|
|
2212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2213
|
+
body: JSON.stringify(body || {}),
|
|
2214
|
+
});
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
return { reachable: false, error: err.message };
|
|
2217
|
+
}
|
|
2218
|
+
let text = '';
|
|
2219
|
+
let parsed = null;
|
|
2220
|
+
try {
|
|
2221
|
+
text = await res.text();
|
|
2222
|
+
parsed = text ? JSON.parse(text) : null;
|
|
2223
|
+
} catch { /* leave parsed null */ }
|
|
2224
|
+
if (!res.ok) {
|
|
2225
|
+
const msg = parsed && parsed.error ? parsed.error : `HTTP ${res.status}`;
|
|
2226
|
+
return { reachable: true, ok: false, status: res.status, error: msg };
|
|
2227
|
+
}
|
|
2228
|
+
return { reachable: true, ok: true, result: parsed && parsed.data ? parsed.data : parsed };
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
async function pollJob(port, jobId, opts = {}) {
|
|
2232
|
+
const intervalMs = opts.intervalMs || 1500;
|
|
2233
|
+
const timeoutMs = opts.timeoutMs || 0;
|
|
2234
|
+
const start = Date.now();
|
|
2235
|
+
while (true) {
|
|
2236
|
+
let res;
|
|
2237
|
+
try {
|
|
2238
|
+
res = await fetch(`http://localhost:${port}/api/v1/system/jobs/${encodeURIComponent(jobId)}`);
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
return { ok: false, error: `Unreachable while polling: ${err.message}` };
|
|
2241
|
+
}
|
|
2242
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status} polling job` };
|
|
2243
|
+
let body;
|
|
2244
|
+
try { body = await res.json(); } catch { body = null; }
|
|
2245
|
+
const job = body && (body.job || (body.data && body.data.job));
|
|
2246
|
+
if (!job) return { ok: false, error: 'Malformed job response' };
|
|
2247
|
+
if (job.status === 'COMPLETED' || job.status === 'FAILED' || job.status === 'CANCELLED') {
|
|
2248
|
+
return { ok: true, terminal: true, job };
|
|
2249
|
+
}
|
|
2250
|
+
if (timeoutMs && Date.now() - start > timeoutMs) {
|
|
2251
|
+
return { ok: false, error: 'Polling timed out' };
|
|
2252
|
+
}
|
|
2253
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
async function handleReindex(flags, positional) {
|
|
2258
|
+
const mountSpec = positional[0];
|
|
2259
|
+
const pathArg = positional[1];
|
|
2260
|
+
if (!mountSpec) {
|
|
2261
|
+
console.error('Usage: quilltap docs reindex <mount> [path] [--force] [--json]');
|
|
2262
|
+
process.exit(1);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
const { db } = await openDb(flags);
|
|
2266
|
+
let mount;
|
|
2267
|
+
try {
|
|
2268
|
+
mount = requireMount(db, mountSpec);
|
|
2269
|
+
} finally {
|
|
2270
|
+
db.close();
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
const http = await callMountAction(flags.port, mount.id, 'reindex', {
|
|
2274
|
+
path: pathArg,
|
|
2275
|
+
force: !!flags.force,
|
|
2276
|
+
});
|
|
2277
|
+
if (!http.reachable) {
|
|
2278
|
+
console.error(`Cannot reach Quilltap server at localhost:${flags.port}. The reindex action requires the running server (it owns the background job queue).`);
|
|
2279
|
+
console.error(` ${http.error || ''}`);
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
}
|
|
2282
|
+
if (!http.ok) {
|
|
2283
|
+
console.error(`Reindex failed: ${http.error}`);
|
|
2284
|
+
process.exit(1);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
const r = http.result;
|
|
2288
|
+
if (flags.json) {
|
|
2289
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
console.log(`${BOLD}Reindex${RESET} ${mount.name}${pathArg ? `:${pathArg}` : ''}`);
|
|
2293
|
+
console.log(` processed: ${r.processed}`);
|
|
2294
|
+
console.log(` succeeded: ${r.succeeded}`);
|
|
2295
|
+
console.log(` failed: ${r.failed}`);
|
|
2296
|
+
console.log(` skipped: ${r.skipped}`);
|
|
2297
|
+
if (r.errors && r.errors.length > 0) {
|
|
2298
|
+
console.log(` errors:`);
|
|
2299
|
+
for (const e of r.errors.slice(0, 10)) {
|
|
2300
|
+
console.log(` ${e.relativePath}: ${e.error}`);
|
|
2301
|
+
}
|
|
2302
|
+
if (r.errors.length > 10) {
|
|
2303
|
+
console.log(` … ${r.errors.length - 10} more`);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
async function handleEmbed(flags, positional) {
|
|
2309
|
+
const mountSpec = positional[0];
|
|
2310
|
+
const pathArg = positional[1];
|
|
2311
|
+
if (!mountSpec) {
|
|
2312
|
+
console.error('Usage: quilltap docs embed <mount> [path] [--force] [--wait] [--json]');
|
|
2313
|
+
process.exit(1);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
const { db } = await openDb(flags);
|
|
2317
|
+
let mount;
|
|
2318
|
+
try {
|
|
2319
|
+
mount = requireMount(db, mountSpec);
|
|
2320
|
+
} finally {
|
|
2321
|
+
db.close();
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
const http = await callMountAction(flags.port, mount.id, 'embed', {
|
|
2325
|
+
path: pathArg,
|
|
2326
|
+
force: !!flags.force,
|
|
2327
|
+
});
|
|
2328
|
+
if (!http.reachable) {
|
|
2329
|
+
console.error(`Cannot reach Quilltap server at localhost:${flags.port}. The embed action requires the running server (it owns the background job queue).`);
|
|
2330
|
+
console.error(` ${http.error || ''}`);
|
|
2331
|
+
process.exit(1);
|
|
2332
|
+
}
|
|
2333
|
+
if (!http.ok) {
|
|
2334
|
+
console.error(`Embed failed: ${http.error}`);
|
|
2335
|
+
process.exit(1);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
const r = http.result;
|
|
2339
|
+
if (!flags.wait) {
|
|
2340
|
+
if (flags.json) {
|
|
2341
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
console.log(`${BOLD}Embed${RESET} ${mount.name}${pathArg ? `:${pathArg}` : ''}`);
|
|
2345
|
+
console.log(` queued: ${r.queued}`);
|
|
2346
|
+
console.log(` skipped: ${r.skipped}`);
|
|
2347
|
+
if (r.jobs && r.jobs.length > 0 && r.jobs.length <= 5) {
|
|
2348
|
+
console.log(` jobs:`);
|
|
2349
|
+
for (const j of r.jobs) console.log(` ${j.id} (${j.status})`);
|
|
2350
|
+
} else if (r.jobs && r.jobs.length > 5) {
|
|
2351
|
+
console.log(` jobs: ${r.jobs.length} queued (use --json for ids)`);
|
|
2352
|
+
}
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// --wait: poll each job to terminal status.
|
|
2357
|
+
const jobs = r.jobs || [];
|
|
2358
|
+
if (jobs.length === 0) {
|
|
2359
|
+
if (flags.json) {
|
|
2360
|
+
process.stdout.write(JSON.stringify({ ...r, waited: true, completed: 0, failed: 0 }, null, 2) + '\n');
|
|
2361
|
+
} else {
|
|
2362
|
+
console.log('No jobs queued.');
|
|
2363
|
+
}
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
console.log(`Waiting on ${jobs.length} job${jobs.length === 1 ? '' : 's'}…`);
|
|
2367
|
+
let done = 0;
|
|
2368
|
+
let failed = 0;
|
|
2369
|
+
for (const j of jobs) {
|
|
2370
|
+
const poll = await pollJob(flags.port, j.id);
|
|
2371
|
+
if (!poll.ok) {
|
|
2372
|
+
console.error(`Job ${j.id}: ${poll.error}`);
|
|
2373
|
+
failed++;
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
if (poll.job.status === 'COMPLETED') done++;
|
|
2377
|
+
else failed++;
|
|
2378
|
+
}
|
|
2379
|
+
if (flags.json) {
|
|
2380
|
+
process.stdout.write(JSON.stringify({ ...r, waited: true, completed: done, failed }, null, 2) + '\n');
|
|
2381
|
+
} else {
|
|
2382
|
+
console.log(`Embed done: ${done} completed, ${failed} failed.`);
|
|
2383
|
+
}
|
|
2384
|
+
if (failed > 0) process.exit(1);
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// ----------------------------------------------------------------------------
|
|
2388
|
+
// status
|
|
2389
|
+
// ----------------------------------------------------------------------------
|
|
2390
|
+
|
|
2391
|
+
async function handleStatus(flags) {
|
|
2392
|
+
const { db } = await openDb(flags);
|
|
2393
|
+
try {
|
|
2394
|
+
// Resolve target mounts: --mount narrows; default is all.
|
|
2395
|
+
let mounts;
|
|
2396
|
+
if (flags.mount) {
|
|
2397
|
+
mounts = [requireMount(db, flags.mount)];
|
|
2398
|
+
} else {
|
|
2399
|
+
mounts = db.prepare(
|
|
2400
|
+
`SELECT * FROM doc_mount_points ORDER BY name COLLATE NOCASE`
|
|
2401
|
+
).all();
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
const topN = flags.top >= 0 ? flags.top : 5;
|
|
2405
|
+
|
|
2406
|
+
// Per-mount aggregates.
|
|
2407
|
+
const extractionGroupByStatus = db.prepare(
|
|
2408
|
+
`SELECT extractionStatus, COUNT(*) AS n FROM doc_mount_file_links WHERE mountPointId = ? GROUP BY extractionStatus`
|
|
2409
|
+
);
|
|
2410
|
+
const textNativeCount = db.prepare(
|
|
2411
|
+
`SELECT COUNT(*) AS n
|
|
2412
|
+
FROM doc_mount_file_links l
|
|
2413
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
2414
|
+
WHERE l.mountPointId = ? AND f.fileType IN ('markdown', 'txt', 'json', 'jsonl')`
|
|
2415
|
+
);
|
|
2416
|
+
const nonTextLinkCount = db.prepare(
|
|
2417
|
+
`SELECT COUNT(*) AS n
|
|
2418
|
+
FROM doc_mount_file_links l
|
|
2419
|
+
JOIN doc_mount_files f ON f.id = l.fileId
|
|
2420
|
+
WHERE l.mountPointId = ? AND f.fileType NOT IN ('markdown', 'txt', 'json', 'jsonl')`
|
|
2421
|
+
);
|
|
2422
|
+
const chunkAgg = db.prepare(
|
|
2423
|
+
`SELECT COUNT(*) AS total,
|
|
2424
|
+
SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) AS embedded
|
|
2425
|
+
FROM doc_mount_chunks WHERE mountPointId = ?`
|
|
2426
|
+
);
|
|
2427
|
+
const oldestExtraction = db.prepare(
|
|
2428
|
+
`SELECT l.relativePath, l.updatedAt, l.extractionStatus, l.extractionError
|
|
2429
|
+
FROM doc_mount_file_links l
|
|
2430
|
+
WHERE l.mountPointId = ? AND l.extractionStatus = ?
|
|
2431
|
+
ORDER BY l.updatedAt ASC
|
|
2432
|
+
LIMIT ?`
|
|
2433
|
+
);
|
|
2434
|
+
|
|
2435
|
+
const report = [];
|
|
2436
|
+
for (const mount of mounts) {
|
|
2437
|
+
const statusRows = extractionGroupByStatus.all(mount.id);
|
|
2438
|
+
const byStatus = {};
|
|
2439
|
+
for (const r of statusRows) byStatus[r.extractionStatus] = r.n;
|
|
2440
|
+
const textNative = (textNativeCount.get(mount.id) || { n: 0 }).n;
|
|
2441
|
+
const nonText = (nonTextLinkCount.get(mount.id) || { n: 0 }).n;
|
|
2442
|
+
// converted/pending/failed counts apply to non-text-native files only.
|
|
2443
|
+
const extracted = byStatus['converted'] || 0;
|
|
2444
|
+
const extractionPending = byStatus['pending'] || 0;
|
|
2445
|
+
const extractionFailed = byStatus['failed'] || 0;
|
|
2446
|
+
const extractionSkipped = byStatus['skipped'] || 0;
|
|
2447
|
+
const extractionNone = byStatus['none'] || 0;
|
|
2448
|
+
|
|
2449
|
+
const chunkRow = chunkAgg.get(mount.id) || { total: 0, embedded: 0 };
|
|
2450
|
+
const totalChunks = chunkRow.total || 0;
|
|
2451
|
+
const embeddedChunks = chunkRow.embedded || 0;
|
|
2452
|
+
const embeddingPending = Math.max(0, totalChunks - embeddedChunks);
|
|
2453
|
+
|
|
2454
|
+
const oldestPending = topN > 0 ? oldestExtraction.all(mount.id, 'pending', topN) : [];
|
|
2455
|
+
const oldestFailed = topN > 0 ? oldestExtraction.all(mount.id, 'failed', topN) : [];
|
|
2456
|
+
|
|
2457
|
+
report.push({
|
|
2458
|
+
mount: { id: mount.id, name: mount.name, mountType: mount.mountType, basePath: mount.basePath },
|
|
2459
|
+
files: {
|
|
2460
|
+
'text-native': textNative,
|
|
2461
|
+
extracted,
|
|
2462
|
+
'extraction-pending': extractionPending,
|
|
2463
|
+
'extraction-failed': extractionFailed,
|
|
2464
|
+
'extraction-skipped': extractionSkipped,
|
|
2465
|
+
'extraction-none': extractionNone,
|
|
2466
|
+
'non-text-total': nonText,
|
|
2467
|
+
},
|
|
2468
|
+
chunks: {
|
|
2469
|
+
total: totalChunks,
|
|
2470
|
+
embedded: embeddedChunks,
|
|
2471
|
+
'embedding-pending': embeddingPending,
|
|
2472
|
+
},
|
|
2473
|
+
oldestPendingExtractions: oldestPending,
|
|
2474
|
+
oldestFailedExtractions: oldestFailed,
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (flags.json) {
|
|
2479
|
+
process.stdout.write(JSON.stringify({ mounts: report }, null, 2) + '\n');
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// Human text output.
|
|
2484
|
+
for (let i = 0; i < report.length; i++) {
|
|
2485
|
+
if (i > 0) console.log('');
|
|
2486
|
+
const r = report[i];
|
|
2487
|
+
console.log(`${BOLD}Mount: ${r.mount.name}${RESET} ${DIM}(${r.mount.id})${RESET}`);
|
|
2488
|
+
console.log(' Files');
|
|
2489
|
+
console.log(` text-native: ${String(r.files['text-native']).padStart(7)}`);
|
|
2490
|
+
console.log(` extracted: ${String(r.files.extracted).padStart(7)}`);
|
|
2491
|
+
console.log(` extraction-pending: ${String(r.files['extraction-pending']).padStart(7)}`);
|
|
2492
|
+
console.log(` extraction-failed: ${String(r.files['extraction-failed']).padStart(7)}`);
|
|
2493
|
+
if (r.files['extraction-skipped']) {
|
|
2494
|
+
console.log(` extraction-skipped: ${String(r.files['extraction-skipped']).padStart(7)}`);
|
|
2495
|
+
}
|
|
2496
|
+
console.log(' Chunks');
|
|
2497
|
+
console.log(` total: ${String(r.chunks.total).padStart(7)}`);
|
|
2498
|
+
console.log(` embedded: ${String(r.chunks.embedded).padStart(7)}`);
|
|
2499
|
+
console.log(` embedding-pending: ${String(r.chunks['embedding-pending']).padStart(7)}`);
|
|
2500
|
+
if (r.oldestPendingExtractions.length > 0) {
|
|
2501
|
+
console.log(' Oldest pending extractions:');
|
|
2502
|
+
for (const row of r.oldestPendingExtractions) {
|
|
2503
|
+
console.log(` ${row.relativePath} ${DIM}queued ${row.updatedAt}${RESET}`);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
if (r.oldestFailedExtractions.length > 0) {
|
|
2507
|
+
console.log(' Oldest failed extractions:');
|
|
2508
|
+
for (const row of r.oldestFailedExtractions) {
|
|
2509
|
+
const reason = row.extractionError ? ` ("${truncateSnippet(row.extractionError, 60)}")` : '';
|
|
2510
|
+
console.log(` ${row.relativePath} ${DIM}failed ${row.updatedAt}${RESET}${reason}`);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
} finally {
|
|
2515
|
+
db.close();
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
function truncateSnippet(s, n) {
|
|
2520
|
+
if (s == null) return '';
|
|
2521
|
+
const str = String(s).replace(/\s+/g, ' ');
|
|
2522
|
+
if (str.length <= n) return str;
|
|
2523
|
+
return str.slice(0, n) + '…';
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
async function docsCommand(args) {
|
|
2527
|
+
if (args.length === 0) {
|
|
2528
|
+
printDocsHelp();
|
|
2529
|
+
process.exit(1);
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
const { flags, positional } = parseFlags(args);
|
|
2533
|
+
|
|
2534
|
+
if (flags.help) {
|
|
2535
|
+
printDocsHelp();
|
|
2536
|
+
process.exit(0);
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
if (positional.length === 0) {
|
|
2540
|
+
printDocsHelp();
|
|
2541
|
+
process.exit(1);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
const verb = positional.shift();
|
|
2545
|
+
|
|
2546
|
+
try {
|
|
2547
|
+
switch (verb) {
|
|
2548
|
+
case 'list':
|
|
2549
|
+
await handleList(flags);
|
|
2550
|
+
break;
|
|
2551
|
+
case 'show':
|
|
2552
|
+
await handleShow(flags, positional[0]);
|
|
2553
|
+
break;
|
|
2554
|
+
case 'files':
|
|
2555
|
+
await handleFiles(flags, positional[0]);
|
|
2556
|
+
break;
|
|
2557
|
+
case 'ls':
|
|
2558
|
+
case 'dir':
|
|
2559
|
+
await handleLs(flags, positional[0], positional[1]);
|
|
2560
|
+
break;
|
|
2561
|
+
case 'tree':
|
|
2562
|
+
await handleTree(flags, positional[0], positional[1]);
|
|
2563
|
+
break;
|
|
2564
|
+
case 'read':
|
|
2565
|
+
await handleRead(flags, positional[0], positional[1]);
|
|
2566
|
+
break;
|
|
612
2567
|
case 'export':
|
|
613
2568
|
await handleExport(flags, positional[0], positional[1]);
|
|
614
2569
|
break;
|
|
615
2570
|
case 'scan':
|
|
616
2571
|
await handleScan(flags, positional[0]);
|
|
617
2572
|
break;
|
|
2573
|
+
case 'write':
|
|
2574
|
+
await handleWrite(flags, positional);
|
|
2575
|
+
break;
|
|
2576
|
+
case 'delete':
|
|
2577
|
+
await handleDelete(flags, positional);
|
|
2578
|
+
break;
|
|
2579
|
+
case 'mkdir':
|
|
2580
|
+
await handleMkdir(flags, positional);
|
|
2581
|
+
break;
|
|
2582
|
+
case 'move':
|
|
2583
|
+
await handleFileOp(flags, positional, 'move');
|
|
2584
|
+
break;
|
|
2585
|
+
case 'copy':
|
|
2586
|
+
await handleFileOp(flags, positional, 'copy');
|
|
2587
|
+
break;
|
|
2588
|
+
case 'status':
|
|
2589
|
+
await handleStatus(flags);
|
|
2590
|
+
break;
|
|
2591
|
+
case 'find':
|
|
2592
|
+
await handleFind(flags, positional);
|
|
2593
|
+
break;
|
|
2594
|
+
case 'grep':
|
|
2595
|
+
await handleGrep(flags, positional);
|
|
2596
|
+
break;
|
|
2597
|
+
case 'reindex':
|
|
2598
|
+
await handleReindex(flags, positional);
|
|
2599
|
+
break;
|
|
2600
|
+
case 'embed':
|
|
2601
|
+
await handleEmbed(flags, positional);
|
|
2602
|
+
break;
|
|
618
2603
|
default:
|
|
619
2604
|
console.error(`Unknown docs subcommand: ${verb}`);
|
|
620
2605
|
printDocsHelp();
|