quilltap 4.10.0-dev.18 → 4.10.0-dev.183

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.
@@ -1,11 +1,17 @@
1
1
  'use strict';
2
2
 
3
+ const { resolveServerPort } = require('./lock-helpers');
4
+
3
5
  /**
4
6
  * `quilltap recall-replay <chatId>` — replay a turn's memory recall against
5
7
  * the running server and print the candidate table, old path (episodic
6
8
  * signals inert) vs. new path (retrospective flip + time window + entity
7
9
  * anchors + multi-probe) side by side.
8
10
  *
11
+ * Then the text fallback's table and the stage trace (query, gather, hydrate,
12
+ * score, select, assemble): what the archive removed, what the budget cut, what
13
+ * the inter-character section took.
14
+ *
9
15
  * Thin wrapper over POST /api/v1/chats/<chatId>?action=recall-replay.
10
16
  * Read-only; nothing is persisted server-side.
11
17
  */
@@ -28,25 +34,62 @@ Replays the per-turn memory recall for a chat turn against the running
28
34
  Quilltap server and prints the full candidate table twice — the pre-overhaul
29
35
  ranking and the episodic (retrospective/time-window/entity) ranking — so the
30
36
  recall constants can be tuned against real "the character forgot" turns.
37
+ After them: what the text fallback would have returned, and the stage trace
38
+ (query → gather → hydrate → score → select → assemble). The anti-repetition
39
+ ring is the one in force at that turn, read from the chat's recall records.
31
40
 
32
41
  Options:
42
+ -d, --data-dir <path> Instance root; used to find the server's port
43
+ -i, --instance <name> Registered instance whose server to call (see 'quilltap instances')
44
+ --passphrase <pass> Accepted and ignored (this tool never opens the database)
33
45
  --turn <number> 1-based interchange to replay at (default: last)
34
46
  --char <characterId> Character whose memories are searched
35
47
  (default: first LLM-controlled participant)
36
- --limit <number> Candidate rows per path (default: 25, max: 100)
37
- --port <number> Server port for API calls (default: 3000)
48
+ --limit <number> Candidate rows per path (default: the larger of 25
49
+ and the new path's head size + 10; max: 100)
50
+ --memory-budget <tokens>
51
+ Memory token budget (positive integer). The new
52
+ path's selected rows then follow the budget-sized
53
+ dynamic head (the head scales with the model's
54
+ memory budget); the old path keeps the historical
55
+ fixed 5-entry head.
56
+ --tuning '<json>' Retuning constants for the NEW path only, e.g.
57
+ '{"boostGateAbs":0.45,"boostGateMargin":0.15,
58
+ "boostGateRamp":0.1,"boostCap":1.6}'. Keys:
59
+ boostGateAbs, boostGateMargin, boostGateRamp,
60
+ boostCap, freshBypassesGate, windowBypassesGate,
61
+ multipliers
62
+ {RECALL_MULTIPLIERS key:
63
+ value}, specificAnchors, anchorMinHits,
64
+ anchorOrder, backgroundReserve.
65
+ Unknown keys are rejected.
66
+ --tuning-file <path> Read the --tuning JSON from a file
67
+ --signals-from <path> Reuse the signals saved in a previous --json
68
+ replay of the same chat and turn, instead of a
69
+ fresh (non-deterministic) distillation
70
+ --as-of Search only memories created before the turn's
71
+ opening message (the corpus as it stood then)
72
+ --port <number> Server port for API calls. Default: the port recorded in the
73
+ --instance/--data-dir server's lock file, else 3000
38
74
  --json Print the raw JSON result instead of tables
39
75
  -h, --help Show this help
40
76
 
41
77
  Examples:
42
78
  quilltap recall-replay <chatId>
43
79
  quilltap recall-replay <chatId> --turn 42
80
+ quilltap recall-replay <chatId> --turn 42 --memory-budget 4000
44
81
  quilltap recall-replay <chatId> --turn 42 --json > replay.json
82
+ quilltap recall-replay <chatId> --turn 42 --signals-from replay.json --as-of \
83
+ --tuning-file tuning-r1.json
45
84
  `);
46
85
  }
47
86
 
48
87
  function parseFlags(args) {
49
- const flags = { turn: undefined, char: undefined, limit: undefined, port: 3000, json: false, help: false };
88
+ const flags = {
89
+ turn: undefined, char: undefined, limit: undefined, memoryBudget: undefined,
90
+ tuning: undefined, tuningFile: undefined, signalsFrom: undefined, asOf: false,
91
+ port: undefined, dataDir: '', instance: '', json: false, help: false,
92
+ };
50
93
  const positional = [];
51
94
  let i = 0;
52
95
  while (i < args.length) {
@@ -73,6 +116,40 @@ function parseFlags(args) {
73
116
  flags.limit = n;
74
117
  break;
75
118
  }
119
+ case '--memory-budget': {
120
+ const n = Number(args[++i]);
121
+ if (!Number.isInteger(n) || n < 1) {
122
+ console.error('Error: --memory-budget must be a positive integer');
123
+ process.exit(1);
124
+ }
125
+ flags.memoryBudget = n;
126
+ break;
127
+ }
128
+ case '--tuning':
129
+ flags.tuning = args[++i];
130
+ break;
131
+ case '--tuning-file':
132
+ flags.tuningFile = args[++i];
133
+ break;
134
+ case '--signals-from':
135
+ flags.signalsFrom = args[++i];
136
+ break;
137
+ case '--as-of':
138
+ flags.asOf = true;
139
+ break;
140
+ case '-d':
141
+ case '--data-dir':
142
+ flags.dataDir = args[++i];
143
+ break;
144
+ case '-i':
145
+ case '--instance':
146
+ flags.instance = args[++i];
147
+ break;
148
+ case '--passphrase':
149
+ // Accepted so universal flags never error; these tools talk to the
150
+ // running server and never open the database themselves.
151
+ i++;
152
+ break;
76
153
  case '--port': {
77
154
  const p = parseInt(args[++i], 10);
78
155
  if (isNaN(p) || p < 1 || p > 65535) {
@@ -106,8 +183,9 @@ function fmt(n, digits = 3) {
106
183
  return n.toFixed(digits);
107
184
  }
108
185
 
109
- function printPath(label, rows) {
110
- console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates)`);
186
+ function printPath(label, rows, headSize) {
187
+ const head = typeof headSize === 'number' ? `, head ${headSize}` : '';
188
+ console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates${head})`);
111
189
  if (rows.length === 0) {
112
190
  console.log(` ${DIM}(none)${RESET}`);
113
191
  return;
@@ -127,6 +205,73 @@ function printPath(label, rows) {
127
205
  }
128
206
  }
129
207
 
208
+ /** The stage trace: one line per stage entry (query → gather → … → assemble). */
209
+ function printTrace(trace) {
210
+ if (!Array.isArray(trace) || trace.length === 0) return;
211
+ console.log(`\n${BOLD}STAGE TRACE${RESET}`);
212
+ for (const entry of trace) {
213
+ const label = entry.label ? `${DIM}(${entry.label})${RESET} ` : '';
214
+ const detail = Object.entries(entry.detail || {})
215
+ .map(([k, v]) => `${k}=${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`)
216
+ .join(' ');
217
+ console.log(` ${CYAN}${String(entry.stage).padEnd(9)}${RESET}${label}${detail}`);
218
+ }
219
+ }
220
+
221
+ function readJson(text, what) {
222
+ try {
223
+ return JSON.parse(text);
224
+ } catch (err) {
225
+ console.error(`${RED}Could not parse ${what} as JSON: ${err.message}${RESET}`);
226
+ process.exit(1);
227
+ }
228
+ }
229
+
230
+ function readJsonFile(path, what) {
231
+ const fs = require('fs');
232
+ let text;
233
+ try {
234
+ text = fs.readFileSync(path, 'utf8');
235
+ } catch (err) {
236
+ console.error(`${RED}Could not read ${what} ${path}: ${err.message}${RESET}`);
237
+ process.exit(1);
238
+ }
239
+ return readJson(text, `${what} ${path}`);
240
+ }
241
+
242
+ /** The tuning object from --tuning or --tuning-file (never both). */
243
+ function loadTuning(flags) {
244
+ if (flags.tuning !== undefined && flags.tuningFile !== undefined) {
245
+ console.error('Error: pass --tuning or --tuning-file, not both');
246
+ process.exit(1);
247
+ }
248
+ if (flags.tuning !== undefined) return readJson(flags.tuning, '--tuning');
249
+ if (flags.tuningFile !== undefined) return readJsonFile(flags.tuningFile, '--tuning-file');
250
+ return undefined;
251
+ }
252
+
253
+ /**
254
+ * The signals saved in a previous --json replay. Refuses a file from another
255
+ * chat or turn — reusing its signals would replay a different question.
256
+ */
257
+ function loadSignals(path, chatId, turn) {
258
+ const saved = readJsonFile(path, '--signals-from');
259
+ const result = saved.data ?? saved;
260
+ if (!result.signals) {
261
+ console.error(`${RED}${path} has no saved signals (its distillation failed); replay without --signals-from${RESET}`);
262
+ process.exit(1);
263
+ }
264
+ if (result.chatId && result.chatId !== chatId) {
265
+ console.error(`${RED}${path} is a replay of chat ${result.chatId}, not ${chatId}${RESET}`);
266
+ process.exit(1);
267
+ }
268
+ if (turn !== undefined && result.turnIndex !== undefined && result.turnIndex !== turn) {
269
+ console.error(`${RED}${path} is a replay of turn ${result.turnIndex}, not ${turn}${RESET}`);
270
+ process.exit(1);
271
+ }
272
+ return result.signals;
273
+ }
274
+
130
275
  async function recallReplayCommand(args) {
131
276
  const { flags, positional } = parseFlags(args);
132
277
 
@@ -140,11 +285,26 @@ async function recallReplayCommand(args) {
140
285
  }
141
286
  const chatId = positional[0];
142
287
 
143
- const url = `http://localhost:${flags.port}/api/v1/chats/${encodeURIComponent(chatId)}?action=recall-replay`;
288
+ let target;
289
+ try {
290
+ target = resolveServerPort({ port: flags.port, instance: flags.instance, dataDir: flags.dataDir });
291
+ } catch (err) {
292
+ console.error(`Error: ${err.message}`);
293
+ process.exit(1);
294
+ }
295
+ const port = target.port;
296
+ if (target.note) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
297
+
298
+ const url = `http://localhost:${port}/api/v1/chats/${encodeURIComponent(chatId)}?action=recall-replay`;
144
299
  const body = {};
145
300
  if (flags.turn !== undefined) body.turnIndex = flags.turn;
146
301
  if (flags.char) body.characterId = flags.char;
147
302
  if (flags.limit !== undefined) body.limit = flags.limit;
303
+ if (flags.memoryBudget !== undefined) body.memoryBudget = flags.memoryBudget;
304
+ const tuning = loadTuning(flags);
305
+ if (tuning !== undefined) body.tuning = tuning;
306
+ if (flags.signalsFrom !== undefined) body.signals = loadSignals(flags.signalsFrom, chatId, flags.turn);
307
+ if (flags.asOf) body.asOf = true;
148
308
 
149
309
  process.stderr.write(`${BOLD}Replaying recall${RESET} for chat ${DIM}${chatId}${RESET} via ${DIM}${url}${RESET}\n`);
150
310
 
@@ -156,8 +316,8 @@ async function recallReplayCommand(args) {
156
316
  body: JSON.stringify(body),
157
317
  });
158
318
  } catch (err) {
159
- console.error(`${RED}Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}${RESET}`);
160
- console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
319
+ console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
320
+ console.error(target.hint);
161
321
  process.exit(1);
162
322
  }
163
323
 
@@ -170,6 +330,11 @@ async function recallReplayCommand(args) {
170
330
  }
171
331
  if (!res.ok || payload?.success === false) {
172
332
  console.error(`${RED}Replay failed (status ${res.status}): ${payload?.error || payload?.message || 'unknown error'}${RESET}`);
333
+ if (Array.isArray(payload?.details)) {
334
+ for (const issue of payload.details) {
335
+ console.error(` ${(issue.path || []).join('.') || '(body)'}: ${issue.message}`);
336
+ }
337
+ }
173
338
  process.exit(1);
174
339
  }
175
340
 
@@ -193,9 +358,26 @@ async function recallReplayCommand(args) {
193
358
  console.log(`${BOLD}Signals${RESET} ${YELLOW}distillation failed — new path ran inert${RESET}`);
194
359
  }
195
360
 
196
- printPath('OLD PATH (episodic signals inert)', result.oldPath || []);
197
- printPath('NEW PATH (retrospective/window/entities live)', result.newPath || []);
361
+ const pinned = result.signalsPinned ? `${CYAN}pinned${RESET}` : `${DIM}fresh distillation${RESET}`;
362
+ const asOf = result.asOf
363
+ ? `${CYAN}as of ${result.asOf}${RESET} ${DIM}(${result.excludedAfterAsOf} later memories left out)${RESET}`
364
+ : `${DIM}whole corpus${RESET}`;
365
+ console.log(`${BOLD}Harness${RESET} signals ${pinned} · ${asOf}`);
366
+ console.log(`${BOLD}Tuning${RESET} ${result.tuning === 'defaults' || !result.tuning ? `${DIM}defaults${RESET}` : `${YELLOW}${result.tuning}${RESET}`} ${DIM}(new path only)${RESET}`);
367
+ if (result.ringSource) {
368
+ const source = result.ringSource === 'debug-log'
369
+ ? `${DIM}as of the turn, from its recall records${RESET}`
370
+ : `${YELLOW}the chat's current ring (no recall records that early)${RESET}`;
371
+ console.log(`${BOLD}Ring${RESET} ${(result.ring || []).length} recently whispered · ${source}`);
372
+ }
373
+
374
+ printPath('OLD PATH (episodic signals inert)', result.oldPath || [], result.oldHeadSize);
375
+ printPath('NEW PATH (retrospective/window/entities live)', result.newPath || [], result.newHeadSize);
376
+ if (Array.isArray(result.fallbackPath)) {
377
+ printPath('TEXT FALLBACK (what the new path returns with no usable vector)', result.fallbackPath, result.newHeadSize);
378
+ }
379
+ printTrace(result.trace);
198
380
  console.log('');
199
381
  }
200
382
 
201
- module.exports = { recallReplayCommand };
383
+ module.exports = { recallReplayCommand, parseFlags };
@@ -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 };