quilltap 4.10.0-dev.51 → 4.10.0-dev.57

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 CHANGED
@@ -100,15 +100,19 @@ quilltap instances default Friday # Make it the fall-thro
100
100
  quilltap instances rename Friday Weekday # Rename, preserving the stored passphrase
101
101
  quilltap instances remove Friday # Unregister
102
102
  quilltap instances restore-key Friday # Rebuild a lost or passphrase-locked .dbkey
103
+ quilltap instances default --clear # Revert to the OS platform default
104
+ quilltap instances list --json # The registry as JSON, for scripts
103
105
  ```
104
106
 
105
- If an instance's `quilltap.dbkey` goes missing — or its passphrase does — `instances restore-key` rebuilds it. The file only *wraps* the pepper; the pepper itself is the database key, so an operator who kept the one printed at first-run setup can get back in. The pepper is read from `ENCRYPTION_MASTER_PEPPER` or prompted for hidden, never passed as a flag, and it is proved against the encrypted databases on disk before anything is written. Run it with the server down — the command refuses while the instance lock is held. Flags: `--passphrase <pass>` / `--no-passphrase` (the new wrapping), `-d, --data-dir <path>`, `--force`, `-y, --yes`.
107
+ If an instance's `quilltap.dbkey` goes missing — or its passphrase does — `instances restore-key` rebuilds it. The file only *wraps* the pepper; the pepper itself is the database key, so an operator who kept the one printed at first-run setup can get back in. The pepper is read from `ENCRYPTION_MASTER_PEPPER` or prompted for hidden, never passed as a flag, and it is proved against the encrypted databases on disk before anything is written. Run it with the server down — the command refuses while the instance lock is held. Flags: `--passphrase <pass>` / `--no-passphrase` (the new wrapping), `-d, --data-dir <path>`, `--force` (proceed when there is no encrypted database to prove against — a fresh or still-plaintext instance), `-y, --yes`. An existing key file is backed up to `quilltap.dbkey.bak-<timestamp>` first, and a registered instance's stored passphrase is updated to match.
106
108
 
107
- Every subcommand then accepts `--instance <name>` in place of `--data-dir`. The registry lives at `<app-support>/Quilltap/instances.json` (mode 0600; e.g. `~/Library/Application Support/Quilltap/instances.json` on macOS). **Resolution precedence:** `--data-dir` > `--instance` > registered default > `QUILLTAP_DATA_DIR` > the OS platform default. Pass the **instance root** (e.g. `~/iCloud/Quilltap/Friday`), not its `data/` subdirectory.
109
+ **It does not re-encrypt character archive bundles.** Those are keyed on the *passphrase*, not the pepper; only the server's Change Passphrase card rewrites them. Bundles made under a passphrase you have just replaced still want the old one.
110
+
111
+ Every subcommand then accepts `--instance <name>` in place of `--data-dir`. The registry lives at `<app-support>/Quilltap/instances.json` (mode 0600; e.g. `~/Library/Application Support/Quilltap/instances.json` on macOS). **Resolution precedence:** `--data-dir` > `--instance` > registered default > `QUILLTAP_DATA_DIR` > the OS platform default. Pass the **instance root** (e.g. `~/iCloud/Quilltap/Friday`), not its `data/` subdirectory — the CLI appends `data/quilltap.db` itself. `instances list --json` emits the registry (`name`, `path`, `hasPassphrase`, `isDefault`) for scripting.
108
112
 
109
113
  ## Database Tool
110
114
 
111
- The encrypted SQLite databases (main, LLM logs, mount index) can be queried directly via `quilltap db`. There are two modes: high-level subcommands that auto-pick the right database and resolve characters/chats/projects by name, and a low-level path for arbitrary SQL.
115
+ The encrypted SQLite databases (main, LLM logs, mount index) can be queried directly via `quilltap db`. They are SQLCipher-encrypted, so the stock `sqlite3` binary **cannot** open them — this is the way in. There are two modes: high-level subcommands that auto-pick the right database and resolve characters/chats/projects by name, and a low-level path for arbitrary SQL.
112
116
 
113
117
  ### Subcommands
114
118
 
@@ -135,6 +139,27 @@ quilltap db memories --character Friday [--about Amy] [--source AUTO]
135
139
  quilltap db characters status # Per-character vault readiness (--id, --diverged, --blocked)
136
140
  ```
137
141
 
142
+ SQLite columns are **camelCase**, mirroring the Zod/TypeScript types — `createdAt`, `updatedAt`, `chatType`, `messageCount`, `projectId`, *not* `created_at`. When in doubt, run `quilltap db schema <table>`.
143
+
144
+ ### Character Archive
145
+
146
+ Archiving prunes a character down to a tombstone and packs everything else into an encrypted `ARCHIVE` bundle in the file library; rehydrating puts it all back at its original ids.
147
+
148
+ ```bash
149
+ quilltap db characters archives # Archived characters + the bundles on the shelf
150
+ quilltap db characters archive Ariadne --write # Archive her (server must be running)
151
+ quilltap db characters rehydrate Ariadne --write # Wake her again
152
+ quilltap db characters export Ariadne --out /tmp # Plaintext .qtap, archived or live
153
+ ```
154
+
155
+ `archives` is read-only, and flags **loose** bundles — survivors of a "keep archived bundles" wipe, which are importable but not rehydratable.
156
+
157
+ `archive` and `rehydrate` run **through the running server's API** (`--port`, default 3000), because the export pipeline and the unlocked passphrase live only in the server process; the server, not the CLI, holds the instance lock for the duration. `--write` is still required as the explicit opt-in. A rehydrate restores the pruned material at its original ids (skip-if-present), clears the tombstone, and queues re-embedding; the bundle stays in the file library afterwards as a spare copy. On failure — wrong-era passphrase, missing bundle, import refusal — the character stays archived and re-running is safe.
158
+
159
+ `export` writes a **plaintext** `.qtap` and takes no `--write`. For an **archived** character it decrypts the bundle straight off the disk, offline (it tries the internal no-passphrase key, then `QUILLTAP_DB_PASSPHRASE`, then prompts) — the only way to reach packed-away mail, photographs, and summaries without rehydrating. For a **live** character it runs the server's export pipeline, so the server must be up.
160
+
161
+ **Pre-emptive, not recovery:** exporting an archive needs an instance that can still decrypt it. It is no help to someone holding only a restored backup and a forgotten passphrase — which is also why changing an instance's passphrase rewrites every archive bundle. A bundle reported left behind by a partial rewrite still wants the old one.
162
+
138
163
  ### Maintenance and Snapshots
139
164
 
140
165
  ```bash
@@ -149,7 +174,7 @@ quilltap db integrity # cipher_integrity_check + integrity
149
174
  quilltap db integrity llm-logs # one DB; exit 0 ok, 1 issues, 2 open failure
150
175
  ```
151
176
 
152
- `backup` and `integrity` are safe to run while the server is up; `optimize` refuses while a live lock is held. Backups default to `<dataDir>/backups/<timestamp>/` and inherit the source's encryption key transparently.
177
+ `backup` and `integrity` are safe to run while the server is up; `optimize` refuses while a live lock is held (see [Locking](#locking)). Backups default to `<dataDir>/backups/<timestamp>/` and inherit the source's encryption key transparently.
153
178
 
154
179
  Most subcommands accept `--json` (for piping) and `--limit N`. Names are case-insensitive; aliases are searched alongside character names. Ambiguous matches print all candidates and exit non-zero.
155
180
 
@@ -166,10 +191,37 @@ quilltap db --llm-logs --tables # Target the LLM logs DB
166
191
  quilltap db --mount-points --tables # Target the mount index DB
167
192
  ```
168
193
 
169
- The database is opened **read-only by default**. Add `--write` to make changes: it opens the database read-write, **claims the instance lock** (`<dataDir>/quilltap.lock`) for the duration, and releases it on exit. It **refuses — with no override — if a running server or another instance holds the lock**, so stop the server first. `--repl` is read-only unless combined with `--write`. Attempting a write without `--write` fails with a hint to re-run with the flag.
194
+ The database is opened **read-only by default**. Add `--write` to make changes: it opens the database read-write, **claims the instance lock** (`<dataDir>/quilltap.lock`) for the duration, and releases it on exit. It **refuses — with no override — if a running server or another instance holds the lock**, so stop the server first. `--repl` is read-only unless combined with `--write`. Attempting a write without `--write` fails with a hint to re-run with the flag. What "held" means, and how to tell a live lock from a stale one, is [below](#locking).
170
195
 
171
196
  In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
172
197
 
198
+ ## Locking
199
+
200
+ Everything that writes to an instance claims `<dataDir>/quilltap.lock` — the same lockfile the server itself uses — for the duration, and releases it on exit. That is `db --write` (including `db --repl --write`), `db optimize`, `maintenance run`, and `instances restore-key`. All four **refuse while the lock is held**: stop the server first. Read-only work — plain `db`, the `docs` read verbs, `memories`, `logs`, `migrations`, `maintenance status`, `db backup`, `db integrity` — never touches the lock and is safe alongside a running instance.
201
+
202
+ ### The five-minute heartbeat window
203
+
204
+ A lock counts as held until its heartbeat is **five minutes** stale, whether or not the process that set it is still alive. Freshness is the fallback for every environment, not just containers: a PID check is not reliable everywhere, and cleaning a *live* instance's lock is much the worse failure.
205
+
206
+ So for up to five minutes after stopping the server, `--write`, `optimize`, `maintenance run`, `restore-key`, and `--lock-clean` all still refuse. That is correct behaviour, not a stale lock. Wait it out; the next startup reclaims the lock regardless.
207
+
208
+ ```bash
209
+ quilltap db --lock-status # Who holds it, and how old the heartbeat is
210
+ quilltap db --lock-clean # Remove a lock whose heartbeat has gone stale
211
+ ```
212
+
213
+ `--lock-status` shows the heartbeat age, which is the tell. `--lock-clean` says so explicitly rather than muttering about liveness, because that arm is only reached once the PID check has come back dead:
214
+
215
+ ```
216
+ Lock heartbeat is still fresh (82s ago). Cannot clean.
217
+ A lock counts as held until its heartbeat is 5 minutes stale, even if its process has gone.
218
+ Wait it out, or use --lock-override to force.
219
+ ```
220
+
221
+ When a live process really does hold the lock, it refuses with "Lock is held by a live Quilltap process" instead.
222
+
223
+ **`--lock-override` exists and is almost never the right answer** — it defeats the protection the lock provides. Reach for `--lock-status` first.
224
+
173
225
  ## Document Stores (Scriptorium)
174
226
 
175
227
  `quilltap docs` exposes the document-store machinery from the command line. Read-only verbs open the mount-index DB directly and work without the server; write and pipeline verbs talk to the running server via `/api/v1/mount-points/[id]`.
@@ -178,6 +230,7 @@ In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
178
230
  # Read
179
231
  quilltap docs list # All mounts
180
232
  quilltap docs show <mount> # One mount, with counts
233
+ quilltap docs files <mount> [--folder <path>] # Flat file list for a mount
181
234
  quilltap docs ls <mount> [path] [--links] # POSIX-flavoured listing (alias: dir)
182
235
  quilltap docs tree <mount> [path] # ASCII tree of a folder hierarchy (--depth, --max-nodes)
183
236
  quilltap docs read [--rendered] <mount> <path> # File contents → stdout
@@ -203,7 +256,24 @@ quilltap docs rmdir <mount> <path> # Delete an empty
203
256
  quilltap docs mvdir <mount> <fromPath> <toPath> # Rename/move a folder (server-required)
204
257
  ```
205
258
 
206
- Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous names print candidates and exit non-zero. `--json` is supported by every verb; `reindex`, `embed`, `link`, `rmdir`, and `mvdir` refuse to run without a reachable server.
259
+ Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous names print candidates and exit non-zero. `--json` is supported by every verb; `reindex`, `embed`, `link`, `rmdir`, and `mvdir` refuse to run without a reachable server. `grep --semantic` goes through `POST /api/v1/mount-points?action=semantic-search`, because the embedding provider lives in the server; it defaults to `--top 20`, `--threshold 0.5`, `--port 3000`.
260
+
261
+ ### Addressing documents with `qtap://` URIs
262
+
263
+ Anywhere a verb takes a positional `<mount> <relativePath>` pair — `read`, `write`, `delete`, `mkdir`, `ls`/`dir`, `tree`, `files`, `move`, `copy`, `link`, `rmdir`, `mvdir` — you may pass a single `qtap://…` URI in its place:
264
+
265
+ ```bash
266
+ quilltap docs read qtap://notes/today.md
267
+ quilltap docs move qtap://drafts/foo.md qtap://notes/2026/foo.md
268
+ quilltap docs grep --mount qtap://notes/ "TODO"
269
+ quilltap docs find --uri Manifesto
270
+ ```
271
+
272
+ The URI authority is matched name-first, UUID as fallback — the same rule as a bare `<mount>` (`qtap://<store name>/…` or `qtap://<uuid>/…`). Two-target verbs (`move` / `copy` / `link` / `mvdir`) take either two `qtap://` URIs or the four legacy positionals; `find` and `grep` take one via `--mount`.
273
+
274
+ **CLI limitation:** the CLI addresses document stores only. `qtap://self/…` needs a character context, and there is none at a shell prompt, so it is rejected with guidance; `qtap://project/…` and `qtap://general/…` are likewise not CLI-addressable — pass a store name or UUID instead.
275
+
276
+ **Emitting URIs:** `--json` output for `find`, `grep`, `ls`, `files`, and `tree` carries a `uri` field on every row or node. `--uri` switches the text output of `find`, `grep`, and `files` to show the canonical `qtap://` URI as the locator (name form, UUID when the store name is ambiguous).
207
277
 
208
278
  ### `--base64` flag
209
279
 
@@ -213,12 +283,49 @@ Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous na
213
283
 
214
284
  ### `link`, `rmdir`, `mvdir`
215
285
 
286
+ **`link` vs `copy`:** `docs link` makes two addresses into one document — it shares the content row *and* enrols both link rows in a `linkGroupId`, so a later write through either path repoints both and re-chunks the sibling. `docs copy` produces an independent document that merely shares a deduplicated content row until the first write. The `links` column in `ls` counts group members, not rows that happen to share identical bytes.
287
+
216
288
  `link` calls `POST /api/v1/mount-points/{srcMountId}?action=link-file` with `{sourcePath, destMountPointId, destPath}`. Creates a true hard link with no byte copy; the server reports back a `strategy` field. Errors: `DEST_EXISTS` (exit 2), `UNSUPPORTED` (cross-storage or cross-device), `SOURCE_NOT_FOUND`.
217
289
 
218
290
  `rmdir` calls `POST /api/v1/mount-points/{mountId}?action=delete-folder` with `{path}`. Fails with a clear message if the folder is not empty (`NOT_EMPTY` / `CONFLICT`).
219
291
 
220
292
  `mvdir` calls `POST /api/v1/mount-points/{mountId}?action=move-folder` with `{fromPath, toPath}`. Fails with exit 2 if the destination already exists (`DEST_EXISTS`).
221
293
 
294
+ ### Docker binds
295
+
296
+ `quilltap docs docker-mounts` reports the bind mounts an instance's filesystem and Obsidian stores need in order to be reachable inside a container. Binds are **path-identical** (`-v /host/vault:/host/vault`), so the `basePath` recorded in the database resolves the same inside and out. Stores sharing a path collapse to a single bind, paths nested inside another bind are dropped, and a path that does not exist is **skipped** rather than handed to Docker to fabricate as an empty directory. It warns about macOS paths outside Docker Desktop's default shares and about a Linux uid mismatch, and refuses on Windows, where path-identical binds are not possible. `--format args` puts only the flags on stdout and all advice on stderr, which is what makes it pipeable into a `docker run`.
297
+
298
+ ## Sync a Store to a Directory
299
+
300
+ `quilltap sync <store|qtap://store/> <path>` mirrors a **database-backed** document store and a directory on disk in both directions. Edit a file in your own editor and the next run carries it into the store; edit it in the Scriptorium and the next run carries it out.
301
+
302
+ ```bash
303
+ quilltap sync Lore ~/Documents/lore --dry-run # plan only
304
+ quilltap sync Lore ~/Documents/lore # apply
305
+ quilltap sync Lore ~/Documents/lore --prefer disk
306
+ ```
307
+
308
+ ```text
309
+ --dry-run Plan and print; change nothing on either side
310
+ --direction <which> both (default), to-disk, or to-store
311
+ --prefer <which> newer (default), store, or disk — resolves conflicts
312
+ --no-delete Never propagate a deletion
313
+ --no-manifest Ignore .quilltap-sync.json (first-run rules every time)
314
+ --json Machine-readable plan and results
315
+ -p, --port <n> Server port (default 3000)
316
+ -i, -d, --passphrase The usual instance plumbing (used only to resolve <store>)
317
+ ```
318
+
319
+ Compares by SHA-256 first and modification time second, so equal bytes with unequal clocks are re-stamped rather than re-copied. The side that changed wins; when both changed since the last run it is a `conflict` and nothing happens. After a content action both sides carry the winner's `lastModified` and the **older** of the two `createdAt`s. Deletions propagate only when `.quilltap-sync.json` — a manifest the verb keeps in the directory — proves the entry was there at the last run; on a first run an entry present on one side is created on the other, never deleted.
320
+
321
+ Files and folders whose names begin with a dot are **invisible in both directions**, the manifest being the one exception. A binary's description travels as `<file>.description.md` beside it. Bytes are preserved verbatim: a `.png` pushed from disk stays a `.png`, unlike a Scriptorium upload. Chunks and embedding vectors are never touched by the sync — the store's own post-write hooks re-index.
322
+
323
+ Exit codes: `0` clean, `1` error or failed action, `2` unresolved conflict; `--dry-run` uses the same codes. Report lines go to stdout, warnings and the summary to stderr.
324
+
325
+ Server-required (as `docs write` already is for database stores), and the path is resolved **on the server** — under Docker it must sit inside a bind mount (`quilltap docs docker-mounts`). Refused for a filesystem or Obsidian store (one pointed at its own `basePath`), an archived character's vault, a store mid-conversion or mid-scan, a second concurrent run, and a manifest belonging to another store.
326
+
327
+ A character vault's **keystone files** — `properties.json`, the five required `.md` files, and `Wardrobe/instructions.md` — are never deleted from the store by a sync. The planner reports a `conflict` instead.
328
+
222
329
  ## Memories
223
330
 
224
331
  `quilltap memories` exposes the same Commonplace Book that each character carries — searchable, sortable, graphable, but never writable. All verbs open the main encrypted DB read-only.
@@ -231,10 +338,11 @@ quilltap memories grep -i --max 3 --context 1 "concrete examples" # Pattern
231
338
  quilltap memories show <id|prefix> [--depth N] [--no-related] # Full record + related-memory neighbourhood
232
339
  quilltap memories tree <id|prefix> [--depth N] [--max-nodes N] # ASCII walk of the bidirectional related-memory graph
233
340
  quilltap memories status [--character <name|id>] # Per-holder rollup + dangling-edge check
341
+ quilltap memories validate [--character <name|id>] [--list] # Dangling-edge health check; exit 1 if any remain
234
342
  quilltap memories grep --semantic --character Ariadne "the argument" # Embedding search (server required, one holder)
235
343
  ```
236
344
 
237
- Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source`, `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` to reverse) apply to `ls`, `find`, and `grep`. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. The legacy `quilltap db memories --character <name>` verb remains undisturbed.
345
+ Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source`, `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` to reverse) apply to `ls`, `find`, and `grep`. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. `validate` is `status`'s terse twin — read-only, exit 1 if any dangling related-memory edge remains, `--list` to print the offending source IDs and their dangling targets. `grep --semantic` defaults to `--top 20`, `--threshold 0.5`, `--port 3000`, and scopes to **one holder at a time**: `--character all` is rejected. The legacy `quilltap db memories --character <name>` verb remains undisturbed.
238
346
 
239
347
  ## Memory Extraction Dry-Run
240
348
 
package/bin/quilltap.js CHANGED
@@ -92,6 +92,7 @@ Subcommands:
92
92
  db Query encrypted databases
93
93
  themes Manage theme bundles
94
94
  docs Inspect, read, and export document mounts
95
+ sync <store> <path> Mirror a database-backed store to a directory
95
96
  memories Search, browse, and graph memories
96
97
  instances Register / inspect named Quilltap instances
97
98
  logs Tail or print an instance log file
@@ -1174,7 +1175,7 @@ async function dbCommand(args) {
1174
1175
  // to the subcommand. Each subcommand parses these flags position-independently,
1175
1176
  // so they behave the same before or after the verb.
1176
1177
  const SUBCOMMANDS = new Set([
1177
- 'db', 'themes', 'docs', 'memories', 'instances', 'memory-diff', 'recall-replay', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
1178
+ 'db', 'themes', 'docs', 'sync', 'memories', 'instances', 'memory-diff', 'recall-replay', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
1178
1179
  ]);
1179
1180
  // Global flags that consume the following token as their value.
1180
1181
  const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
@@ -1213,6 +1214,12 @@ if (subName === 'db') {
1213
1214
  } else if (subName === 'docs') {
1214
1215
  const { docsCommand } = require('../lib/docs-commands');
1215
1216
  docsCommand(subArgs);
1217
+ } else if (subName === 'sync') {
1218
+ const { syncCommand } = require('../lib/sync-command');
1219
+ syncCommand(subArgs).catch(err => {
1220
+ console.error(`Error: ${err.message}`);
1221
+ process.exit(1);
1222
+ });
1216
1223
  } else if (subName === 'memories') {
1217
1224
  const { memoriesCommand } = require('../lib/memories-commands');
1218
1225
  memoriesCommand(subArgs).catch(err => {
@@ -108,6 +108,7 @@ describe('every subcommand has its own completion arm', () => {
108
108
  const HELP_SOURCES = {
109
109
  db: ['bin/quilltap.js', 'printDbHelp'],
110
110
  docs: ['lib/docs-commands.js', 'printDocsHelp'],
111
+ sync: ['lib/sync-command.js', 'printSyncHelp'],
111
112
  memories: ['lib/memories-commands.js', 'printMemoriesHelp'],
112
113
  themes: ['lib/theme-commands.js', 'printHelp'],
113
114
  instances: ['lib/instances-commands.js', 'printHelp'],
@@ -0,0 +1,158 @@
1
+ /**
2
+ * How `quilltap sync` renders a plan.
3
+ *
4
+ * The formatter is the operator's whole view of what the verb did, and its
5
+ * two load-bearing claims are easy to get subtly wrong: the second column is
6
+ * the side that CHANGES (so `modify store` means the store is rewritten from
7
+ * disk, not the other way round), and the exit code distinguishes "an
8
+ * unresolved conflict" from "something failed" so a script can tell them apart.
9
+ *
10
+ * @jest-environment node
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const {
16
+ formatActionLine,
17
+ formatActionLines,
18
+ formatSummary,
19
+ exitCodeFor,
20
+ displayPath,
21
+ formatBytes,
22
+ } = require('../sync-report');
23
+
24
+ function action(over = {}) {
25
+ return { kind: 'create', side: 'disk', relativePath: 'a.md', entryKind: 'file', ...over };
26
+ }
27
+
28
+ function summary(over = {}) {
29
+ return {
30
+ created: 0, modified: 0, deleted: 0, touched: 0,
31
+ described: 0, conflicts: 0, skipped: 0, failed: 0, ...over,
32
+ };
33
+ }
34
+
35
+ describe('one action, one line', () => {
36
+ it('puts the action first and the side that changes second', () => {
37
+ expect(formatActionLine(action({ kind: 'modify', side: 'store', relativePath: 'ch.md' }), false))
38
+ .toBe('modify store ch.md');
39
+ });
40
+
41
+ it('pads the columns so paths line up under each other', () => {
42
+ const lines = formatActionLines([
43
+ action({ kind: 'mkdir', side: 'disk', relativePath: 'lore', entryKind: 'folder' }),
44
+ action({ kind: 'conflict', side: null, relativePath: 'ch.md' }),
45
+ ], false);
46
+ const column = lines.map((l) => l.indexOf(l.trim().split(/\s+/)[2] || ''));
47
+ expect(new Set(column).size).toBe(1);
48
+ });
49
+
50
+ it('marks a folder with a trailing slash so it cannot be mistaken for a file', () => {
51
+ expect(displayPath(action({ entryKind: 'folder', relativePath: 'drafts' }))).toBe('drafts/');
52
+ expect(displayPath(action({ entryKind: 'file', relativePath: 'drafts' }))).toBe('drafts');
53
+ });
54
+
55
+ it('writes an em dash where no side changes', () => {
56
+ expect(formatActionLine(action({ kind: 'conflict', side: null }), false))
57
+ .toContain('conflict —');
58
+ });
59
+
60
+ it('appends the engine’s reason in parentheses', () => {
61
+ expect(formatActionLine(action({ kind: 'modify', side: 'store', reason: 'disk newer by 2h 14m' }), false))
62
+ .toContain('(disk newer by 2h 14m)');
63
+ });
64
+
65
+ it('shows the sha and size on a line that moved bytes', () => {
66
+ const line = formatActionLine(
67
+ action({ sha256: '3f9a' + '0'.repeat(60), sizeBytes: 421888 }), false
68
+ );
69
+ expect(line).toContain('sha 3f9a…');
70
+ expect(line).toContain('412.0 KB');
71
+ });
72
+
73
+ it('says nothing about bytes on a delete', () => {
74
+ expect(formatActionLine(action({ kind: 'delete', side: 'store', sha256: 'x'.repeat(64) }), false))
75
+ .not.toContain('sha');
76
+ });
77
+
78
+ it('leads with the error when an action failed', () => {
79
+ const line = formatActionLine(
80
+ action({ outcome: 'failed', error: 'EACCES', reason: 'store newer' }), false
81
+ );
82
+ expect(line).toContain('FAILED: EACCES');
83
+ expect(line).not.toContain('store newer');
84
+ });
85
+
86
+ it('emits no escape codes when colour is off', () => {
87
+ const lines = formatActionLines(
88
+ [action(), action({ kind: 'conflict', side: null }), action({ kind: 'delete', side: 'store' })],
89
+ false
90
+ );
91
+ expect(lines.join('\n')).not.toMatch(/\x1b\[/);
92
+ });
93
+
94
+ it('tints the line when colour is on', () => {
95
+ expect(formatActionLine(action({ kind: 'conflict', side: null }), true)).toMatch(/\x1b\[31m/);
96
+ });
97
+
98
+ it('caps the path column so one long path does not push every detail off screen', () => {
99
+ const lines = formatActionLines([
100
+ action({ relativePath: 'a/'.repeat(60) + 'deep.md', reason: 'why' }),
101
+ action({ relativePath: 'b.md', reason: 'why' }),
102
+ ], false);
103
+ expect(lines[1].indexOf('(why)')).toBeLessThan(80);
104
+ });
105
+ });
106
+
107
+ describe('the summary', () => {
108
+ it('names only the categories that actually happened', () => {
109
+ expect(formatSummary(summary({ created: 3, modified: 1 }), 800, false))
110
+ .toBe('3 created, 1 modified — 0.8 s');
111
+ });
112
+
113
+ it('says so plainly when there was nothing to do', () => {
114
+ expect(formatSummary(summary(), 120, false)).toBe('Already in step — 0.1 s');
115
+ });
116
+
117
+ it('speaks in the conditional under --dry-run', () => {
118
+ expect(formatSummary(summary({ created: 2 }), 300, true)).toBe('Would do: 2 created — 0.3 s');
119
+ expect(formatSummary(summary(), 300, true)).toBe('Nothing to do — 0.3 s');
120
+ });
121
+
122
+ it('pluralises conflicts', () => {
123
+ expect(formatSummary(summary({ conflicts: 1 }), 0, false)).toContain('1 conflict —');
124
+ expect(formatSummary(summary({ conflicts: 2 }), 0, false)).toContain('2 conflicts');
125
+ });
126
+
127
+ it('reports failures last, where they are hardest to miss', () => {
128
+ const line = formatSummary(summary({ created: 1, failed: 2 }), 0, false);
129
+ expect(line.indexOf('2 failed')).toBeGreaterThan(line.indexOf('1 created'));
130
+ });
131
+ });
132
+
133
+ describe('the exit code', () => {
134
+ it('is 0 when the run was clean', () => {
135
+ expect(exitCodeFor(summary({ created: 5, touched: 2 }))).toBe(0);
136
+ });
137
+
138
+ it('is 2 for an unresolved conflict', () => {
139
+ expect(exitCodeFor(summary({ conflicts: 1 }))).toBe(2);
140
+ });
141
+
142
+ it('is 1 when something failed outright, even alongside a conflict', () => {
143
+ expect(exitCodeFor(summary({ conflicts: 1, failed: 1 }))).toBe(1);
144
+ });
145
+ });
146
+
147
+ describe('byte formatting', () => {
148
+ it('scales through the units', () => {
149
+ expect(formatBytes(512)).toBe('512 B');
150
+ expect(formatBytes(2048)).toBe('2.0 KB');
151
+ expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
152
+ expect(formatBytes(3 * 1024 ** 3)).toBe('3.00 GB');
153
+ });
154
+
155
+ it('says nothing for a size it was not given', () => {
156
+ expect(formatBytes(undefined)).toBe('');
157
+ });
158
+ });
@@ -61,7 +61,7 @@ _quilltap_complete() {
61
61
  local global_opts="-d --data-dir -i --instance -p --port -o --open -v --version -h --help --update --passphrase"
62
62
 
63
63
  # Top-level subcommands
64
- local top_cmds="db docs themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion"
64
+ local top_cmds="db docs sync themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion"
65
65
 
66
66
  # Flags that swallow the word after them. A flat list will not do: -o is the
67
67
  # valueless global --open but themes' valued --output, and `memories`
@@ -71,6 +71,7 @@ _quilltap_complete() {
71
71
  local vf_global=" -d --data-dir -i --instance -p --port --passphrase "
72
72
  local vf_db=" --limit --grep --character --project --about --source --chat --message --field --tail --last --from --type --out --id --count "
73
73
  local vf_docs=" --mount --folder --type --ext --limit --max --context --top --threshold --sort --depth --max-nodes --format "
74
+ local vf_sync=" --direction --prefer "
74
75
  local vf_themes=" -o --output -k --key -n --name "
75
76
  local vf_memories=" -d --data-dir --instance --passphrase --port --character --about --source --chat --project --since --until --min-importance --min-reinforced --sort --limit --in --max --context --depth --max-nodes --top --threshold "
76
77
  local vf_logs=" --stream --tail --grep "
@@ -93,6 +94,7 @@ _quilltap_complete() {
93
94
  case "$subcommand" in
94
95
  db) valued="$vf_global$vf_db" ;;
95
96
  docs) valued="$vf_global$vf_docs" ;;
97
+ sync) valued="$vf_global$vf_sync" ;;
96
98
  themes) valued="$vf_global$vf_themes" ;;
97
99
  memories) valued="$vf_memories" ;;
98
100
  logs) valued="$vf_global$vf_logs" ;;
@@ -197,6 +199,14 @@ _quilltap_complete() {
197
199
  COMPREPLY=($(compgen -W "args json" -- "$cur"))
198
200
  return
199
201
  ;;
202
+ --direction)
203
+ COMPREPLY=($(compgen -W "both to-disk to-store" -- "$cur"))
204
+ return
205
+ ;;
206
+ --prefer)
207
+ COMPREPLY=($(compgen -W "newer store disk" -- "$cur"))
208
+ return
209
+ ;;
200
210
  esac
201
211
 
202
212
  # Subcommand-specific completion
@@ -241,6 +251,18 @@ _quilltap_complete() {
241
251
  _quilltap_docs_positional "$subverb" "$positional_count" "$cur"
242
252
  fi
243
253
  ;;
254
+ sync)
255
+ local sync_flags="--dry-run --direction --prefer --no-delete --no-manifest \
256
+ --json --instance --data-dir --passphrase --port --help"
257
+ if [[ "$cur" == -* ]]; then
258
+ COMPREPLY=($(compgen -W "$sync_flags" -- "$cur"))
259
+ elif [[ "$positional_count" == "1" ]]; then
260
+ _quilltap_lines_compreply "$cur" \
261
+ <<< "$(command quilltap docs list --names-only "${ctx_flags[@]}" 2>/dev/null)"
262
+ else
263
+ COMPREPLY=($(compgen -d -- "$cur"))
264
+ fi
265
+ ;;
244
266
  themes)
245
267
  local themes_verbs="list install uninstall validate export create search update registry"
246
268
  local themes_flags="--instance --data-dir --output -o --help"
@@ -19,7 +19,7 @@ function __quilltap_no_subcommand
19
19
  end
20
20
  for i in (seq 2 (count $cmd))
21
21
  switch $cmd[$i]
22
- case db docs themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion
22
+ case db docs sync themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion
23
23
  return 1
24
24
  end
25
25
  end
@@ -31,7 +31,7 @@ function __quilltap_using_subcommand
31
31
  set -l cmd (commandline -opc)
32
32
  for i in (seq 2 (count $cmd))
33
33
  switch $cmd[$i]
34
- case db docs themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion
34
+ case db docs sync themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion
35
35
  test "$cmd[$i]" = "$target"
36
36
  return $status
37
37
  end
@@ -55,6 +55,7 @@ end
55
55
 
56
56
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'db' -d 'Query encrypted databases'
57
57
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'docs' -d 'Inspect and read document mounts'
58
+ complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'sync' -d 'Mirror a database-backed store to a directory'
58
59
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'themes' -d 'Manage theme bundles'
59
60
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'instances' -d 'Register or inspect instances'
60
61
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'memories' -d 'Search and browse memories'
@@ -188,6 +189,22 @@ complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'uri' -d 'Show can
188
189
  complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'base64' -d 'Base64 transfer for binary files'
189
190
  complete -c quilltap -n '__quilltap_using_subverb docs docker-mounts' -l 'format' -d 'Output shape' -x -a 'args json'
190
191
 
192
+ # ---------- sync ----------
193
+ # `quilltap sync <store> <path>`: the first positional is a store, the second a
194
+ # local directory — so the verb is not -f (file completion stays available).
195
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -f -a '(__quilltap_mount_names)' -d 'Document store'
196
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'dry-run' -d 'Plan and print; change nothing'
197
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'direction' -d 'Which side may change' -x -a 'both to-disk to-store'
198
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'prefer' -d 'How to resolve a conflict' -x -a 'newer store disk'
199
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'no-delete' -d 'Never propagate a deletion'
200
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'no-manifest' -d 'Ignore .quilltap-sync.json'
201
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'json' -d 'Machine-readable plan and results'
202
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'port' -s 'p' -d 'Server port' -x
203
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'instance' -s 'i' -d 'Registered instance' -x -a '(__quilltap_instance_names)'
204
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'data-dir' -s 'd' -d 'Data directory' -x -a '(__fish_complete_directories)'
205
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'passphrase' -d 'Database passphrase' -x
206
+ complete -c quilltap -n '__quilltap_using_subcommand sync' -l 'help' -s 'h' -d 'Show help'
207
+
191
208
  # ---------- themes verbs ----------
192
209
  complete -c quilltap -n '__quilltap_using_subcommand themes' -f -a 'list' -d 'List themes'
193
210
  complete -c quilltap -n '__quilltap_using_subcommand themes' -f -a 'install' -d 'Install theme'
@@ -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
@@ -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 };
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Rendering for `quilltap sync`.
5
+ *
6
+ * Deliberately pure — actions and a colour switch in, lines out — so the
7
+ * report's shape can be tested without a server, a store, or a directory, the
8
+ * way `docker-mounts` is.
9
+ *
10
+ * ## The shape of a line
11
+ *
12
+ * modify store chapters/03.md (disk newer by 2h 14m)
13
+ * ^ ^ ^ ^
14
+ * action side path why
15
+ *
16
+ * The first two columns are fixed width so the paths line up under each other,
17
+ * because the thing an operator actually reads down is the path column. The
18
+ * side is the side that CHANGES: `modify store` means the store is rewritten
19
+ * from disk, which is the opposite of the intuition some people bring and so
20
+ * is worth being unambiguous about.
21
+ *
22
+ * Advisory text — warnings, the summary — goes to stderr in the caller, so
23
+ * stdout stays greppable.
24
+ *
25
+ * @module sync-report
26
+ */
27
+
28
+ const RESET = '\x1b[0m';
29
+ const DIM = '\x1b[2m';
30
+ const GREEN = '\x1b[32m';
31
+ const RED = '\x1b[31m';
32
+ const YELLOW = '\x1b[33m';
33
+
34
+ /** Column widths. `conflict` is the longest action word at 8. */
35
+ const ACTION_WIDTH = 8;
36
+ const SIDE_WIDTH = 6;
37
+
38
+ /** Which colour an action's line takes. */
39
+ const ACTION_COLOURS = {
40
+ create: GREEN,
41
+ mkdir: GREEN,
42
+ modify: GREEN,
43
+ describe: GREEN,
44
+ delete: YELLOW,
45
+ rmdir: YELLOW,
46
+ touch: DIM,
47
+ skip: DIM,
48
+ conflict: RED,
49
+ };
50
+
51
+ function pad(text, width) {
52
+ return text.length >= width ? text : text + ' '.repeat(width - text.length);
53
+ }
54
+
55
+ function formatBytes(n) {
56
+ if (n === undefined || n === null) return '';
57
+ if (n < 1024) return `${n} B`;
58
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
59
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
60
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
61
+ }
62
+
63
+ /**
64
+ * The parenthetical after a path: whatever the engine said, plus the sha and
65
+ * size on a line that put bytes somewhere.
66
+ */
67
+ function detailFor(action) {
68
+ const parts = [];
69
+ if (action.outcome === 'failed' && action.error) {
70
+ parts.push(`FAILED: ${action.error}`);
71
+ } else if (action.reason) {
72
+ parts.push(action.reason);
73
+ }
74
+ if ((action.kind === 'create' || action.kind === 'modify') && action.sha256) {
75
+ const size = formatBytes(action.sizeBytes);
76
+ parts.push(`sha ${action.sha256.slice(0, 4)}…${size ? `, ${size}` : ''}`);
77
+ }
78
+ return parts.join('; ');
79
+ }
80
+
81
+ /**
82
+ * A trailing slash marks a folder, so `drafts/` and a file called `drafts`
83
+ * are not the same line.
84
+ */
85
+ function displayPath(action) {
86
+ return action.entryKind === 'folder' ? `${action.relativePath}/` : action.relativePath;
87
+ }
88
+
89
+ /**
90
+ * One line per action.
91
+ *
92
+ * `pathWidth` aligns the parenthetical into a column of its own; the caller
93
+ * computes it once across the whole plan, so a single very long path does not
94
+ * push every other line's detail off the screen — it is capped.
95
+ */
96
+ function formatActionLine(action, colour, pathWidth = 0) {
97
+ const tint = colour
98
+ ? (action.outcome === 'failed' ? RED : ACTION_COLOURS[action.kind] || '')
99
+ : '';
100
+ const reset = tint ? RESET : '';
101
+ const side = action.side || '—';
102
+ const detail = detailFor(action);
103
+ const shownPath = detail ? pad(displayPath(action), pathWidth) : displayPath(action);
104
+ const head = `${tint}${pad(action.kind, ACTION_WIDTH)}${reset} ${pad(side, SIDE_WIDTH)} ${shownPath}`;
105
+ if (!detail) return head;
106
+ return colour ? `${head} ${DIM}(${detail})${RESET}` : `${head} (${detail})`;
107
+ }
108
+
109
+ /** Longest path that carries a detail, capped so one outlier cannot ruin the column. */
110
+ const MAX_PATH_COLUMN = 44;
111
+
112
+ function formatActionLines(actions, colour) {
113
+ const pathWidth = Math.min(
114
+ MAX_PATH_COLUMN,
115
+ actions.reduce(
116
+ (widest, action) => (detailFor(action) ? Math.max(widest, displayPath(action).length) : widest),
117
+ 0
118
+ )
119
+ );
120
+ return actions.map((action) => formatActionLine(action, colour, pathWidth));
121
+ }
122
+
123
+ /** `3 created, 1 modified, … — 0.8 s`, or a plain "nothing to do". */
124
+ function formatSummary(summary, elapsedMs, dryRun) {
125
+ const bits = [];
126
+ if (summary.created) bits.push(`${summary.created} created`);
127
+ if (summary.modified) bits.push(`${summary.modified} modified`);
128
+ if (summary.deleted) bits.push(`${summary.deleted} deleted`);
129
+ if (summary.touched) bits.push(`${summary.touched} touched`);
130
+ if (summary.described) bits.push(`${summary.described} described`);
131
+ if (summary.skipped) bits.push(`${summary.skipped} skipped`);
132
+ if (summary.conflicts) bits.push(`${summary.conflicts} conflict${summary.conflicts === 1 ? '' : 's'}`);
133
+ if (summary.failed) bits.push(`${summary.failed} failed`);
134
+
135
+ const seconds = `${(elapsedMs / 1000).toFixed(1)} s`;
136
+ const prefix = dryRun ? 'Would do: ' : '';
137
+ if (bits.length === 0) {
138
+ return dryRun ? `Nothing to do — ${seconds}` : `Already in step — ${seconds}`;
139
+ }
140
+ return `${prefix}${bits.join(', ')} — ${seconds}`;
141
+ }
142
+
143
+ /**
144
+ * The process exit code a report earns.
145
+ *
146
+ * 0 clean
147
+ * 1 something failed outright
148
+ * 2 at least one conflict is still unresolved
149
+ *
150
+ * `--dry-run` uses the same codes, so a script can gate on a clean plan before
151
+ * it lets a real run proceed.
152
+ */
153
+ function exitCodeFor(summary) {
154
+ if (summary.failed > 0) return 1;
155
+ if (summary.conflicts > 0) return 2;
156
+ return 0;
157
+ }
158
+
159
+ module.exports = {
160
+ formatActionLine,
161
+ formatActionLines,
162
+ formatSummary,
163
+ exitCodeFor,
164
+ detailFor,
165
+ displayPath,
166
+ formatBytes,
167
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.10.0-dev.51",
3
+ "version": "4.10.0-dev.57",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",