quilltap 4.10.0-dev.9 → 4.10.0-dev.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -7
- package/bin/quilltap.js +43 -25
- package/lib/__tests__/completion-coverage.test.js +1 -0
- package/lib/__tests__/sync-report.test.js +158 -0
- package/lib/completion/bash.template +23 -1
- package/lib/completion/fish.template +19 -2
- package/lib/completion/zsh.template +36 -0
- package/lib/db-commands.js +20 -0
- package/lib/db-helpers.js +12 -0
- package/lib/native-modules.js +102 -1
- package/lib/sync-command.js +323 -0
- package/lib/sync-report.js +167 -0
- package/lib/text-codec.js +61 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Text BLOB codec — the CLI's reader for compressed text columns.
|
|
5
|
+
*
|
|
6
|
+
* MIRROR OF `lib/database/text-compression.ts` in the server repo. The CLI is
|
|
7
|
+
* published independently and cannot import from the app, so the format is
|
|
8
|
+
* duplicated here deliberately. **If the header or codec changes there, change
|
|
9
|
+
* it here in the same commit** — a stale copy shows the user mojibake.
|
|
10
|
+
*
|
|
11
|
+
* Format (see the server module for the reasoning):
|
|
12
|
+
* [0] magic 0x51 ('Q') [1] version 0x01 [2] codec 0x01 (brotli) [3..] payload
|
|
13
|
+
*
|
|
14
|
+
* Values under 512 bytes are stored as plain TEXT, so a column holds a mix of
|
|
15
|
+
* compressed BLOBs and plain strings. `decodeText` accepts both, plus NULL and
|
|
16
|
+
* uncompressed buffers, and never throws.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const zlib = require('zlib');
|
|
20
|
+
|
|
21
|
+
const TEXT_BLOB_MAGIC = 0x51;
|
|
22
|
+
const TEXT_BLOB_VERSION = 0x01;
|
|
23
|
+
const TEXT_CODEC_BROTLI = 0x01;
|
|
24
|
+
const TEXT_BLOB_HEADER_BYTES = 3;
|
|
25
|
+
|
|
26
|
+
/** Does this value carry the compressed-text header? */
|
|
27
|
+
function isCompressedTextBlob(value) {
|
|
28
|
+
if (!Buffer.isBuffer(value) || value.length < TEXT_BLOB_HEADER_BYTES) return false;
|
|
29
|
+
return (
|
|
30
|
+
value[0] === TEXT_BLOB_MAGIC &&
|
|
31
|
+
value[1] === TEXT_BLOB_VERSION &&
|
|
32
|
+
value[2] === TEXT_CODEC_BROTLI
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Decode a stored value to text. Total: never throws, never returns undefined. */
|
|
37
|
+
function decodeText(value) {
|
|
38
|
+
if (value === null || value === undefined) return null;
|
|
39
|
+
if (typeof value === 'string') return value;
|
|
40
|
+
if (!Buffer.isBuffer(value)) return String(value);
|
|
41
|
+
if (!isCompressedTextBlob(value)) return value.toString('utf-8');
|
|
42
|
+
|
|
43
|
+
const payload = value.subarray(TEXT_BLOB_HEADER_BYTES);
|
|
44
|
+
try {
|
|
45
|
+
return zlib.brotliDecompressSync(payload).toString('utf-8');
|
|
46
|
+
} catch {
|
|
47
|
+
return payload.toString('utf-8');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register `qt_text()` on a connection, so raw SQL (`quilltap db "SELECT …"`,
|
|
53
|
+
* `--repl`) can read inside a compressed column:
|
|
54
|
+
*
|
|
55
|
+
* SELECT json_extract(qt_text(response), '$.error') FROM llm_logs;
|
|
56
|
+
*/
|
|
57
|
+
function registerTextCodecFunction(db) {
|
|
58
|
+
db.function('qt_text', { deterministic: true }, (value) => decodeText(value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { decodeText, isCompressedTextBlob, registerTextCodecFunction };
|