quilltap 4.9.0-dev → 4.9.0-dev.102
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 +5 -1
- package/bin/quilltap.js +42 -18
- package/lib/__tests__/completion-behavior.test.js +162 -0
- package/lib/__tests__/completion-coverage.test.js +95 -0
- package/lib/__tests__/dbkey-restore.test.js +162 -0
- package/lib/completion/bash.template +118 -26
- package/lib/completion/fish.template +18 -2
- package/lib/completion/zsh.template +340 -100
- package/lib/db-helpers.js +9 -32
- package/lib/dbkey-restore.js +347 -0
- package/lib/dbkey.js +188 -0
- package/lib/instances-commands.js +48 -0
- package/lib/instances.js +6 -30
- package/lib/lock-helpers.js +2 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -182,6 +182,7 @@ quilltap docs export <mount> <outputDir> # Mount → directory
|
|
|
182
182
|
quilltap docs find <pattern> # Substring match on file names (--mount, --ext, --type, --limit)
|
|
183
183
|
quilltap docs grep <pattern> # Substring match on extracted text (--mount, --ignore-case, -l, --max, --context)
|
|
184
184
|
quilltap docs status # Per-mount extraction + embedding rollup (--mount, --top)
|
|
185
|
+
quilltap docs docker-mounts # Bind mounts filesystem stores need under Docker (--format args|json)
|
|
185
186
|
|
|
186
187
|
# Server-required
|
|
187
188
|
quilltap docs scan <mount> # Trigger a rescan
|
|
@@ -391,8 +392,11 @@ Fish picks new completion files up automatically — no shell restart needed.
|
|
|
391
392
|
|
|
392
393
|
- **Subcommands**: `quilltap d<TAB>` → `db docs`
|
|
393
394
|
- **Sub-verbs per namespace**: `quilltap db s<TAB>` → `schema show`
|
|
395
|
+
- **Flags per verb**: `quilltap docs docker-mounts --<TAB>` → the flags that verb accepts
|
|
394
396
|
- **Instance names**: `quilltap --instance Fr<TAB>` → registered instances
|
|
395
|
-
- **Mount names**: `quilltap docs ls
|
|
397
|
+
- **Mount names**: both `--mount` and the positional a verb takes — `quilltap docs ls Qu<TAB>`, and either end of `docs move`/`copy`/`link`
|
|
398
|
+
|
|
399
|
+
Completions **parse the line rather than counting words**, so a flag typed anywhere the CLI itself accepts one does not derail them: `quilltap docs --instance Friday <TAB>` still offers the `docs` verbs. bash and zsh also reuse the `-i` / `-d` / `--passphrase` already on the line when looking store names up, so the names offered come from the instance you are addressing rather than the default one. fish completes `--mount` but not the positionals, and always reads the default instance.
|
|
396
400
|
|
|
397
401
|
Dynamic completions shell out to `quilltap`'s own subcommands. If the active instance is encrypted and no passphrase is reachable, the completion silently returns nothing rather than prompting in the middle of a tab.
|
|
398
402
|
|
package/bin/quilltap.js
CHANGED
|
@@ -264,23 +264,47 @@ function linkNativeModules(standaloneDir) {
|
|
|
264
264
|
const sharpDir = resolveModuleDir('sharp');
|
|
265
265
|
linkModule('sharp', sharpDir);
|
|
266
266
|
|
|
267
|
-
// Link
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
267
|
+
// Link a scoped native's platform-specific siblings (@img/sharp-*,
|
|
268
|
+
// @napi-rs/canvas-*). Both wrappers require their binary as a SCOPE-SIBLING
|
|
269
|
+
// — `@img/sharp-darwin-arm64` from inside `@img/sharp` — so the binary has to
|
|
270
|
+
// sit in the same node_modules the wrapper was resolved from. It cannot be
|
|
271
|
+
// inherited: the standalone tree lives in the download cache
|
|
272
|
+
// (~/Library/Caches/Quilltap/standalone and friends), far outside this
|
|
273
|
+
// package's node_modules, so Node's upward walk never reaches the copy npm
|
|
274
|
+
// installed for us. Linking the wrapper without its siblings therefore
|
|
275
|
+
// produces a wrapper that resolves and then throws at first use.
|
|
276
|
+
function linkScopedPlatformSiblings(scope, prefix, wrapperName, wrapperDir) {
|
|
277
|
+
if (!wrapperDir) return;
|
|
278
|
+
// Walk back exactly as many segments as the wrapper's own name has, so this
|
|
279
|
+
// works whether the wrapper is unscoped ('sharp' -> one) or scoped
|
|
280
|
+
// ('@napi-rs/canvas' -> two). Counting fixed levels would silently resolve
|
|
281
|
+
// one directory too high for sharp and miss @img entirely.
|
|
282
|
+
let nodeModulesDir = wrapperDir;
|
|
283
|
+
for (let i = 0; i < wrapperName.split('/').length; i++) {
|
|
284
|
+
nodeModulesDir = path.dirname(nodeModulesDir);
|
|
285
|
+
}
|
|
286
|
+
const scopeDir = path.join(nodeModulesDir, scope);
|
|
287
|
+
if (!fs.existsSync(scopeDir)) return;
|
|
288
|
+
try {
|
|
289
|
+
for (const name of fs.readdirSync(scopeDir)) {
|
|
290
|
+
if (!name.startsWith(prefix)) continue;
|
|
291
|
+
linkModule(`${scope}/${name}`, path.join(scopeDir, name));
|
|
281
292
|
}
|
|
293
|
+
} catch {
|
|
294
|
+
// Non-fatal — the wrapper may still find a binary by another route.
|
|
282
295
|
}
|
|
283
296
|
}
|
|
297
|
+
|
|
298
|
+
linkScopedPlatformSiblings('@img', 'sharp-', 'sharp', sharpDir);
|
|
299
|
+
|
|
300
|
+
// Link @napi-rs/canvas (pdfjs-dist's PDF rasteriser) the same way sharp is
|
|
301
|
+
// handled: build-standalone-tarball.mjs strips every @napi-rs/canvas-* binary
|
|
302
|
+
// from the tarball, so without this the standalone tree keeps only the JS
|
|
303
|
+
// wrapper and PDF rendering dies on a missing native. The wrapper is scoped,
|
|
304
|
+
// so it needs its siblings linked alongside it.
|
|
305
|
+
const canvasDir = resolveModuleDir('@napi-rs/canvas');
|
|
306
|
+
linkModule('@napi-rs/canvas', canvasDir);
|
|
307
|
+
linkScopedPlatformSiblings('@napi-rs', 'canvas-', '@napi-rs/canvas', canvasDir);
|
|
284
308
|
}
|
|
285
309
|
|
|
286
310
|
// Main
|
|
@@ -505,8 +529,8 @@ function handleLockCommand(dataDir, opts) {
|
|
|
505
529
|
} else if (alive && !isNode) {
|
|
506
530
|
status = '\x1b[33mSUSPECT\x1b[0m (PID alive but does not look like Quilltap — possible PID reuse)';
|
|
507
531
|
} else if (!sameHost) {
|
|
508
|
-
// Different hostname — could be a
|
|
509
|
-
const isVMOrContainer =
|
|
532
|
+
// Different hostname — could be a container on this machine
|
|
533
|
+
const isVMOrContainer = lock.environment === 'docker';
|
|
510
534
|
const heartbeatAgeMs = lock.lastHeartbeat
|
|
511
535
|
? Date.now() - new Date(lock.lastHeartbeat).getTime()
|
|
512
536
|
: Infinity;
|
|
@@ -589,8 +613,8 @@ function handleLockCommand(dataDir, opts) {
|
|
|
589
613
|
console.log('This is likely a stale lock with a reused PID. Removing.');
|
|
590
614
|
}
|
|
591
615
|
} else if (!sameHost) {
|
|
592
|
-
// Different hostname — check if it's a
|
|
593
|
-
const isVMOrContainer =
|
|
616
|
+
// Different hostname — check if it's a container with a recent heartbeat
|
|
617
|
+
const isVMOrContainer = lock.environment === 'docker';
|
|
594
618
|
const heartbeatAgeMs = lock.lastHeartbeat
|
|
595
619
|
? Date.now() - new Date(lock.lastHeartbeat).getTime()
|
|
596
620
|
: Infinity;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioural guard for the shell-completion templates.
|
|
3
|
+
*
|
|
4
|
+
* The static coverage test next door proves every subcommand is *mentioned*.
|
|
5
|
+
* This one proves the completions still fire once flags are on the line — the
|
|
6
|
+
* failure the templates actually shipped with: `quilltap docs --instance
|
|
7
|
+
* Friday <TAB>` offered nothing, because the verb was looked up by counting
|
|
8
|
+
* words rather than by parsing them.
|
|
9
|
+
*
|
|
10
|
+
* Bash is driven for real (source the script, set COMP_WORDS/COMP_CWORD, read
|
|
11
|
+
* COMPREPLY back). Zsh's completion system can only be driven from inside a
|
|
12
|
+
* completion widget, so its template is checked structurally instead.
|
|
13
|
+
*
|
|
14
|
+
* The one zsh assertion that needs a real `zsh` — the parse check — skips
|
|
15
|
+
* where the shell isn't installed. Bash is on every machine that runs this
|
|
16
|
+
* suite; zsh is not (GitHub's ubuntu runners ship without it, which is why
|
|
17
|
+
* CI's test job installs it before calling jest).
|
|
18
|
+
*
|
|
19
|
+
* @jest-environment node
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { execFileSync } = require('child_process');
|
|
28
|
+
|
|
29
|
+
const COMPLETION_DIR = path.join(__dirname, '..', 'completion');
|
|
30
|
+
|
|
31
|
+
/** A `quilltap` on PATH that answers the completion lookups deterministically. */
|
|
32
|
+
function makeStubBin() {
|
|
33
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quilltap-completion-'));
|
|
34
|
+
const stub = path.join(dir, 'quilltap');
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
stub,
|
|
37
|
+
[
|
|
38
|
+
'#!/bin/sh',
|
|
39
|
+
'case "$*" in',
|
|
40
|
+
' *"instances list --names-only"*) printf "StubInstance\\n" ;;',
|
|
41
|
+
' *"docs list --names-only"*) printf "Stub Store\\nOther Store\\n" ;;',
|
|
42
|
+
'esac',
|
|
43
|
+
'exit 0',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n'),
|
|
46
|
+
{ mode: 0o755 }
|
|
47
|
+
);
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const STUB_BIN = makeStubBin();
|
|
52
|
+
afterAll(() => fs.rmSync(STUB_BIN, { recursive: true, force: true }));
|
|
53
|
+
|
|
54
|
+
/** Whether a real `zsh` exists to hand a script to. */
|
|
55
|
+
const HAS_ZSH = (() => {
|
|
56
|
+
try {
|
|
57
|
+
execFileSync('zsh', ['-c', ':'], { stdio: 'ignore' });
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Complete `line` with the bash template and return the candidate list.
|
|
66
|
+
* A trailing space means "start a new word", exactly as at a real prompt.
|
|
67
|
+
*/
|
|
68
|
+
function bashComplete(line) {
|
|
69
|
+
const script = `
|
|
70
|
+
source ${JSON.stringify(path.join(COMPLETION_DIR, 'bash.template'))}
|
|
71
|
+
COMP_LINE=${JSON.stringify(line)}
|
|
72
|
+
COMP_POINT=\${#COMP_LINE}
|
|
73
|
+
eval "COMP_WORDS=(\$COMP_LINE)"
|
|
74
|
+
[[ "\$COMP_LINE" =~ [[:space:]]$ ]] && COMP_WORDS+=("")
|
|
75
|
+
COMP_CWORD=\$(( \${#COMP_WORDS[@]} - 1 ))
|
|
76
|
+
_quilltap_complete
|
|
77
|
+
printf '%s\\n' "\${COMPREPLY[@]}"
|
|
78
|
+
`;
|
|
79
|
+
return execFileSync('bash', ['-c', script], {
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
env: { ...process.env, PATH: `${STUB_BIN}:${process.env.PATH}` },
|
|
82
|
+
})
|
|
83
|
+
.split('\n')
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('bash completion survives flags on the line', () => {
|
|
88
|
+
it('offers docs verbs with no flags', () => {
|
|
89
|
+
expect(bashComplete('quilltap docs ')).toContain('list');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
['an instance flag', 'quilltap docs --instance Friday '],
|
|
94
|
+
['a short instance flag', 'quilltap docs -i Friday '],
|
|
95
|
+
['a subcommand flag that takes a value', 'quilltap docs --limit 5 '],
|
|
96
|
+
['a valueless flag', 'quilltap docs --json '],
|
|
97
|
+
['flags on both sides', 'quilltap --instance Friday docs --json '],
|
|
98
|
+
])('still offers docs verbs after %s', (_label, line) => {
|
|
99
|
+
expect(bashComplete(line)).toContain('list');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('still offers db verbs after a flag', () => {
|
|
103
|
+
expect(bashComplete('quilltap db --limit 5 ')).toContain('characters');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('still offers db characters verbs after a flag', () => {
|
|
107
|
+
expect(bashComplete('quilltap db characters --instance Friday ')).toContain('status');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('treats -i as --ignore-case under memories, not --instance', () => {
|
|
111
|
+
const got = bashComplete('quilltap memories -i ');
|
|
112
|
+
expect(got).toContain('ls');
|
|
113
|
+
expect(got).not.toContain('StubInstance');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('bash completion looks up names against the addressed instance', () => {
|
|
118
|
+
it('completes --mount from the document stores', () => {
|
|
119
|
+
expect(bashComplete('quilltap docs --mount ')).toContain('Stub\\ Store');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('completes a store positional for verbs that take one', () => {
|
|
123
|
+
expect(bashComplete('quilltap docs ls ')).toContain('Stub\\ Store');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('completes the destination store of a move', () => {
|
|
127
|
+
expect(bashComplete('quilltap docs move Src a.md ')).toContain('Stub\\ Store');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not offer stores where the verb takes none', () => {
|
|
131
|
+
expect(bashComplete('quilltap docs find ')).not.toContain('Stub\\ Store');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('zsh completion parses positions instead of counting words', () => {
|
|
136
|
+
const tpl = fs.readFileSync(path.join(COMPLETION_DIR, 'zsh.template'), 'utf8');
|
|
137
|
+
|
|
138
|
+
it('has no hard-coded word-index tests', () => {
|
|
139
|
+
// `(( CURRENT == 2 ))` is the bug: it only holds when the verb sits
|
|
140
|
+
// immediately after the subcommand, so any preceding flag hides it.
|
|
141
|
+
expect(tpl).not.toMatch(/\(\(\s*CURRENT\s*==/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('stops the top-level _arguments swallowing flags typed after the subcommand', () => {
|
|
145
|
+
// Without the (-) prefixes the rest-argument array comes back empty and
|
|
146
|
+
// _quilltap_subcommand has nothing to dispatch on.
|
|
147
|
+
expect(tpl).toContain("'(-): :->subcommand'");
|
|
148
|
+
expect(tpl).toContain("'(-)*::arg:->args'");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('hands every subcommand verb to _arguments as a positional', () => {
|
|
152
|
+
const dispatchers = tpl.match(/'\(?-?\)?1: :->\w+'/g) || [];
|
|
153
|
+
expect(dispatchers.length).toBeGreaterThanOrEqual(6);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Needs the shell itself; skipped rather than failed where it is absent.
|
|
157
|
+
(HAS_ZSH ? it : it.skip)('is syntactically valid', () => {
|
|
158
|
+
const file = path.join(STUB_BIN, '_quilltap');
|
|
159
|
+
fs.writeFileSync(file, tpl);
|
|
160
|
+
expect(() => execFileSync('zsh', ['-n', file], { stdio: 'pipe' })).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -94,3 +94,98 @@ describe('every subcommand has its own completion arm', () => {
|
|
|
94
94
|
expect(noFlags).toEqual([]);
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The arm-per-subcommand check above is still too coarse: `docs docker-mounts`
|
|
100
|
+
* had its own arm in all three shells while `--format`, its only flag, was
|
|
101
|
+
* offered by none of them. A flag documented in a subcommand's own `--help` is
|
|
102
|
+
* the contract the user reads, so that text is the source of truth here —
|
|
103
|
+
* whatever `--help` advertises, the three templates must offer.
|
|
104
|
+
*
|
|
105
|
+
* Each entry names the single function whose template literal prints that
|
|
106
|
+
* subcommand's help.
|
|
107
|
+
*/
|
|
108
|
+
const HELP_SOURCES = {
|
|
109
|
+
db: ['bin/quilltap.js', 'printDbHelp'],
|
|
110
|
+
docs: ['lib/docs-commands.js', 'printDocsHelp'],
|
|
111
|
+
memories: ['lib/memories-commands.js', 'printMemoriesHelp'],
|
|
112
|
+
themes: ['lib/theme-commands.js', 'printHelp'],
|
|
113
|
+
instances: ['lib/instances-commands.js', 'printHelp'],
|
|
114
|
+
logs: ['lib/logs-commands.js', 'printLogsHelp'],
|
|
115
|
+
migrations: ['lib/migrations-commands.js', 'printHelp'],
|
|
116
|
+
maintenance: ['lib/maintenance-commands.js', 'printHelp'],
|
|
117
|
+
'file-verify': ['lib/file-verify-commands.js', 'printHelp'],
|
|
118
|
+
'memory-diff': ['lib/memory-diff-command.js', 'printMemoryDiffHelp'],
|
|
119
|
+
'recall-replay': ['lib/recall-replay-command.js', 'printRecallReplayHelp'],
|
|
120
|
+
completion: ['lib/completion-commands.js', 'printCompletionHelp'],
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const PKG_ROOT = path.join(__dirname, '..', '..');
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Long flags named anywhere in one subcommand's help text. The declaration
|
|
127
|
+
* pattern tolerates arbitrary whitespace and an `async`/parameter list, so
|
|
128
|
+
* reformatting a help function does not fail a test about its content.
|
|
129
|
+
*/
|
|
130
|
+
function flagsInHelp(relPath, fnName) {
|
|
131
|
+
const src = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8');
|
|
132
|
+
const decl = String.raw`(?:async\s+)?function\s+${fnName}\s*\([^)]*\)\s*\{`;
|
|
133
|
+
const body = src.match(new RegExp(`${decl}([\\s\\S]*?)\\n\\}`));
|
|
134
|
+
if (!body) throw new Error(`Could not locate ${fnName}() in ${relPath}`);
|
|
135
|
+
return [...new Set(body[1].match(/--[a-z0-9][a-z0-9-]+/g) || [])].sort();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `--max` is a prefix of `--max-nodes`, so a plain substring test passes for a
|
|
140
|
+
* flag that is not actually there. Require the match to end at a non-flag
|
|
141
|
+
* character.
|
|
142
|
+
*/
|
|
143
|
+
function mentionsFlag(haystack, flag) {
|
|
144
|
+
return new RegExp(`${flag}(?![a-z0-9-])`).test(haystack);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe('completions offer every flag the help text advertises', () => {
|
|
148
|
+
it('covers every subcommand in the dispatch table', () => {
|
|
149
|
+
// A new subcommand needs a help source here, or its flags go unchecked.
|
|
150
|
+
expect(Object.keys(HELP_SOURCES).sort()).toEqual([...SUBCOMMANDS].sort());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const cases = Object.entries(HELP_SOURCES).flatMap(([sub, [file, fn]]) =>
|
|
154
|
+
['bash', 'zsh', 'fish'].map((shell) => [sub, shell, file, fn])
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
it.each(cases)('%s: %s template offers every documented flag', (sub, shell, file, fn) => {
|
|
158
|
+
const tpl = fs.readFileSync(path.join(COMPLETION_DIR, `${shell}.template`), 'utf8');
|
|
159
|
+
// fish spells the flag `-l 'name'`, already an exact quoted token.
|
|
160
|
+
const present = (flag) =>
|
|
161
|
+
shell === 'fish' ? tpl.includes(`-l '${flag.slice(2)}'`) : mentionsFlag(tpl, flag);
|
|
162
|
+
const missing = flagsInHelp(file, fn).filter((flag) => !present(flag));
|
|
163
|
+
expect(missing).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* bash cannot infer which flags swallow the next word, so it carries explicit
|
|
169
|
+
* `vf_*` lists. A valued flag missing from its list makes the flag's value look
|
|
170
|
+
* like the subcommand's verb — the bug 101 failure mode. zsh and fish take the
|
|
171
|
+
* value from the flag's own spec, so only bash needs guarding.
|
|
172
|
+
*/
|
|
173
|
+
describe('bash knows which docs flags take a value', () => {
|
|
174
|
+
it('lists every valued docs flag in vf_docs', () => {
|
|
175
|
+
const tpl = fs.readFileSync(path.join(COMPLETION_DIR, 'bash.template'), 'utf8');
|
|
176
|
+
// The scanner reads `$vf_global$vf_docs`, so a flag in either list counts.
|
|
177
|
+
const vfGlobal = tpl.match(/local vf_global="([^"]*)"/);
|
|
178
|
+
const vfDocs = tpl.match(/local vf_docs="([^"]*)"/);
|
|
179
|
+
expect(vfGlobal).toBeTruthy();
|
|
180
|
+
expect(vfDocs).toBeTruthy();
|
|
181
|
+
const scanned = new Set(`${vfGlobal[1]} ${vfDocs[1]}`.trim().split(/\s+/));
|
|
182
|
+
// A docs flag zsh declares with a `:value:` spec is by definition valued.
|
|
183
|
+
const zsh = fs.readFileSync(path.join(COMPLETION_DIR, 'zsh.template'), 'utf8');
|
|
184
|
+
const docsOpts = zsh.match(/docs_opts=\(([\s\S]*?)\n \)/);
|
|
185
|
+
expect(docsOpts).toBeTruthy();
|
|
186
|
+
const valued = [...docsOpts[1].matchAll(/'(--[a-z0-9-]+)\[[^\]]*\]:[^']*'/g)].map((m) => m[1]);
|
|
187
|
+
expect(valued.length).toBeGreaterThan(5);
|
|
188
|
+
const missing = valued.filter((flag) => !scanned.has(flag));
|
|
189
|
+
expect(missing).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quilltap instances restore-key` — the guards that stand between an operator
|
|
3
|
+
* with a pepper and a `.dbkey` that would brick an instance.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing one is the pepper proof: a key file holding the WRONG
|
|
6
|
+
* pepper is worse than none at all, because the server unwraps it happily and
|
|
7
|
+
* then reports an intact database as corrupt. So the proof runs against real
|
|
8
|
+
* SQLCipher files, not the suite's `better-sqlite3` mock — the mock would open
|
|
9
|
+
* anything.
|
|
10
|
+
*
|
|
11
|
+
* @jest-environment node
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const PKG_ROOT = path.join(__dirname, '..', '..');
|
|
22
|
+
|
|
23
|
+
// The root jest config maps both driver names onto __mocks__/better-sqlite3.ts,
|
|
24
|
+
// which accepts any key. Point them back at the real binding by absolute path
|
|
25
|
+
// so a wrong pepper actually fails to decrypt.
|
|
26
|
+
//
|
|
27
|
+
// Two copies can back this: the per-package install used in local development,
|
|
28
|
+
// and — in CI, where only the root `npm ci` runs — the root alias, where
|
|
29
|
+
// package.json installs better-sqlite3-multiple-ciphers under the name
|
|
30
|
+
// `better-sqlite3`. Same fallback chain as the __tests__/unit/packages/quilltap
|
|
31
|
+
// integration suites.
|
|
32
|
+
jest.mock('better-sqlite3-multiple-ciphers', () => {
|
|
33
|
+
const join = require('path').join;
|
|
34
|
+
try {
|
|
35
|
+
return require(join(PKG_ROOT, 'node_modules', 'better-sqlite3-multiple-ciphers'));
|
|
36
|
+
} catch {
|
|
37
|
+
return require(join(PKG_ROOT, '..', '..', 'node_modules', 'better-sqlite3'));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const Database = require('better-sqlite3-multiple-ciphers');
|
|
42
|
+
const { provePepper, databaseState } = require('../dbkey-restore');
|
|
43
|
+
const {
|
|
44
|
+
INTERNAL_PASSPHRASE,
|
|
45
|
+
encryptDbKey,
|
|
46
|
+
decryptDbKey,
|
|
47
|
+
tryDecryptDbKey,
|
|
48
|
+
preserveExtraFields,
|
|
49
|
+
readDbKeyFile,
|
|
50
|
+
writeDbKeyFile,
|
|
51
|
+
} = require('../dbkey');
|
|
52
|
+
|
|
53
|
+
const PEPPER = crypto.randomBytes(32).toString('base64');
|
|
54
|
+
const OTHER_PEPPER = crypto.randomBytes(32).toString('base64');
|
|
55
|
+
|
|
56
|
+
let dataDir;
|
|
57
|
+
|
|
58
|
+
function seedEncryptedDb(filename, pepper) {
|
|
59
|
+
const db = new Database(path.join(dataDir, filename));
|
|
60
|
+
db.pragma(`key = "x'${Buffer.from(pepper, 'base64').toString('hex')}'"`);
|
|
61
|
+
db.exec("CREATE TABLE t (a TEXT); INSERT INTO t VALUES ('hello');");
|
|
62
|
+
db.close();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function seedPlaintextDb(filename) {
|
|
66
|
+
const db = new Database(path.join(dataDir, filename));
|
|
67
|
+
db.exec('CREATE TABLE t (a TEXT);');
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qtap-restore-'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('databaseState', () => {
|
|
80
|
+
it('reads the SQLite magic to tell plaintext from SQLCipher', () => {
|
|
81
|
+
seedPlaintextDb('plain.db');
|
|
82
|
+
seedEncryptedDb('enc.db', PEPPER);
|
|
83
|
+
|
|
84
|
+
expect(databaseState(path.join(dataDir, 'plain.db'))).toBe('plaintext');
|
|
85
|
+
expect(databaseState(path.join(dataDir, 'enc.db'))).toBe('encrypted');
|
|
86
|
+
expect(databaseState(path.join(dataDir, 'nope.db'))).toBe('absent');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('provePepper', () => {
|
|
91
|
+
it('proves the right pepper against every encrypted database', () => {
|
|
92
|
+
seedEncryptedDb('quilltap.db', PEPPER);
|
|
93
|
+
seedEncryptedDb('quilltap-llm-logs.db', PEPPER);
|
|
94
|
+
seedEncryptedDb('quilltap-mount-index.db', PEPPER);
|
|
95
|
+
|
|
96
|
+
const { proved, results } = provePepper(dataDir, PEPPER);
|
|
97
|
+
expect(proved).toBe(true);
|
|
98
|
+
expect(results.every((r) => r.ok === true)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('refuses a pepper that does not open the databases', () => {
|
|
102
|
+
seedEncryptedDb('quilltap.db', PEPPER);
|
|
103
|
+
|
|
104
|
+
const { proved, results } = provePepper(dataDir, OTHER_PEPPER);
|
|
105
|
+
expect(proved).toBe(false);
|
|
106
|
+
expect(results.find((r) => r.filename === 'quilltap.db').ok).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('reports one bad database among good ones rather than averaging it away', () => {
|
|
110
|
+
seedEncryptedDb('quilltap.db', PEPPER);
|
|
111
|
+
seedEncryptedDb('quilltap-llm-logs.db', OTHER_PEPPER);
|
|
112
|
+
|
|
113
|
+
const { proved, results } = provePepper(dataDir, PEPPER);
|
|
114
|
+
expect(proved).toBe(false);
|
|
115
|
+
expect(results.find((r) => r.filename === 'quilltap.db').ok).toBe(true);
|
|
116
|
+
expect(results.find((r) => r.filename === 'quilltap-llm-logs.db').ok).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('cannot prove anything when the databases are absent or still plaintext', () => {
|
|
120
|
+
expect(provePepper(dataDir, PEPPER).proved).toBe(false);
|
|
121
|
+
|
|
122
|
+
seedPlaintextDb('quilltap.db');
|
|
123
|
+
const { proved, results } = provePepper(dataDir, PEPPER);
|
|
124
|
+
expect(proved).toBe(false);
|
|
125
|
+
expect(results.find((r) => r.filename === 'quilltap.db')).toMatchObject({
|
|
126
|
+
state: 'plaintext',
|
|
127
|
+
ok: null,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('rewrapping a key file', () => {
|
|
133
|
+
it('round-trips the pepper under a new passphrase', () => {
|
|
134
|
+
writeDbKeyFile(dataDir, encryptDbKey(PEPPER, INTERNAL_PASSPHRASE));
|
|
135
|
+
expect(decryptDbKey(readDbKeyFile(dataDir), INTERNAL_PASSPHRASE)).toBe(PEPPER);
|
|
136
|
+
|
|
137
|
+
writeDbKeyFile(dataDir, encryptDbKey(PEPPER, 'the lamplighter'));
|
|
138
|
+
const rewrapped = readDbKeyFile(dataDir);
|
|
139
|
+
expect(tryDecryptDbKey(rewrapped, INTERNAL_PASSPHRASE)).toBeNull();
|
|
140
|
+
expect(decryptDbKey(rewrapped, 'the lamplighter')).toBe(PEPPER);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('carries fields the wrapping does not own across the rebuild', () => {
|
|
144
|
+
// `minServerVersion` is written by lib/startup/version-guard.ts for the
|
|
145
|
+
// Electron shell's pre-launch check. Dropping it on a rewrap would take
|
|
146
|
+
// the version floor with it.
|
|
147
|
+
const existing = encryptDbKey(PEPPER, INTERNAL_PASSPHRASE);
|
|
148
|
+
existing.minServerVersion = '4.9.0-dev.91';
|
|
149
|
+
|
|
150
|
+
const fresh = preserveExtraFields(existing, encryptDbKey(PEPPER, 'the lamplighter'));
|
|
151
|
+
|
|
152
|
+
expect(fresh.minServerVersion).toBe('4.9.0-dev.91');
|
|
153
|
+
expect(fresh.salt).not.toBe(existing.salt);
|
|
154
|
+
expect(decryptDbKey(fresh, 'the lamplighter')).toBe(PEPPER);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('writes the key file owner-only', () => {
|
|
158
|
+
writeDbKeyFile(dataDir, encryptDbKey(PEPPER, INTERNAL_PASSPHRASE));
|
|
159
|
+
const mode = fs.statSync(path.join(dataDir, 'quilltap.dbkey')).mode & 0o777;
|
|
160
|
+
expect(mode).toBe(0o600);
|
|
161
|
+
});
|
|
162
|
+
});
|