quilltap 4.10.0-dev.51 → 4.10.0-dev.54
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 +27 -0
- package/bin/quilltap.js +8 -1
- 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/sync-command.js +323 -0
- package/lib/sync-report.js +167 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -219,6 +219,33 @@ Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous na
|
|
|
219
219
|
|
|
220
220
|
`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
221
|
|
|
222
|
+
## Sync a Store to a Directory
|
|
223
|
+
|
|
224
|
+
`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.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
quilltap sync Lore ~/Documents/lore --dry-run # plan only
|
|
228
|
+
quilltap sync Lore ~/Documents/lore # apply
|
|
229
|
+
quilltap sync Lore ~/Documents/lore --prefer disk
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```text
|
|
233
|
+
--dry-run Plan and print; change nothing on either side
|
|
234
|
+
--direction <which> both (default), to-disk, or to-store
|
|
235
|
+
--prefer <which> newer (default), store, or disk — resolves conflicts
|
|
236
|
+
--no-delete Never propagate a deletion
|
|
237
|
+
--no-manifest Ignore .quilltap-sync.json (first-run rules every time)
|
|
238
|
+
--json Machine-readable plan and results
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
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. 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.
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
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, an archived character's vault, a store mid-conversion or mid-scan, and a manifest belonging to another store.
|
|
248
|
+
|
|
222
249
|
## Memories
|
|
223
250
|
|
|
224
251
|
`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.
|
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
|
+
};
|