quilltap 4.10.0-dev.9 → 4.10.0-dev.92
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 +117 -7
- package/bin/quilltap.js +43 -25
- package/lib/__tests__/completion-coverage.test.js +1 -0
- package/lib/__tests__/sync-report.test.js +158 -0
- package/lib/completion/bash.template +23 -1
- package/lib/completion/fish.template +19 -2
- package/lib/completion/zsh.template +36 -0
- package/lib/db-commands.js +20 -0
- package/lib/db-helpers.js +12 -0
- package/lib/native-modules.js +102 -1
- package/lib/sync-command.js +323 -0
- package/lib/sync-report.js +167 -0
- package/lib/text-codec.js +61 -0
- package/package.json +1 -1
|
@@ -37,6 +37,7 @@ _quilltap() {
|
|
|
37
37
|
subcommands=(
|
|
38
38
|
'db:Query encrypted databases'
|
|
39
39
|
'docs:Inspect, read, and export document mounts'
|
|
40
|
+
'sync:Mirror a database-backed store to a directory'
|
|
40
41
|
'themes:Manage theme bundles'
|
|
41
42
|
'instances:Register or inspect named Quilltap instances'
|
|
42
43
|
'memories:Search, browse, and graph memories'
|
|
@@ -80,6 +81,9 @@ _quilltap_subcommand() {
|
|
|
80
81
|
docs)
|
|
81
82
|
_quilltap_docs
|
|
82
83
|
;;
|
|
84
|
+
sync)
|
|
85
|
+
_quilltap_sync
|
|
86
|
+
;;
|
|
83
87
|
themes)
|
|
84
88
|
_quilltap_themes
|
|
85
89
|
;;
|
|
@@ -314,6 +318,38 @@ _quilltap_docs() {
|
|
|
314
318
|
esac
|
|
315
319
|
}
|
|
316
320
|
|
|
321
|
+
# `quilltap sync <store> <path>` — a store, then a local directory.
|
|
322
|
+
_quilltap_sync() {
|
|
323
|
+
local -a sync_opts
|
|
324
|
+
sync_opts=(
|
|
325
|
+
'(-i --instance)'{-i,--instance}'[Registered instance name]:instance:_quilltap_instance_names'
|
|
326
|
+
'(-d --data-dir)'{-d,--data-dir}'[Data directory]:directory:_directories'
|
|
327
|
+
'--passphrase[Database passphrase]:passphrase:'
|
|
328
|
+
'(-p --port)'{-p,--port}'[Server port]:port:'
|
|
329
|
+
'--json[Machine-readable plan and results]'
|
|
330
|
+
'--dry-run[Plan and print; change nothing]'
|
|
331
|
+
'--direction[Which side may change]:direction:(both to-disk to-store)'
|
|
332
|
+
'--prefer[How to resolve a conflict]:prefer:(newer store disk)'
|
|
333
|
+
'--no-delete[Never propagate a deletion]'
|
|
334
|
+
'--no-manifest[Ignore .quilltap-sync.json]'
|
|
335
|
+
'(-h --help)'{-h,--help}'[Show help]'
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
_arguments -C $sync_opts \
|
|
339
|
+
'1: :->store' \
|
|
340
|
+
'2: :->target' \
|
|
341
|
+
'*: :'
|
|
342
|
+
|
|
343
|
+
case "$state" in
|
|
344
|
+
store)
|
|
345
|
+
_quilltap_mount_names
|
|
346
|
+
;;
|
|
347
|
+
target)
|
|
348
|
+
_directories
|
|
349
|
+
;;
|
|
350
|
+
esac
|
|
351
|
+
}
|
|
352
|
+
|
|
317
353
|
# Which docs positionals name a document store, and which name a local path.
|
|
318
354
|
# `move`/`copy`/`link` take <srcMount> <srcPath> <dstMount> <dstPath>, so a
|
|
319
355
|
# store is wanted at both 2 and 4; everything else that takes a store takes it
|
package/lib/db-commands.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
resolveProject,
|
|
17
17
|
} = require('./db-helpers');
|
|
18
18
|
const { getLockStatus } = require('./lock-helpers');
|
|
19
|
+
const { decodeText } = require('./text-codec');
|
|
19
20
|
|
|
20
21
|
// Tables grouped by domain for `db schema` (with-no-arg) overview, and for
|
|
21
22
|
// DB-routing when a verb names a specific table. Keep this list short — it's
|
|
@@ -477,6 +478,11 @@ function cmdMessages(args, ctx) {
|
|
|
477
478
|
params.push(last);
|
|
478
479
|
const rows = db.prepare(sql).all(...params).reverse(); // oldest first
|
|
479
480
|
|
|
481
|
+
// `content` may be a brotli BLOB (see lib/text-codec.js). Decode before
|
|
482
|
+
// anything prints or JSON-serializes it — a raw BLOB prints as binary and
|
|
483
|
+
// JSON.stringify turns it into a byte array.
|
|
484
|
+
for (const r of rows) r.content = decodeText(r.content);
|
|
485
|
+
|
|
480
486
|
if (json) return printJson({ chat, total: totalRow.n, returned: rows.length, messages: rows });
|
|
481
487
|
|
|
482
488
|
console.log(`Chat: ${chat.title} (${chat.id}) — showing ${rows.length} of ${totalRow.n} matching messages`);
|
|
@@ -557,6 +563,13 @@ function cmdMessage(args, ctx) {
|
|
|
557
563
|
try {
|
|
558
564
|
const row = db.prepare('SELECT * FROM chat_messages WHERE id = ?').get(id);
|
|
559
565
|
if (!row) throw new Error(`No chat_message with id ${id}`);
|
|
566
|
+
|
|
567
|
+
// The four large text columns may be brotli BLOBs (see lib/text-codec.js).
|
|
568
|
+
// Decode before anything prints or JSON-serializes them.
|
|
569
|
+
for (const col of ['content', 'opaqueContent', 'description', 'context']) {
|
|
570
|
+
if (col in row) row[col] = decodeText(row[col]);
|
|
571
|
+
}
|
|
572
|
+
|
|
560
573
|
if (json) return printJson(row);
|
|
561
574
|
|
|
562
575
|
printRecord(`Message ${row.id}`, {
|
|
@@ -596,6 +609,13 @@ function cmdLog(args, ctx) {
|
|
|
596
609
|
try {
|
|
597
610
|
const row = db.prepare('SELECT * FROM llm_logs WHERE id = ?').get(id);
|
|
598
611
|
if (!row) throw new Error(`No llm_log with id ${id}`);
|
|
612
|
+
|
|
613
|
+
// request/response may be brotli BLOBs (see lib/text-codec.js). Decode
|
|
614
|
+
// before anything reads, prints or JSON-serializes them — a raw BLOB
|
|
615
|
+
// would print as binary and JSON.stringify as a byte array.
|
|
616
|
+
row.request = decodeText(row.request);
|
|
617
|
+
row.response = decodeText(row.response);
|
|
618
|
+
|
|
599
619
|
if (json) return printJson(row);
|
|
600
620
|
|
|
601
621
|
let finishReason = null;
|
package/lib/db-helpers.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const { registerTextCodecFunction } = require('./text-codec');
|
|
5
6
|
|
|
6
7
|
const CTRL_C = String.fromCharCode(3);
|
|
7
8
|
const CTRL_D = String.fromCharCode(4);
|
|
@@ -201,6 +202,17 @@ function openEncryptedDb(dbPath, pepper, { readonly = true, friendlyName = 'data
|
|
|
201
202
|
'The database may be encrypted with a different key, or the .dbkey file may be missing.');
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// Compressed text columns (llm_logs.request/response, chat_messages.content
|
|
206
|
+
// and friends) are BLOBs; qt_text() lets raw SQL and the repl read inside
|
|
207
|
+
// them. It is also REQUIRED for any --write that touches chat_messages: the
|
|
208
|
+
// message search triggers call it, so a connection without it fails the
|
|
209
|
+
// write loudly rather than letting the index drift.
|
|
210
|
+
try {
|
|
211
|
+
registerTextCodecFunction(db);
|
|
212
|
+
} catch {
|
|
213
|
+
// An old better-sqlite3 without db.function must not block a read.
|
|
214
|
+
}
|
|
215
|
+
|
|
204
216
|
return db;
|
|
205
217
|
}
|
|
206
218
|
|
package/lib/native-modules.js
CHANGED
|
@@ -62,6 +62,84 @@ function betterSqlite3NeedsRebuild() {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Locate an executable installed by a dependency, given the package directory
|
|
66
|
+
// that needs it. Checks the package's own `.bin` first, then the `.bin` beside
|
|
67
|
+
// it (the hoisted case, which is the usual one). Returns null when absent.
|
|
68
|
+
function findBinFor(pkgDir, tool) {
|
|
69
|
+
const fs = require('fs');
|
|
70
|
+
const names = process.platform === 'win32' ? [`${tool}.cmd`, `${tool}.exe`, tool] : [tool];
|
|
71
|
+
const dirs = [
|
|
72
|
+
path.join(pkgDir, 'node_modules', '.bin'), // nested install
|
|
73
|
+
path.join(pkgDir, '..', '.bin'), // hoisted alongside the package
|
|
74
|
+
];
|
|
75
|
+
for (const dir of dirs) {
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const candidate = path.join(dir, name);
|
|
78
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Rebuild ONE native package in place, addressed by its directory.
|
|
85
|
+
//
|
|
86
|
+
// Deliberately does NOT go through `npm rebuild <name>`, which fails this job
|
|
87
|
+
// two different ways:
|
|
88
|
+
//
|
|
89
|
+
// 1. npm refuses install scripts for any package not listed in the root
|
|
90
|
+
// package.json `allowScripts` map (keyed `name@version`). The root
|
|
91
|
+
// SQLCipher copy is installed under the ALIAS `better-sqlite3`, which is
|
|
92
|
+
// not a key there, so the rebuild dies with EALLOWSCRIPTS.
|
|
93
|
+
// 2. Asking for the real name (`better-sqlite3-multiple-ciphers`) at the root
|
|
94
|
+
// instead resolves a phantom directory: npm reports "rebuilt dependencies
|
|
95
|
+
// successfully" and the binding on disk is untouched. A success message is
|
|
96
|
+
// worse than an error.
|
|
97
|
+
//
|
|
98
|
+
// Running the package's own build chain (`prebuild-install || node-gyp rebuild`,
|
|
99
|
+
// exactly what its `install` script does) in its own directory sidesteps both:
|
|
100
|
+
// no name resolution, no npm script policy. `prebuild-install` downloads the
|
|
101
|
+
// prebuilt binary for the running ABI and needs network; node-gyp compiles and
|
|
102
|
+
// needs a toolchain.
|
|
103
|
+
//
|
|
104
|
+
// Returns { ok, reason }. Never throws. Verifies the result rather than trusting
|
|
105
|
+
// the exit code — see (2): the whole failure mode here is a rebuild that claims
|
|
106
|
+
// to have worked.
|
|
107
|
+
function rebuildNativePackage(pkgDir, bindingPath) {
|
|
108
|
+
const fs = require('fs');
|
|
109
|
+
if (!fs.existsSync(pkgDir)) return { ok: false, reason: `no package at ${pkgDir}` };
|
|
110
|
+
|
|
111
|
+
const before = fs.existsSync(bindingPath) ? readCompiledAbi(bindingPath) : null;
|
|
112
|
+
const attempts = [];
|
|
113
|
+
|
|
114
|
+
const prebuild = findBinFor(pkgDir, 'prebuild-install');
|
|
115
|
+
if (prebuild) attempts.push({ label: 'prebuild-install', cmd: `"${prebuild}"` });
|
|
116
|
+
|
|
117
|
+
const nodeGyp = findBinFor(pkgDir, 'node-gyp');
|
|
118
|
+
if (nodeGyp) attempts.push({ label: 'node-gyp rebuild', cmd: `"${nodeGyp}" rebuild --release` });
|
|
119
|
+
|
|
120
|
+
if (attempts.length === 0) {
|
|
121
|
+
return { ok: false, reason: 'neither prebuild-install nor node-gyp is installed' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const failures = [];
|
|
125
|
+
for (const attempt of attempts) {
|
|
126
|
+
try {
|
|
127
|
+
execSync(attempt.cmd, { cwd: pkgDir, stdio: 'inherit' });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
failures.push(`${attempt.label}: ${err.message.split('\n')[0]}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Trust nothing but the binary. A tool can exit 0 having done nothing.
|
|
133
|
+
const after = fs.existsSync(bindingPath) ? readCompiledAbi(bindingPath) : null;
|
|
134
|
+
if (after === process.versions.modules) return { ok: true, reason: attempt.label };
|
|
135
|
+
failures.push(
|
|
136
|
+
`${attempt.label}: exited 0 but the binding is still ${after ?? 'missing'} ` +
|
|
137
|
+
`(wanted ${process.versions.modules}${before ? `, was ${before}` : ''})`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, reason: failures.join('; ') };
|
|
141
|
+
}
|
|
142
|
+
|
|
65
143
|
// Rebuild the named native modules against the current Node ABI. Prints a
|
|
66
144
|
// friendly notice rather than throwing; returns true on success, false on
|
|
67
145
|
// failure. Backfills node-pty's spawn-helper afterward.
|
|
@@ -96,7 +174,27 @@ function ensureDatabaseNativeModule() {
|
|
|
96
174
|
} catch {
|
|
97
175
|
return true; // detection hiccup — let the real load be the source of truth
|
|
98
176
|
}
|
|
99
|
-
|
|
177
|
+
|
|
178
|
+
const bindingPath = betterSqlite3BindingPath();
|
|
179
|
+
if (!bindingPath) {
|
|
180
|
+
console.error(' Warning: could not locate the SQLCipher binding to rebuild.');
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
// build/Release/better_sqlite3.node → the package directory above it.
|
|
184
|
+
const pkgDir = path.resolve(path.dirname(bindingPath), '..', '..');
|
|
185
|
+
|
|
186
|
+
console.log(` Rebuilding the SQLCipher binding for Node.js ${process.version}...`);
|
|
187
|
+
const result = rebuildNativePackage(pkgDir, bindingPath);
|
|
188
|
+
if (result.ok) {
|
|
189
|
+
console.log(` Done (${result.reason}).`);
|
|
190
|
+
console.log('');
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
console.error('');
|
|
194
|
+
console.error(` Warning: failed to rebuild the SQLCipher binding — ${result.reason}`);
|
|
195
|
+
console.error(` Try running: (cd ${pkgDir} && ./node_modules/.bin/prebuild-install)`);
|
|
196
|
+
console.error('');
|
|
197
|
+
return false;
|
|
100
198
|
}
|
|
101
199
|
|
|
102
200
|
// node-pty needs a `spawn-helper` executable beside the pty.node it loads, or
|
|
@@ -194,7 +292,10 @@ function ensureNativeModules() {
|
|
|
194
292
|
module.exports = {
|
|
195
293
|
resolveModuleDir,
|
|
196
294
|
readCompiledAbi,
|
|
295
|
+
betterSqlite3BindingPath,
|
|
197
296
|
betterSqlite3NeedsRebuild,
|
|
297
|
+
findBinFor,
|
|
298
|
+
rebuildNativePackage,
|
|
198
299
|
ensureDatabaseNativeModule,
|
|
199
300
|
ensureNativeModules,
|
|
200
301
|
reconcileNodePtySpawnHelper,
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `quilltap sync <store> <path>` — keep a database-backed document store and a
|
|
5
|
+
* directory on disk in step with each other.
|
|
6
|
+
*
|
|
7
|
+
* This file is a thin client. Every decision the sync makes — which side wins,
|
|
8
|
+
* what counts as a deletion, when to refuse — is made by the engine in the
|
|
9
|
+
* server (`lib/mount-index/sync/`), and every flag is validated by the route's
|
|
10
|
+
* own schema, which is the single source of truth. The CLI resolves the store
|
|
11
|
+
* name to a UUID (the one thing it opens the database for, read-only), posts
|
|
12
|
+
* the request, and prints what came back.
|
|
13
|
+
*
|
|
14
|
+
* The engine lives in the server rather than here because the sync writes
|
|
15
|
+
* through the store's own chokepoints — `linkDocumentContent`, the folder-row
|
|
16
|
+
* helper, the hard-link fan-out, the post-write re-chunk — and those are
|
|
17
|
+
* TypeScript in `lib/`, unreachable from this plain-JS package. A direct
|
|
18
|
+
* SQLite writer would be a second copy of all of them, and a lock-gated one
|
|
19
|
+
* could not re-chunk at all. `docs write` on a database store already requires
|
|
20
|
+
* the server for exactly this reason; `deconvert` already takes a server-local
|
|
21
|
+
* target path. This is that shape, not a new one.
|
|
22
|
+
*
|
|
23
|
+
* `<path>` is therefore resolved on the SERVER. Running under Docker, it must
|
|
24
|
+
* sit inside a bind mount — `quilltap docs docker-mounts` plans those.
|
|
25
|
+
*
|
|
26
|
+
* @module sync-command
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const {
|
|
32
|
+
resolveDataDirAndPassphrase,
|
|
33
|
+
printDefaultInstanceHint,
|
|
34
|
+
loadDbKey,
|
|
35
|
+
openMountIndexDb,
|
|
36
|
+
UUID_RE,
|
|
37
|
+
} = require('./db-helpers');
|
|
38
|
+
const { isQtapUri, parseQtapUri } = require('./qtap-uri');
|
|
39
|
+
const { formatActionLines, formatSummary, exitCodeFor } = require('./sync-report');
|
|
40
|
+
|
|
41
|
+
const RESET = '\x1b[0m';
|
|
42
|
+
const DIM = '\x1b[2m';
|
|
43
|
+
const YELLOW = '\x1b[33m';
|
|
44
|
+
|
|
45
|
+
function printSyncHelp() {
|
|
46
|
+
console.log(`
|
|
47
|
+
Quilltap Document Store Sync
|
|
48
|
+
|
|
49
|
+
Usage: quilltap sync <store|qtap://store/> <path> [options]
|
|
50
|
+
|
|
51
|
+
Mirrors a database-backed document store and a directory on disk in both
|
|
52
|
+
directions. The two sides are compared by SHA-256 first and by modification
|
|
53
|
+
time second: equal bytes with unequal clocks are re-stamped, not re-copied.
|
|
54
|
+
Whichever side changed is copied to the other; when both changed since the
|
|
55
|
+
last run it is reported as a conflict and left alone.
|
|
56
|
+
|
|
57
|
+
The directory is created if it is missing. A document store is never created.
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
--dry-run Plan and print; change nothing on either side
|
|
61
|
+
--direction <which> both (default), to-disk, or to-store
|
|
62
|
+
--prefer <which> newer (default), store, or disk — resolves conflicts
|
|
63
|
+
--no-delete Never propagate a deletion to the other side
|
|
64
|
+
--no-manifest Ignore .quilltap-sync.json (first-run rules every time)
|
|
65
|
+
--json Machine-readable plan and results on stdout
|
|
66
|
+
-p, --port <number> Server port (default: 3000)
|
|
67
|
+
-d, --data-dir <path> Override data directory
|
|
68
|
+
-i, --instance <name> Use a registered instance
|
|
69
|
+
--passphrase <pass> Decrypt .dbkey if peppered
|
|
70
|
+
-h, --help Show this help
|
|
71
|
+
|
|
72
|
+
Exit codes: 0 clean, 1 an error or a failed action, 2 an unresolved conflict.
|
|
73
|
+
--dry-run uses the same codes, so a script can gate on a clean plan.
|
|
74
|
+
|
|
75
|
+
Files and folders whose names begin with a dot are INVISIBLE to the sync in
|
|
76
|
+
both directions — never copied, never deleted, on either side. The one
|
|
77
|
+
exception is .quilltap-sync.json, the record the verb keeps of what the last
|
|
78
|
+
run left; it lives in the directory and never enters the store.
|
|
79
|
+
|
|
80
|
+
A binary's description travels beside it as <file>.description.md. Editing
|
|
81
|
+
that file changes the caption in the store; deleting it clears the caption.
|
|
82
|
+
A text document's description is not synced.
|
|
83
|
+
|
|
84
|
+
The server must be running: a database-backed store's writes go through it,
|
|
85
|
+
as they already do for 'quilltap docs write'. <path> is resolved on the
|
|
86
|
+
server — under Docker it must sit inside a bind mount (see
|
|
87
|
+
'quilltap docs docker-mounts').
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
quilltap sync Lore ~/Documents/lore --dry-run
|
|
91
|
+
quilltap sync Lore ~/Documents/lore
|
|
92
|
+
quilltap sync Lore ~/Documents/lore --prefer disk
|
|
93
|
+
quilltap sync qtap://Lore/ ~/Documents/lore --direction to-disk
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseFlags(args) {
|
|
98
|
+
const flags = {
|
|
99
|
+
dataDir: '',
|
|
100
|
+
instance: '',
|
|
101
|
+
passphrase: '',
|
|
102
|
+
port: 3000,
|
|
103
|
+
json: false,
|
|
104
|
+
dryRun: false,
|
|
105
|
+
direction: 'both',
|
|
106
|
+
prefer: 'newer',
|
|
107
|
+
noDelete: false,
|
|
108
|
+
noManifest: false,
|
|
109
|
+
help: false,
|
|
110
|
+
};
|
|
111
|
+
const positional = [];
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < args.length; i++) {
|
|
114
|
+
const arg = args[i];
|
|
115
|
+
switch (arg) {
|
|
116
|
+
case '-d': case '--data-dir': flags.dataDir = args[++i] || ''; break;
|
|
117
|
+
case '-i': case '--instance': flags.instance = args[++i] || ''; break;
|
|
118
|
+
case '--passphrase': flags.passphrase = args[++i] || ''; break;
|
|
119
|
+
case '-p': case '--port': flags.port = parseInt(args[++i], 10) || 3000; break;
|
|
120
|
+
case '--json': flags.json = true; break;
|
|
121
|
+
case '--dry-run': flags.dryRun = true; break;
|
|
122
|
+
case '--direction': flags.direction = args[++i] || ''; break;
|
|
123
|
+
case '--prefer': flags.prefer = args[++i] || ''; break;
|
|
124
|
+
case '--no-delete': flags.noDelete = true; break;
|
|
125
|
+
case '--no-manifest': flags.noManifest = true; break;
|
|
126
|
+
case '-h': case '--help': flags.help = true; break;
|
|
127
|
+
default:
|
|
128
|
+
if (arg.startsWith('-')) {
|
|
129
|
+
console.error(`Unknown option: ${arg}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
positional.push(arg);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { flags, positional };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `~/x` → an absolute path. The server sees only what we send it. */
|
|
139
|
+
function expandPath(input) {
|
|
140
|
+
let expanded = input;
|
|
141
|
+
if (expanded === '~') expanded = os.homedir();
|
|
142
|
+
else if (expanded.startsWith('~/')) expanded = path.join(os.homedir(), expanded.slice(2));
|
|
143
|
+
return path.resolve(expanded);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A store name or UUID, or a `qtap://store/` URI with an empty path. This is
|
|
148
|
+
* the only thing the verb opens the database for, and it opens it read-only.
|
|
149
|
+
*/
|
|
150
|
+
function resolveStoreSpec(spec) {
|
|
151
|
+
if (!isQtapUri(spec)) return spec;
|
|
152
|
+
const parsed = parseQtapUri(spec);
|
|
153
|
+
if (parsed.scope !== 'document_store') {
|
|
154
|
+
throw new Error(
|
|
155
|
+
'sync addresses document stores only; qtap://project/… and qtap://general/… are not CLI-addressable.'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (parsed.path) {
|
|
159
|
+
throw new Error(`sync takes a whole store, not a path inside one: ${spec}`);
|
|
160
|
+
}
|
|
161
|
+
if (!parsed.mountPoint || parsed.mountPoint.toLowerCase() === 'self') {
|
|
162
|
+
throw new Error('"self" requires a character context and is not resolvable from the CLI.');
|
|
163
|
+
}
|
|
164
|
+
return parsed.mountPoint;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Name-first, UUID fallback — the same resolution the `docs` verbs use. */
|
|
168
|
+
function requireMount(db, spec) {
|
|
169
|
+
if (UUID_RE.test(spec)) {
|
|
170
|
+
const row = db.prepare('SELECT * FROM doc_mount_points WHERE id = ?').get(spec);
|
|
171
|
+
if (!row) {
|
|
172
|
+
console.error(`No document store found with id ${spec}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
return row;
|
|
176
|
+
}
|
|
177
|
+
const rows = db.prepare(
|
|
178
|
+
`SELECT * FROM doc_mount_points WHERE LOWER(name) = LOWER(?) ORDER BY name COLLATE NOCASE`
|
|
179
|
+
).all(spec);
|
|
180
|
+
if (rows.length === 0) {
|
|
181
|
+
console.error(`No document store found with name "${spec}"`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
if (rows.length > 1) {
|
|
185
|
+
console.error(`Ambiguous store name "${spec}" matches multiple stores:`);
|
|
186
|
+
for (const r of rows) console.error(` ${r.id} ${r.name} (${r.mountType})`);
|
|
187
|
+
console.error('Pass the UUID instead.');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
return rows[0];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isConnectionRefused(err) {
|
|
194
|
+
if (!err) return false;
|
|
195
|
+
const code = err.cause && err.cause.code ? err.cause.code : err.code;
|
|
196
|
+
return code === 'ECONNREFUSED' || code === 'ENOTFOUND' ||
|
|
197
|
+
code === 'EHOSTUNREACH' || code === 'ECONNRESET';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function syncCommand(args) {
|
|
201
|
+
const { flags, positional } = parseFlags(args);
|
|
202
|
+
if (flags.help || positional.length === 0) {
|
|
203
|
+
printSyncHelp();
|
|
204
|
+
process.exit(flags.help ? 0 : 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const [storeSpec, targetSpec] = positional;
|
|
208
|
+
if (!targetSpec) {
|
|
209
|
+
console.error('Usage: quilltap sync <store> <path> [options]');
|
|
210
|
+
console.error("Run 'quilltap sync --help' for the full list of options.");
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
if (positional.length > 2) {
|
|
214
|
+
console.error(`sync takes one store and one path; got ${positional.length} arguments.`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let storeName;
|
|
219
|
+
try {
|
|
220
|
+
storeName = resolveStoreSpec(storeSpec);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`Error: ${err.message}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const targetPath = expandPath(targetSpec);
|
|
227
|
+
|
|
228
|
+
// Resolve the store from the local database — read-only, and the only thing
|
|
229
|
+
// the CLI opens it for.
|
|
230
|
+
const resolved = resolveDataDirAndPassphrase({
|
|
231
|
+
dataDir: flags.dataDir,
|
|
232
|
+
instance: flags.instance,
|
|
233
|
+
passphrase: flags.passphrase,
|
|
234
|
+
});
|
|
235
|
+
printDefaultInstanceHint(resolved);
|
|
236
|
+
const pepper = await loadDbKey(resolved.dataDir, resolved.passphrase);
|
|
237
|
+
const db = openMountIndexDb(resolved.dataDir, pepper, { readonly: true });
|
|
238
|
+
let mount;
|
|
239
|
+
try {
|
|
240
|
+
mount = requireMount(db, storeName);
|
|
241
|
+
} finally {
|
|
242
|
+
db.close();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (mount.mountType !== 'database') {
|
|
246
|
+
console.error(
|
|
247
|
+
`"${mount.name}" is a ${mount.mountType} store — it already IS a directory` +
|
|
248
|
+
(mount.basePath ? ` (${mount.basePath})` : '') + '.'
|
|
249
|
+
);
|
|
250
|
+
console.error('sync mirrors database-backed stores only.');
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const url =
|
|
255
|
+
`http://localhost:${flags.port}/api/v1/mount-points/${encodeURIComponent(mount.id)}?action=sync`;
|
|
256
|
+
|
|
257
|
+
// The route's schema is the single source of truth for these; the CLI does
|
|
258
|
+
// not re-validate, so a bad --direction is refused by the server with the
|
|
259
|
+
// server's own wording.
|
|
260
|
+
const body = {
|
|
261
|
+
targetPath,
|
|
262
|
+
dryRun: flags.dryRun,
|
|
263
|
+
direction: flags.direction,
|
|
264
|
+
prefer: flags.prefer,
|
|
265
|
+
propagateDeletes: !flags.noDelete,
|
|
266
|
+
useManifest: !flags.noManifest,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
let res;
|
|
270
|
+
try {
|
|
271
|
+
res = await fetch(url, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify(body),
|
|
275
|
+
});
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (isConnectionRefused(err)) {
|
|
278
|
+
console.error(
|
|
279
|
+
`Cannot sync database-backed store "${mount.name}" without the Quilltap server.`
|
|
280
|
+
);
|
|
281
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const payload = await res.json().catch(() => null);
|
|
288
|
+
if (!res.ok) {
|
|
289
|
+
const message = payload && payload.error ? payload.error : `HTTP ${res.status}`;
|
|
290
|
+
console.error(`Error: ${message}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const report = payload && payload.data !== undefined ? payload.data : payload;
|
|
295
|
+
|
|
296
|
+
if (flags.json) {
|
|
297
|
+
console.log(JSON.stringify(report, null, 2));
|
|
298
|
+
process.exit(exitCodeFor(report.summary));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const colour = Boolean(process.stdout.isTTY);
|
|
302
|
+
for (const line of formatActionLines(report.actions, colour)) {
|
|
303
|
+
console.log(line);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Advisory text goes to stderr so stdout stays greppable.
|
|
307
|
+
for (const warning of report.warnings || []) {
|
|
308
|
+
console.error(colour ? `${YELLOW}warning:${RESET} ${warning}` : `warning: ${warning}`);
|
|
309
|
+
}
|
|
310
|
+
const summary = formatSummary(report.summary, report.elapsedMs, report.dryRun);
|
|
311
|
+
console.error(colour ? `${DIM}${summary}${RESET}` : summary);
|
|
312
|
+
if (report.summary.conflicts > 0) {
|
|
313
|
+
console.error(
|
|
314
|
+
colour
|
|
315
|
+
? `${DIM}Re-run with --prefer store or --prefer disk to resolve the conflicts.${RESET}`
|
|
316
|
+
: 'Re-run with --prefer store or --prefer disk to resolve the conflicts.'
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
process.exit(exitCodeFor(report.summary));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = { syncCommand, printSyncHelp, expandPath, resolveStoreSpec };
|