quilltap 4.7.0-dev → 4.7.0-dev.101
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 +38 -4
- package/bin/quilltap.js +54 -4
- package/lib/__tests__/qtap-uri.test.js +111 -0
- package/lib/completion/bash.template +1 -1
- package/lib/completion/fish.template +2 -1
- package/lib/completion/zsh.template +2 -1
- package/lib/docs-commands.js +423 -19
- package/lib/lock-helpers.js +242 -0
- package/lib/maintenance-commands.js +394 -0
- package/lib/qtap-uri.js +171 -0
- package/lib/theme-validation.js +37 -0
- package/package.json +1 -1
package/lib/docs-commands.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
loadDbKey,
|
|
10
10
|
openMountIndexDb,
|
|
11
11
|
} = require('./db-helpers');
|
|
12
|
+
const { isQtapUri, parseQtapUri, formatDocStoreUri } = require('./qtap-uri');
|
|
12
13
|
|
|
13
14
|
const RESET = '\x1b[0m';
|
|
14
15
|
const BOLD = '\x1b[1m';
|
|
@@ -77,21 +78,35 @@ Server-required subcommands (background-job queue lives in the running server):
|
|
|
77
78
|
Enqueue embedding jobs for un-embedded chunks
|
|
78
79
|
|
|
79
80
|
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>
|
|
82
|
-
mkdir <mount> <path>
|
|
83
|
-
move <srcMount> <srcPath> <dstMount> <dstPath>
|
|
84
|
-
copy [--force] <srcMount> <srcPath> <dstMount> <dstPath>
|
|
81
|
+
write [--force] [--base64] <mount> <path> [file] Write a file from <file> or stdin
|
|
82
|
+
delete <mount> <path> Idempotent file delete
|
|
83
|
+
mkdir <mount> <path> Idempotent folder create
|
|
84
|
+
move <srcMount> <srcPath> <dstMount> <dstPath> Move file (hard-link when possible)
|
|
85
|
+
copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (hard-link unless --force)
|
|
86
|
+
link <srcMount> <srcPath> <dstMount> <dstPath> Hard-link file (server-required)
|
|
87
|
+
rmdir <mount> <path> Delete an empty folder (server-required)
|
|
88
|
+
mvdir <mount> <fromPath> <toPath> Rename/move a folder (server-required)
|
|
85
89
|
|
|
86
90
|
<mount> may be a mount name or UUID. Names are case-insensitive; ambiguous
|
|
87
91
|
names print candidates and exit non-zero.
|
|
88
92
|
|
|
93
|
+
Addressing with qtap:// URIs: anywhere a <mount> <relativePath> pair is taken,
|
|
94
|
+
you may instead pass a single qtap:// URI in its place — e.g.
|
|
95
|
+
quilltap docs read qtap://notes/today.md
|
|
96
|
+
quilltap docs move qtap://drafts/foo.md qtap://notes/2026/foo.md
|
|
97
|
+
The CLI addresses document stores only: qtap://self/… needs a character context
|
|
98
|
+
and qtap://project/… and qtap://general/… are not CLI-addressable; pass a store
|
|
99
|
+
name or UUID instead. --mount on find/grep also accepts a qtap://store/ URI.
|
|
100
|
+
|
|
89
101
|
Options:
|
|
90
102
|
-d, --data-dir <path> Override data directory
|
|
91
103
|
-i, --instance <name> Use a registered instance (see 'quilltap instances')
|
|
92
104
|
--passphrase <pass> Decrypt .dbkey if peppered
|
|
93
105
|
--port <number> Server port for API calls (default: 3000)
|
|
94
|
-
--json Machine-readable output
|
|
106
|
+
--json Machine-readable output (find/grep/ls/files/tree rows
|
|
107
|
+
carry a 'uri' field)
|
|
108
|
+
--uri For find/grep/files: show the canonical qtap:// URI
|
|
109
|
+
as the locator instead of the mount/path columns
|
|
95
110
|
--rendered For 'read': output extracted plaintext
|
|
96
111
|
--folder <path> For 'files': narrow to a folder prefix
|
|
97
112
|
--recursive, -R For 'ls': list all files recursively, grouped by folder
|
|
@@ -107,6 +122,11 @@ Options:
|
|
|
107
122
|
For 'write': overwrite existing destination
|
|
108
123
|
For 'copy': overwrite + force a real byte copy
|
|
109
124
|
(skips the default hard-link path)
|
|
125
|
+
--base64 For 'write': send content as base64 JSON via PUT
|
|
126
|
+
.../files/{path} (portable path used
|
|
127
|
+
by the file browser; server-required)
|
|
128
|
+
For 'read': fetch raw bytes via GET .../files/{path}
|
|
129
|
+
?encoding=base64 and emit to stdout
|
|
110
130
|
-h, --help Show this help
|
|
111
131
|
|
|
112
132
|
Read-only operations (list, show, files, read, export) open the mount-index
|
|
@@ -129,10 +149,17 @@ Examples:
|
|
|
129
149
|
quilltap docs move drafts foo.md notes 2026/foo.md
|
|
130
150
|
quilltap docs copy notes today.md archive 2026-05/today.md
|
|
131
151
|
quilltap docs copy --force notes today.md archive copy.md
|
|
152
|
+
quilltap docs link notes today.md archive 2026-05/today.md
|
|
153
|
+
quilltap docs rmdir notes 2026/may
|
|
154
|
+
quilltap docs mvdir notes drafts/old drafts/archive
|
|
155
|
+
quilltap docs write --base64 notes image.png < image.png
|
|
156
|
+
quilltap docs read --base64 notes image.png > image.png
|
|
132
157
|
quilltap docs find Manifesto
|
|
133
158
|
quilltap docs find --mount notes --ext md Knowledge
|
|
134
159
|
quilltap docs grep --mount notes --ignore-case "five-point Calvinist"
|
|
135
160
|
quilltap docs grep --mount notes -l "TODO"
|
|
161
|
+
quilltap docs read qtap://notes/today.md
|
|
162
|
+
quilltap docs find --uri Manifesto
|
|
136
163
|
quilltap docs status
|
|
137
164
|
quilltap docs status --mount notes --top 10
|
|
138
165
|
quilltap docs reindex notes Knowledge --force
|
|
@@ -174,6 +201,8 @@ function parseFlags(args) {
|
|
|
174
201
|
// semantic search
|
|
175
202
|
semantic: false,
|
|
176
203
|
threshold: -1,
|
|
204
|
+
// base64 read/write flag
|
|
205
|
+
base64: false,
|
|
177
206
|
};
|
|
178
207
|
const positional = [];
|
|
179
208
|
let i = 0;
|
|
@@ -193,6 +222,7 @@ function parseFlags(args) {
|
|
|
193
222
|
break;
|
|
194
223
|
}
|
|
195
224
|
case '--json': flags.json = true; break;
|
|
225
|
+
case '--uri': flags.uri = true; break;
|
|
196
226
|
case '--rendered': flags.rendered = true; break;
|
|
197
227
|
case '--folder': flags.folder = args[++i]; break;
|
|
198
228
|
case '--force': flags.force = true; break;
|
|
@@ -219,6 +249,7 @@ function parseFlags(args) {
|
|
|
219
249
|
case '--long': flags.long = true; break;
|
|
220
250
|
case '--names-only': flags.namesOnly = true; break;
|
|
221
251
|
case '--semantic': flags.semantic = true; break;
|
|
252
|
+
case '--base64': flags.base64 = true; break;
|
|
222
253
|
case '--threshold': {
|
|
223
254
|
const v = parseFloat(args[++i]);
|
|
224
255
|
if (isNaN(v) || v < 0 || v > 1) {
|
|
@@ -299,6 +330,44 @@ function assertDocsSchema(db, dataDir) {
|
|
|
299
330
|
|
|
300
331
|
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
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Expand a single `qtap://…` argument into the CLI's `[mountSpec, relPath]`
|
|
335
|
+
* pair. The CLI addresses document stores only, so non-`document_store` scopes
|
|
336
|
+
* and the character-context-only `self` authority are rejected with guidance
|
|
337
|
+
* (throws; the docs dispatcher prints `Error: <message>`).
|
|
338
|
+
*/
|
|
339
|
+
function expandQtapArg(arg) {
|
|
340
|
+
const p = parseQtapUri(arg);
|
|
341
|
+
if (p.scope !== 'document_store') {
|
|
342
|
+
throw new Error(
|
|
343
|
+
'The CLI addresses document stores only; project/general scopes ' +
|
|
344
|
+
'(qtap://project/…, qtap://general/…) are not CLI-addressable. Pass a store name or UUID.'
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (p.mountPoint && p.mountPoint.toLowerCase() === 'self') {
|
|
348
|
+
throw new Error(
|
|
349
|
+
'"self" requires a character context and is not resolvable from the CLI; pass a store name or UUID.'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return [p.mountPoint, p.path];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Walk a subcommand's positional args, expanding any `qtap://…` argument into
|
|
357
|
+
* the `<mount> <relativePath>` pair the existing handlers consume. A bare
|
|
358
|
+
* `<mount> <path>` pair is left untouched, so both forms work. Two-target
|
|
359
|
+
* commands (move/copy/link) accept two `qtap://` args (→ four positionals) or
|
|
360
|
+
* the legacy four positionals.
|
|
361
|
+
*/
|
|
362
|
+
function expandQtapPositionals(positional) {
|
|
363
|
+
const out = [];
|
|
364
|
+
for (const arg of positional) {
|
|
365
|
+
if (isQtapUri(arg)) out.push(...expandQtapArg(arg));
|
|
366
|
+
else out.push(arg);
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
302
371
|
function requireMount(db, spec) {
|
|
303
372
|
if (!spec) {
|
|
304
373
|
console.error('Error: mount name or id is required');
|
|
@@ -332,6 +401,33 @@ function requireMount(db, spec) {
|
|
|
332
401
|
return rows[0];
|
|
333
402
|
}
|
|
334
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Lower-cased names that map to more than one enabled mount — i.e. names that
|
|
406
|
+
* are ambiguous and must use the UUID form in a `qtap://` URI. Mirrors the
|
|
407
|
+
* server's `countByName` ambiguity check.
|
|
408
|
+
*/
|
|
409
|
+
function loadAmbiguousMountNames(db) {
|
|
410
|
+
const rows = db.prepare('SELECT name FROM doc_mount_points WHERE enabled = 1').all();
|
|
411
|
+
const counts = new Map();
|
|
412
|
+
for (const r of rows) {
|
|
413
|
+
const key = String(r.name || '').trim().toLowerCase();
|
|
414
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
415
|
+
}
|
|
416
|
+
const ambiguous = new Set();
|
|
417
|
+
for (const [key, count] of counts) if (count > 1) ambiguous.add(key);
|
|
418
|
+
return ambiguous;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Canonical document-store `qtap://` URI for a CLI result row: the readable
|
|
423
|
+
* store name unless it is ambiguous (then the UUID), matching the resolver's
|
|
424
|
+
* name-first / id-as-fallback rule.
|
|
425
|
+
*/
|
|
426
|
+
function rowQtapUri(mountName, mountId, relativePath, ambiguousNames) {
|
|
427
|
+
const useId = !mountName || ambiguousNames.has(String(mountName).trim().toLowerCase());
|
|
428
|
+
return formatDocStoreUri(useId ? mountId : mountName, relativePath || '');
|
|
429
|
+
}
|
|
430
|
+
|
|
335
431
|
function formatBytes(n) {
|
|
336
432
|
if (n < 1024) return `${n} B`;
|
|
337
433
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
@@ -477,16 +573,21 @@ async function handleFiles(flags, id) {
|
|
|
477
573
|
ORDER BY l.relativePath
|
|
478
574
|
`).all(mount.id);
|
|
479
575
|
}
|
|
576
|
+
const ambiguousNames = loadAmbiguousMountNames(db);
|
|
577
|
+
const rowsWithUri = rows.map(r => ({
|
|
578
|
+
...r,
|
|
579
|
+
uri: rowQtapUri(mount.name, mount.id, r.relativePath, ambiguousNames),
|
|
580
|
+
}));
|
|
480
581
|
if (flags.json) {
|
|
481
|
-
process.stdout.write(JSON.stringify(
|
|
582
|
+
process.stdout.write(JSON.stringify(rowsWithUri, null, 2) + '\n');
|
|
482
583
|
return;
|
|
483
584
|
}
|
|
484
585
|
if (rows.length === 0) {
|
|
485
586
|
console.log('(no files)');
|
|
486
587
|
return;
|
|
487
588
|
}
|
|
488
|
-
const display =
|
|
489
|
-
relativePath: r.relativePath,
|
|
589
|
+
const display = rowsWithUri.map(r => ({
|
|
590
|
+
...(flags.uri ? { uri: r.uri } : { relativePath: r.relativePath }),
|
|
490
591
|
type: r.fileType,
|
|
491
592
|
source: r.source,
|
|
492
593
|
size: formatBytes(r.fileSizeBytes || 0),
|
|
@@ -726,6 +827,7 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
726
827
|
|
|
727
828
|
// JSON output
|
|
728
829
|
if (flags.json) {
|
|
830
|
+
const ambiguousNames = loadAmbiguousMountNames(db);
|
|
729
831
|
const out = [];
|
|
730
832
|
if (!flags.recursive) {
|
|
731
833
|
for (const folder of folders) {
|
|
@@ -733,6 +835,7 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
733
835
|
type: 'folder',
|
|
734
836
|
name: folder.name,
|
|
735
837
|
path: folder.path,
|
|
838
|
+
uri: rowQtapUri(mount.name, mount.id, folder.path, ambiguousNames),
|
|
736
839
|
createdAt: folder.createdAt,
|
|
737
840
|
updatedAt: folder.updatedAt,
|
|
738
841
|
});
|
|
@@ -751,6 +854,7 @@ async function handleLs(flags, mountSpec, rawPath) {
|
|
|
751
854
|
type: 'file',
|
|
752
855
|
name: file.fileName,
|
|
753
856
|
relativePath: file.relativePath,
|
|
857
|
+
uri: rowQtapUri(mount.name, mount.id, file.relativePath, ambiguousNames),
|
|
754
858
|
fileType: file.fileType,
|
|
755
859
|
source: file.source,
|
|
756
860
|
fileSizeBytes: file.fileSizeBytes,
|
|
@@ -989,16 +1093,19 @@ function renderTreeAscii(node, prefix, isLast, isRoot, maxNodes, nodeCount) {
|
|
|
989
1093
|
}
|
|
990
1094
|
}
|
|
991
1095
|
|
|
992
|
-
function treeToJson(node) {
|
|
1096
|
+
function treeToJson(node, ctx) {
|
|
993
1097
|
return {
|
|
994
1098
|
name: node.name,
|
|
995
1099
|
type: node.type,
|
|
996
1100
|
path: node.path || undefined,
|
|
997
1101
|
relativePath: node.relativePath || undefined,
|
|
1102
|
+
uri: ctx
|
|
1103
|
+
? rowQtapUri(ctx.mountName, ctx.mountId, node.relativePath || node.path || '', ctx.ambiguousNames)
|
|
1104
|
+
: undefined,
|
|
998
1105
|
size: node.size !== undefined ? node.size : undefined,
|
|
999
1106
|
chunkCount: node.chunkCount !== undefined ? node.chunkCount : undefined,
|
|
1000
1107
|
children: node.children && node.children.length > 0
|
|
1001
|
-
? node.children.map(treeToJson)
|
|
1108
|
+
? node.children.map((child) => treeToJson(child, ctx))
|
|
1002
1109
|
: undefined,
|
|
1003
1110
|
};
|
|
1004
1111
|
}
|
|
@@ -1031,7 +1138,8 @@ async function handleTree(flags, mountSpec, rawPath) {
|
|
|
1031
1138
|
const tree = buildFolderTree(db, mount.id, startPath, maxDepth);
|
|
1032
1139
|
|
|
1033
1140
|
if (flags.json) {
|
|
1034
|
-
const
|
|
1141
|
+
const ambiguousNames = loadAmbiguousMountNames(db);
|
|
1142
|
+
const output = treeToJson(tree, { mountName: mount.name, mountId: mount.id, ambiguousNames });
|
|
1035
1143
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
1036
1144
|
return;
|
|
1037
1145
|
}
|
|
@@ -1078,10 +1186,50 @@ function ttyGuard(fileType, flags, label) {
|
|
|
1078
1186
|
|
|
1079
1187
|
async function handleRead(flags, id, relativePath) {
|
|
1080
1188
|
if (!id || !relativePath) {
|
|
1081
|
-
console.error('Usage: quilltap docs read [--rendered] <mount-id> <relativePath>');
|
|
1189
|
+
console.error('Usage: quilltap docs read [--rendered] [--base64] <mount-id> <relativePath>');
|
|
1082
1190
|
process.exit(1);
|
|
1083
1191
|
}
|
|
1084
1192
|
|
|
1193
|
+
// --base64: fetch raw bytes via the item route (server-required).
|
|
1194
|
+
if (flags.base64) {
|
|
1195
|
+
const { db } = await openDb(flags);
|
|
1196
|
+
let mount;
|
|
1197
|
+
try {
|
|
1198
|
+
mount = requireMount(db, id);
|
|
1199
|
+
} finally {
|
|
1200
|
+
db.close();
|
|
1201
|
+
}
|
|
1202
|
+
const url = fileItemUrl(flags.port, mount.id, relativePath) + '?encoding=base64';
|
|
1203
|
+
let res;
|
|
1204
|
+
try {
|
|
1205
|
+
res = await fetch(url);
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
if (isConnectionRefused(err)) {
|
|
1208
|
+
console.error(`Cannot reach Quilltap server at localhost:${flags.port}. --base64 read requires the running server.`);
|
|
1209
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
throw err;
|
|
1213
|
+
}
|
|
1214
|
+
if (!res.ok) {
|
|
1215
|
+
let body = null;
|
|
1216
|
+
try { body = await res.json(); } catch { /* ignore */ }
|
|
1217
|
+
const msg = body && body.error ? body.error : `HTTP ${res.status}`;
|
|
1218
|
+
console.error(`Read failed: ${msg}`);
|
|
1219
|
+
process.exit(1);
|
|
1220
|
+
}
|
|
1221
|
+
let body = null;
|
|
1222
|
+
try { body = await res.json(); } catch { /* ignore */ }
|
|
1223
|
+
// A zero-byte file legitimately returns content: "" (falsy), so check the
|
|
1224
|
+
// type, not truthiness — otherwise empty files fail to round-trip.
|
|
1225
|
+
if (!body || typeof body.content !== 'string') {
|
|
1226
|
+
console.error('Read failed: server returned no content');
|
|
1227
|
+
process.exit(1);
|
|
1228
|
+
}
|
|
1229
|
+
process.stdout.write(Buffer.from(body.content, 'base64'));
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1085
1233
|
const { db } = await openDb(flags);
|
|
1086
1234
|
try {
|
|
1087
1235
|
const mount = requireMount(db, id);
|
|
@@ -1469,6 +1617,35 @@ async function writeViaHttp(port, mountId, relativePath, data, force) {
|
|
|
1469
1617
|
return { reachable: true, ok: true, result: unwrap(body) };
|
|
1470
1618
|
}
|
|
1471
1619
|
|
|
1620
|
+
// fileItemUrl — builds the item-route URL for the new PUT/GET .../files/{path}
|
|
1621
|
+
// Each path segment is independently URL-encoded to preserve the slash
|
|
1622
|
+
// structure of the route.
|
|
1623
|
+
function fileItemUrl(port, mountId, relativePath) {
|
|
1624
|
+
const encodedSegments = relativePath
|
|
1625
|
+
.split('/')
|
|
1626
|
+
.map(seg => encodeURIComponent(seg))
|
|
1627
|
+
.join('/');
|
|
1628
|
+
return `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}/files/${encodedSegments}`;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function writeViaHttpBase64(port, mountId, relativePath, data, force) {
|
|
1632
|
+
const content = data.toString('base64');
|
|
1633
|
+
const url = fileItemUrl(port, mountId, relativePath);
|
|
1634
|
+
const attempt = await tryFetch(url, {
|
|
1635
|
+
method: 'PUT',
|
|
1636
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1637
|
+
body: JSON.stringify({ content, encoding: 'base64', force: !!force }),
|
|
1638
|
+
});
|
|
1639
|
+
if (!attempt.ok) return { reachable: false };
|
|
1640
|
+
const body = await readBodyJson(attempt.res);
|
|
1641
|
+
if (!attempt.res.ok) {
|
|
1642
|
+
const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
|
|
1643
|
+
const code = body && body.code ? body.code : null;
|
|
1644
|
+
return { reachable: true, ok: false, status: attempt.res.status, error: msg, code };
|
|
1645
|
+
}
|
|
1646
|
+
return { reachable: true, ok: true, result: unwrap(body) };
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1472
1649
|
async function handleWrite(flags, positional) {
|
|
1473
1650
|
const force = flags.force;
|
|
1474
1651
|
const [mountSpec, relativePath, filename] = positional;
|
|
@@ -1488,6 +1665,31 @@ async function handleWrite(flags, positional) {
|
|
|
1488
1665
|
db.close();
|
|
1489
1666
|
}
|
|
1490
1667
|
|
|
1668
|
+
// --base64: use the new item-route PUT (server-required; no offline fallback).
|
|
1669
|
+
if (flags.base64) {
|
|
1670
|
+
const http = await writeViaHttpBase64(flags.port, mount.id, relativePath, data, force);
|
|
1671
|
+
if (!http.reachable) {
|
|
1672
|
+
console.error(`Cannot reach Quilltap server at localhost:${flags.port}. --base64 write requires the running server.`);
|
|
1673
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
1674
|
+
process.exit(1);
|
|
1675
|
+
}
|
|
1676
|
+
if (!http.ok) {
|
|
1677
|
+
console.error(`Write failed: ${http.error}`);
|
|
1678
|
+
process.exit(http.code === 'DEST_EXISTS' ? 2 : 1);
|
|
1679
|
+
}
|
|
1680
|
+
const r = http.result;
|
|
1681
|
+
if (r.sha256 !== sourceSha) {
|
|
1682
|
+
console.error(`Checksum mismatch: source ${sourceSha} != dest ${r.sha256}`);
|
|
1683
|
+
process.exit(1);
|
|
1684
|
+
}
|
|
1685
|
+
if (flags.json) {
|
|
1686
|
+
process.stdout.write(JSON.stringify({ ...r, sourceSha256: sourceSha }, null, 2) + '\n');
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
console.log(`${GREEN}Wrote${RESET} ${mount.name}:${r.relativePath ?? relativePath} (${formatBytes(r.sizeBytes)}, sha=${r.sha256.slice(0, 12)}…)`);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1491
1693
|
const http = await writeViaHttp(flags.port, mount.id, relativePath, data, force);
|
|
1492
1694
|
if (http.reachable) {
|
|
1493
1695
|
if (!http.ok) {
|
|
@@ -1868,6 +2070,169 @@ async function handleFileOp(flags, positional, action) {
|
|
|
1868
2070
|
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
2071
|
}
|
|
1870
2072
|
|
|
2073
|
+
// ----------------------------------------------------------------------------
|
|
2074
|
+
// link — hard-link via server (server-required; no offline fallback)
|
|
2075
|
+
// ----------------------------------------------------------------------------
|
|
2076
|
+
|
|
2077
|
+
async function handleLink(flags, positional) {
|
|
2078
|
+
const [srcMountSpec, srcPath, dstMountSpec, dstPath] = positional;
|
|
2079
|
+
if (!srcMountSpec || !srcPath || !dstMountSpec || !dstPath) {
|
|
2080
|
+
console.error('Usage: quilltap docs link <srcMount> <srcPath> <dstMount> <dstPath>');
|
|
2081
|
+
process.exit(1);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
const { db } = await openDb(flags);
|
|
2085
|
+
let sourceMount, destMount;
|
|
2086
|
+
try {
|
|
2087
|
+
sourceMount = requireMount(db, srcMountSpec);
|
|
2088
|
+
destMount = requireMount(db, dstMountSpec);
|
|
2089
|
+
} finally {
|
|
2090
|
+
db.close();
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const http = await fileOpViaHttp(flags.port, 'link-file', sourceMount.id, {
|
|
2094
|
+
sourcePath: srcPath,
|
|
2095
|
+
destMountPointId: destMount.id,
|
|
2096
|
+
destPath: dstPath,
|
|
2097
|
+
});
|
|
2098
|
+
if (!http.reachable) {
|
|
2099
|
+
console.error(`Cannot reach Quilltap server at localhost:${flags.port}. 'link' requires the running server (hard-link logic lives there).`);
|
|
2100
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
2101
|
+
process.exit(1);
|
|
2102
|
+
}
|
|
2103
|
+
if (!http.ok) {
|
|
2104
|
+
const r = http;
|
|
2105
|
+
if (r.code === 'DEST_EXISTS') {
|
|
2106
|
+
console.error(`Link failed: destination already exists at ${dstPath}`);
|
|
2107
|
+
process.exit(2);
|
|
2108
|
+
}
|
|
2109
|
+
if (r.code === 'UNSUPPORTED') {
|
|
2110
|
+
console.error(`Link failed: hard links are not supported across storage types or devices for this mount pair`);
|
|
2111
|
+
process.exit(1);
|
|
2112
|
+
}
|
|
2113
|
+
if (r.code === 'SOURCE_NOT_FOUND') {
|
|
2114
|
+
console.error(`Link failed: source not found at ${srcPath} in mount ${sourceMount.name}`);
|
|
2115
|
+
process.exit(1);
|
|
2116
|
+
}
|
|
2117
|
+
console.error(`Link failed: ${r.error}`);
|
|
2118
|
+
process.exit(1);
|
|
2119
|
+
}
|
|
2120
|
+
const r = http.result;
|
|
2121
|
+
if (flags.json) {
|
|
2122
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
const strategy = r.strategy ? ` (${r.strategy})` : '';
|
|
2126
|
+
const size = r.sizeBytes != null ? `, ${formatBytes(r.sizeBytes)}` : '';
|
|
2127
|
+
const sha = r.destSha256 ? `, sha=${r.destSha256.slice(0, 12)}…` : '';
|
|
2128
|
+
console.log(`${GREEN}Linked${RESET} ${sourceMount.name}:${srcPath} → ${destMount.name}:${dstPath}${DIM}${strategy}${size}${sha}${RESET}`);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// ----------------------------------------------------------------------------
|
|
2132
|
+
// rmdir — delete an empty folder (server-required)
|
|
2133
|
+
// ----------------------------------------------------------------------------
|
|
2134
|
+
|
|
2135
|
+
async function handleRmdir(flags, positional) {
|
|
2136
|
+
const [mountSpec, folderPath] = positional;
|
|
2137
|
+
if (!mountSpec || !folderPath) {
|
|
2138
|
+
console.error('Usage: quilltap docs rmdir <mount> <path>');
|
|
2139
|
+
process.exit(1);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
const { db } = await openDb(flags);
|
|
2143
|
+
let mount;
|
|
2144
|
+
try {
|
|
2145
|
+
mount = requireMount(db, mountSpec);
|
|
2146
|
+
} finally {
|
|
2147
|
+
db.close();
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
const attempt = await tryFetch(actionUrl(flags.port, mount.id, 'delete-folder'), {
|
|
2151
|
+
method: 'POST',
|
|
2152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2153
|
+
body: JSON.stringify({ path: folderPath }),
|
|
2154
|
+
});
|
|
2155
|
+
if (!attempt.ok) {
|
|
2156
|
+
console.error(`Cannot reach Quilltap server at localhost:${flags.port}. 'rmdir' requires the running server.`);
|
|
2157
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
2158
|
+
process.exit(1);
|
|
2159
|
+
}
|
|
2160
|
+
const body = await readBodyJson(attempt.res);
|
|
2161
|
+
if (!attempt.res.ok) {
|
|
2162
|
+
const code = body && body.code ? body.code : null;
|
|
2163
|
+
const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
|
|
2164
|
+
if (code === 'NOT_EMPTY' || code === 'CONFLICT') {
|
|
2165
|
+
console.error(`rmdir failed: folder is not empty — ${folderPath}`);
|
|
2166
|
+
console.error('Remove all files inside it first, or use delete to remove individual files.');
|
|
2167
|
+
process.exit(1);
|
|
2168
|
+
}
|
|
2169
|
+
if (code === 'NOT_FOUND' || attempt.res.status === 404) {
|
|
2170
|
+
console.error(`rmdir failed: folder not found — ${folderPath}`);
|
|
2171
|
+
process.exit(1);
|
|
2172
|
+
}
|
|
2173
|
+
console.error(`rmdir failed: ${msg}`);
|
|
2174
|
+
process.exit(1);
|
|
2175
|
+
}
|
|
2176
|
+
const r = unwrap(body);
|
|
2177
|
+
if (flags.json) {
|
|
2178
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
console.log(`${GREEN}Removed${RESET} ${mount.name}:${folderPath}`);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// ----------------------------------------------------------------------------
|
|
2185
|
+
// mvdir — rename/move a folder (server-required)
|
|
2186
|
+
// ----------------------------------------------------------------------------
|
|
2187
|
+
|
|
2188
|
+
async function handleMvdir(flags, positional) {
|
|
2189
|
+
const [mountSpec, fromPath, toPath] = positional;
|
|
2190
|
+
if (!mountSpec || !fromPath || !toPath) {
|
|
2191
|
+
console.error('Usage: quilltap docs mvdir <mount> <fromPath> <toPath>');
|
|
2192
|
+
process.exit(1);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
const { db } = await openDb(flags);
|
|
2196
|
+
let mount;
|
|
2197
|
+
try {
|
|
2198
|
+
mount = requireMount(db, mountSpec);
|
|
2199
|
+
} finally {
|
|
2200
|
+
db.close();
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
const attempt = await tryFetch(actionUrl(flags.port, mount.id, 'move-folder'), {
|
|
2204
|
+
method: 'POST',
|
|
2205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2206
|
+
body: JSON.stringify({ fromPath, toPath }),
|
|
2207
|
+
});
|
|
2208
|
+
if (!attempt.ok) {
|
|
2209
|
+
console.error(`Cannot reach Quilltap server at localhost:${flags.port}. 'mvdir' requires the running server.`);
|
|
2210
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
2211
|
+
process.exit(1);
|
|
2212
|
+
}
|
|
2213
|
+
const body = await readBodyJson(attempt.res);
|
|
2214
|
+
if (!attempt.res.ok) {
|
|
2215
|
+
const code = body && body.code ? body.code : null;
|
|
2216
|
+
const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
|
|
2217
|
+
if (code === 'DEST_EXISTS') {
|
|
2218
|
+
console.error(`mvdir failed: destination already exists — ${toPath}`);
|
|
2219
|
+
process.exit(2);
|
|
2220
|
+
}
|
|
2221
|
+
if (code === 'SOURCE_NOT_FOUND' || attempt.res.status === 404) {
|
|
2222
|
+
console.error(`mvdir failed: source folder not found — ${fromPath}`);
|
|
2223
|
+
process.exit(1);
|
|
2224
|
+
}
|
|
2225
|
+
console.error(`mvdir failed: ${msg}`);
|
|
2226
|
+
process.exit(1);
|
|
2227
|
+
}
|
|
2228
|
+
const r = unwrap(body);
|
|
2229
|
+
if (flags.json) {
|
|
2230
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
console.log(`${GREEN}Moved${RESET} ${mount.name}:${fromPath} → ${mount.name}:${toPath}`);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
1871
2236
|
// ----------------------------------------------------------------------------
|
|
1872
2237
|
// dispatch
|
|
1873
2238
|
// ----------------------------------------------------------------------------
|
|
@@ -1892,6 +2257,8 @@ function resolveSearchMounts(db, mountSpec) {
|
|
|
1892
2257
|
}
|
|
1893
2258
|
|
|
1894
2259
|
async function handleFind(flags, positional) {
|
|
2260
|
+
// --mount may be given as a qtap://store/ URI; reduce it to the store spec.
|
|
2261
|
+
if (flags.mount && isQtapUri(flags.mount)) flags.mount = expandQtapArg(flags.mount)[0];
|
|
1895
2262
|
const pattern = positional[0];
|
|
1896
2263
|
if (!pattern) {
|
|
1897
2264
|
console.error('Usage: quilltap docs find [--mount <name|id|all>] [--type file|folder] [--ext <ext>] [--limit N] [--json] <pattern>');
|
|
@@ -1908,6 +2275,7 @@ async function handleFind(flags, positional) {
|
|
|
1908
2275
|
try {
|
|
1909
2276
|
const mounts = resolveSearchMounts(db, flags.mount);
|
|
1910
2277
|
const showMountColumn = !flags.mount || flags.mount === 'all';
|
|
2278
|
+
const ambiguousNames = loadAmbiguousMountNames(db);
|
|
1911
2279
|
|
|
1912
2280
|
const liked = `%${escapeLike(pattern)}%`;
|
|
1913
2281
|
|
|
@@ -1935,6 +2303,7 @@ async function handleFind(flags, positional) {
|
|
|
1935
2303
|
mount: { id: mount.id, name: mount.name },
|
|
1936
2304
|
kind: 'file',
|
|
1937
2305
|
relativePath: r.relativePath,
|
|
2306
|
+
uri: rowQtapUri(mount.name, mount.id, r.relativePath, ambiguousNames),
|
|
1938
2307
|
size: r.fileSizeBytes,
|
|
1939
2308
|
fileType: r.fileType,
|
|
1940
2309
|
lastModified: r.lastModified,
|
|
@@ -1962,6 +2331,7 @@ async function handleFind(flags, positional) {
|
|
|
1962
2331
|
mount: { id: mount.id, name: mount.name },
|
|
1963
2332
|
kind: 'folder',
|
|
1964
2333
|
relativePath: r.path,
|
|
2334
|
+
uri: rowQtapUri(mount.name, mount.id, r.path, ambiguousNames),
|
|
1965
2335
|
name: r.name,
|
|
1966
2336
|
updatedAt: r.updatedAt,
|
|
1967
2337
|
});
|
|
@@ -1979,13 +2349,17 @@ async function handleFind(flags, positional) {
|
|
|
1979
2349
|
console.log('(no matches)');
|
|
1980
2350
|
return;
|
|
1981
2351
|
}
|
|
1982
|
-
// Plain text columns.
|
|
1983
|
-
|
|
2352
|
+
// Plain text columns. `--uri` swaps the mount/path columns for the single
|
|
2353
|
+
// canonical qtap:// URI.
|
|
2354
|
+
const headers = flags.uri
|
|
2355
|
+
? ['uri', 'size', 'modified']
|
|
2356
|
+
: showMountColumn
|
|
1984
2357
|
? ['mount', 'path', 'size', 'modified']
|
|
1985
2358
|
: ['path', 'size', 'modified'];
|
|
1986
2359
|
const rows = out.map(r => {
|
|
1987
2360
|
const size = r.size != null ? formatBytes(r.size) : '-';
|
|
1988
2361
|
const mod = r.lastModified || r.updatedAt || '';
|
|
2362
|
+
if (flags.uri) return [r.uri, size, mod];
|
|
1989
2363
|
return showMountColumn
|
|
1990
2364
|
? [r.mount.name, r.relativePath, size, mod]
|
|
1991
2365
|
: [r.relativePath, size, mod];
|
|
@@ -2091,6 +2465,8 @@ async function handleSemanticGrep(flags, query) {
|
|
|
2091
2465
|
}
|
|
2092
2466
|
|
|
2093
2467
|
async function handleGrep(flags, positional) {
|
|
2468
|
+
// --mount may be given as a qtap://store/ URI; reduce it to the store spec.
|
|
2469
|
+
if (flags.mount && isQtapUri(flags.mount)) flags.mount = expandQtapArg(flags.mount)[0];
|
|
2094
2470
|
const pattern = positional[0];
|
|
2095
2471
|
if (!pattern) {
|
|
2096
2472
|
console.error('Usage: quilltap docs grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] [--json] <pattern>');
|
|
@@ -2107,6 +2483,7 @@ async function handleGrep(flags, positional) {
|
|
|
2107
2483
|
try {
|
|
2108
2484
|
const mounts = resolveSearchMounts(db, flags.mount);
|
|
2109
2485
|
const showMountColumn = !flags.mount || flags.mount === 'all';
|
|
2486
|
+
const ambiguousNames = loadAmbiguousMountNames(db);
|
|
2110
2487
|
|
|
2111
2488
|
const linksByMount = db.prepare(`
|
|
2112
2489
|
SELECT l.id AS linkId, l.fileId, l.relativePath, l.extractedText,
|
|
@@ -2148,6 +2525,7 @@ async function handleGrep(flags, positional) {
|
|
|
2148
2525
|
results.push({
|
|
2149
2526
|
mount: { id: mount.id, name: mount.name },
|
|
2150
2527
|
relativePath: link.relativePath,
|
|
2528
|
+
uri: rowQtapUri(mount.name, mount.id, link.relativePath, ambiguousNames),
|
|
2151
2529
|
matches,
|
|
2152
2530
|
});
|
|
2153
2531
|
}
|
|
@@ -2163,14 +2541,16 @@ async function handleGrep(flags, positional) {
|
|
|
2163
2541
|
}
|
|
2164
2542
|
|
|
2165
2543
|
for (const r of results) {
|
|
2166
|
-
|
|
2544
|
+
// `--uri` uses the canonical qtap:// URI as the locator; otherwise the
|
|
2545
|
+
// familiar [mount:]path form.
|
|
2546
|
+
const locator = flags.uri ? r.uri : `${showMountColumn ? `${r.mount.name}:` : ''}${r.relativePath}`;
|
|
2167
2547
|
if (flags.pathsOnly) {
|
|
2168
|
-
console.log(
|
|
2548
|
+
console.log(locator);
|
|
2169
2549
|
continue;
|
|
2170
2550
|
}
|
|
2171
2551
|
for (const m of r.matches) {
|
|
2172
2552
|
const oneLine = m.snippet.replace(/\n/g, ' ⏎ ');
|
|
2173
|
-
console.log(`${
|
|
2553
|
+
console.log(`${locator}:${m.line}: ${oneLine}`);
|
|
2174
2554
|
}
|
|
2175
2555
|
}
|
|
2176
2556
|
} finally {
|
|
@@ -2529,7 +2909,9 @@ async function docsCommand(args) {
|
|
|
2529
2909
|
process.exit(1);
|
|
2530
2910
|
}
|
|
2531
2911
|
|
|
2532
|
-
const
|
|
2912
|
+
const parsed = parseFlags(args);
|
|
2913
|
+
const flags = parsed.flags;
|
|
2914
|
+
let positional = parsed.positional;
|
|
2533
2915
|
|
|
2534
2916
|
if (flags.help) {
|
|
2535
2917
|
printDocsHelp();
|
|
@@ -2543,7 +2925,20 @@ async function docsCommand(args) {
|
|
|
2543
2925
|
|
|
2544
2926
|
const verb = positional.shift();
|
|
2545
2927
|
|
|
2928
|
+
// Verbs that take positional `<mount> <relativePath>` accept a single
|
|
2929
|
+
// `qtap://…` URI in its place; expand it before dispatch so every handler
|
|
2930
|
+
// keeps consuming the familiar mount/path positionals. (find/grep address a
|
|
2931
|
+
// mount via `--mount`, handled inside those handlers; export's second
|
|
2932
|
+
// positional is a local output dir, so it is excluded.)
|
|
2933
|
+
const QTAP_POSITIONAL_VERBS = new Set([
|
|
2934
|
+
'read', 'write', 'delete', 'mkdir', 'ls', 'dir', 'tree', 'files',
|
|
2935
|
+
'move', 'copy', 'link', 'rmdir', 'mvdir', 'show', 'scan',
|
|
2936
|
+
]);
|
|
2937
|
+
|
|
2546
2938
|
try {
|
|
2939
|
+
if (QTAP_POSITIONAL_VERBS.has(verb)) {
|
|
2940
|
+
positional = expandQtapPositionals(positional);
|
|
2941
|
+
}
|
|
2547
2942
|
switch (verb) {
|
|
2548
2943
|
case 'list':
|
|
2549
2944
|
await handleList(flags);
|
|
@@ -2585,6 +2980,15 @@ async function docsCommand(args) {
|
|
|
2585
2980
|
case 'copy':
|
|
2586
2981
|
await handleFileOp(flags, positional, 'copy');
|
|
2587
2982
|
break;
|
|
2983
|
+
case 'link':
|
|
2984
|
+
await handleLink(flags, positional);
|
|
2985
|
+
break;
|
|
2986
|
+
case 'rmdir':
|
|
2987
|
+
await handleRmdir(flags, positional);
|
|
2988
|
+
break;
|
|
2989
|
+
case 'mvdir':
|
|
2990
|
+
await handleMvdir(flags, positional);
|
|
2991
|
+
break;
|
|
2588
2992
|
case 'status':
|
|
2589
2993
|
await handleStatus(flags);
|
|
2590
2994
|
break;
|