quilltap 4.5.0-dev → 4.6.0-dev

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.
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // Shared graph-integrity scanner used by `memories status` (per-holder
4
+ // rollup) and `memories validate` (read-only dangling-edge check).
5
+ //
6
+ // "Dangling edges" are UUIDs in `memories.relatedMemoryIds` that no longer
7
+ // resolve to a row in the `memories` table. Cross-character links are
8
+ // legitimate — a memory's links may point at memories owned by other holders
9
+ // — so the universe of valid IDs is *every* row, not just the holder's.
10
+
11
+ /**
12
+ * Scan dangling edges across the memories table.
13
+ *
14
+ * @param {import('better-sqlite3').Database} db Open readonly DB handle.
15
+ * @param {object} [opts]
16
+ * @param {string} [opts.characterId] Restrict the scan to one holder.
17
+ * When omitted, scans every memory.
18
+ * @param {boolean} [opts.includePairs] Return the per-source list of
19
+ * dangling target IDs (for --list).
20
+ * @returns {{
21
+ * nodes: number,
22
+ * withLinks: number,
23
+ * isolated: number,
24
+ * totalEdges: number,
25
+ * avgDegree: number,
26
+ * maxDegree: number,
27
+ * danglingEdges: number,
28
+ * danglingPairs?: Array<{ sourceId: string, characterId: string, targetIds: string[] }>
29
+ * }}
30
+ */
31
+ function scanDanglingEdges(db, opts = {}) {
32
+ const { characterId, includePairs = false } = opts;
33
+
34
+ // The valid set is *every* memory ID in the table. Computed once even
35
+ // when restricted to a single holder, because cross-character links are
36
+ // legitimate and we don't want them counted as dangling.
37
+ const allIds = new Set();
38
+ for (const row of db.prepare('SELECT id FROM memories').all()) {
39
+ allIds.add(row.id);
40
+ }
41
+
42
+ const scanRows = characterId
43
+ ? db.prepare('SELECT id, characterId, relatedMemoryIds FROM memories WHERE characterId = ?').all(characterId)
44
+ : db.prepare('SELECT id, characterId, relatedMemoryIds FROM memories').all();
45
+
46
+ let withLinks = 0;
47
+ let isolated = 0;
48
+ let totalEdges = 0;
49
+ let maxDegree = 0;
50
+ let danglingEdges = 0;
51
+ const danglingPairs = includePairs ? [] : undefined;
52
+
53
+ for (const row of scanRows) {
54
+ let arr;
55
+ try {
56
+ arr = JSON.parse(row.relatedMemoryIds || '[]');
57
+ } catch {
58
+ arr = [];
59
+ }
60
+ if (!Array.isArray(arr)) arr = [];
61
+
62
+ if (arr.length === 0) {
63
+ isolated++;
64
+ continue;
65
+ }
66
+
67
+ withLinks++;
68
+ totalEdges += arr.length;
69
+ if (arr.length > maxDegree) maxDegree = arr.length;
70
+
71
+ const danglingTargets = includePairs ? [] : null;
72
+ for (const linkedId of arr) {
73
+ if (!allIds.has(linkedId)) {
74
+ danglingEdges++;
75
+ if (danglingTargets) danglingTargets.push(linkedId);
76
+ }
77
+ }
78
+ if (includePairs && danglingTargets && danglingTargets.length > 0) {
79
+ danglingPairs.push({
80
+ sourceId: row.id,
81
+ characterId: row.characterId,
82
+ targetIds: danglingTargets,
83
+ });
84
+ }
85
+ }
86
+
87
+ const nodes = scanRows.length;
88
+ const avgDegree = withLinks > 0 ? totalEdges / withLinks : 0;
89
+
90
+ const result = {
91
+ nodes,
92
+ withLinks,
93
+ isolated,
94
+ totalEdges,
95
+ avgDegree: Number(avgDegree.toFixed(2)),
96
+ maxDegree,
97
+ danglingEdges,
98
+ };
99
+ if (includePairs) {
100
+ result.danglingPairs = danglingPairs;
101
+ }
102
+ return result;
103
+ }
104
+
105
+ module.exports = { scanDanglingEdges };
@@ -0,0 +1,342 @@
1
+ 'use strict';
2
+
3
+ // CLI entry-point for `quilltap instances <verb> [...]`.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const readline = require('readline');
8
+
9
+ const {
10
+ getInstancesPath,
11
+ listInstances,
12
+ readInstances,
13
+ resolveInstance,
14
+ upsertInstance,
15
+ removeInstance,
16
+ setInstancePassphrase,
17
+ setDefaultInstance,
18
+ clearDefaultInstance,
19
+ getDefaultInstance,
20
+ renameInstance,
21
+ verifyPassphrase,
22
+ expandPath,
23
+ } = require('./instances');
24
+ const { promptPassphrase } = require('./db-helpers');
25
+
26
+ function printHelp() {
27
+ console.log(`
28
+ Quilltap Instance Registry
29
+
30
+ Usage: quilltap instances <verb> [args]
31
+
32
+ Verbs:
33
+ list List registered instances (default)
34
+ show <name> Show one instance (passphrase status only)
35
+ path Print the path to instances.json
36
+ add <name> [<path>] Register an instance (prompts for missing path / passphrase)
37
+ remove <name> Forget an instance (alias: rm, delete)
38
+ set-passphrase <name> Change or clear the stored passphrase
39
+ default [<name>] Set/show/clear default instance
40
+ rename <old> <new> Rename an instance (preserves passphrase)
41
+ -h, --help This help
42
+
43
+ Storage: ~/Library/Application Support/Quilltap/instances.json on macOS,
44
+ ~/.quilltap/instances.json on Linux, %APPDATA%\\Quilltap\\instances.json on
45
+ Windows. The file is created with mode 0600 and refused to load if group or
46
+ other permissions are set.
47
+
48
+ Once an instance is registered, every CLI subcommand that accepts --data-dir
49
+ will also accept --instance <name>:
50
+
51
+ quilltap --instance Friday # start the server pointed at Friday
52
+ quilltap db --instance Ignite schema characters
53
+ quilltap docs --instance Lebanon list
54
+
55
+ Default Instance:
56
+ When no --instance or --data-dir is specified, the CLI uses the registered
57
+ default (if one is set), then QUILLTAP_DATA_DIR (if set), then the OS platform
58
+ default. Marked with * in list output.
59
+
60
+ quilltap instances default Friday # set Friday as the default
61
+ quilltap instances default --clear # clear the default
62
+ quilltap instances default # show the current default
63
+
64
+ Examples:
65
+ quilltap instances add Friday ~/iCloud/Quilltap/Friday
66
+ quilltap instances add Ignite ~/iCloud/Quilltap/Ignite # prompts for passphrase
67
+ quilltap instances set-passphrase Ignite # prompts hidden
68
+ quilltap instances remove Friday-External
69
+ quilltap instances rename Friday FridayDev
70
+ quilltap instances default Friday
71
+ quilltap instances list
72
+ `);
73
+ }
74
+
75
+ function promptLine(prompt) {
76
+ return new Promise((resolve) => {
77
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
78
+ rl.question(prompt, (answer) => {
79
+ rl.close();
80
+ resolve(answer);
81
+ });
82
+ });
83
+ }
84
+
85
+ async function promptHiddenWithConfirm(label) {
86
+ const first = await promptPassphrase(`${label}: `);
87
+ if (!first) return '';
88
+ const second = await promptPassphrase(`${label} (confirm): `);
89
+ if (first !== second) {
90
+ throw new Error('Passphrases did not match.');
91
+ }
92
+ return first;
93
+ }
94
+
95
+ function formatRow(name, instancePath, hasPassphrase, isDefault) {
96
+ const tag = hasPassphrase ? '[passphrase set]' : '[no passphrase]';
97
+ const marker = isDefault ? '*' : ' ';
98
+ return `${marker} ${name.padEnd(20)} ${tag.padEnd(18)} ${instancePath}`;
99
+ }
100
+
101
+ function cmdList(opts = {}) {
102
+ const entries = listInstances();
103
+ if (opts.namesOnly) {
104
+ // Hidden flag for completion: print one name per line
105
+ for (const entry of entries) {
106
+ console.log(entry.name);
107
+ }
108
+ return;
109
+ }
110
+ if (opts.json) {
111
+ console.log(JSON.stringify(entries, null, 2));
112
+ return;
113
+ }
114
+ console.log(`Instances file: ${getInstancesPath()}`);
115
+ if (entries.length === 0) {
116
+ console.log('No instances registered. Add one with `quilltap instances add <name>`.');
117
+ return;
118
+ }
119
+ console.log('');
120
+ console.log(`* ${'NAME'.padEnd(20)} ${'PASSPHRASE'.padEnd(18)} PATH`);
121
+ for (const entry of entries) {
122
+ console.log(formatRow(entry.name, entry.path, entry.hasPassphrase, entry.isDefault));
123
+ }
124
+ }
125
+
126
+ function cmdShow(name) {
127
+ if (!name) {
128
+ console.error('Usage: quilltap instances show <name>');
129
+ process.exit(1);
130
+ }
131
+ const inst = resolveInstance(name);
132
+ console.log(`Name: ${inst.name}`);
133
+ console.log(`Path: ${inst.path}`);
134
+ console.log(`Passphrase: ${inst.passphrase ? 'set' : 'not set'}`);
135
+
136
+ const dataDir = path.join(inst.path, 'data');
137
+ const dbkey = path.join(dataDir, 'quilltap.dbkey');
138
+ const mainDb = path.join(dataDir, 'quilltap.db');
139
+ console.log(`Data dir: ${dataDir}${fs.existsSync(dataDir) ? '' : ' (missing)'}`);
140
+ console.log(`.dbkey: ${fs.existsSync(dbkey) ? 'present' : 'missing'}`);
141
+ console.log(`quilltap.db: ${fs.existsSync(mainDb) ? 'present' : 'missing'}`);
142
+ }
143
+
144
+ function cmdPath() {
145
+ console.log(getInstancesPath());
146
+ }
147
+
148
+ async function cmdAdd(args) {
149
+ const [rawName, rawPath] = args;
150
+ let name = rawName;
151
+ let instancePath = rawPath;
152
+
153
+ if (!name) {
154
+ name = (await promptLine('Instance name: ')).trim();
155
+ if (!name) {
156
+ console.error('Aborted: no name provided.');
157
+ process.exit(1);
158
+ }
159
+ }
160
+ if (!instancePath) {
161
+ instancePath = (await promptLine(`Path for "${name}" (instance root, contains data/files/logs): `)).trim();
162
+ if (!instancePath) {
163
+ console.error('Aborted: no path provided.');
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ const expanded = expandPath(instancePath);
169
+ if (!fs.existsSync(expanded)) {
170
+ const proceed = (await promptLine(`Path "${expanded}" does not exist. Save anyway? [y/N] `)).trim().toLowerCase();
171
+ if (proceed !== 'y' && proceed !== 'yes') {
172
+ console.error('Aborted.');
173
+ process.exit(1);
174
+ }
175
+ }
176
+
177
+ const wantPass = (await promptLine('Record a passphrase for this instance? [y/N] ')).trim().toLowerCase();
178
+ let passphrase = '';
179
+ if (wantPass === 'y' || wantPass === 'yes') {
180
+ passphrase = await promptHiddenWithConfirm('Passphrase');
181
+ if (!passphrase) {
182
+ console.log('Empty passphrase — not recording one.');
183
+ } else {
184
+ const state = await verifyPassphrase(expanded, passphrase);
185
+ if (state === 'valid') {
186
+ console.log('Passphrase verified against .dbkey.');
187
+ } else if (state === 'wrong') {
188
+ console.error('Passphrase does not unlock this instance\'s .dbkey. Not saving.');
189
+ process.exit(1);
190
+ } else if (state === 'no-encryption') {
191
+ console.error('This instance\'s .dbkey does not require a passphrase. Not saving.');
192
+ process.exit(1);
193
+ } else if (state === 'no-dbkey') {
194
+ console.log('No .dbkey on disk yet — passphrase will be saved without verification.');
195
+ }
196
+ }
197
+ }
198
+
199
+ const key = upsertInstance(name, { instancePath, passphrase });
200
+ console.log(`Saved instance "${key}" → ${getInstancesPath()}`);
201
+ }
202
+
203
+ function cmdRemove(args) {
204
+ const [name] = args;
205
+ if (!name) {
206
+ console.error('Usage: quilltap instances remove <name>');
207
+ process.exit(1);
208
+ }
209
+ const key = removeInstance(name);
210
+ console.log(`Removed instance "${key}".`);
211
+ }
212
+
213
+ async function cmdSetPassphrase(args) {
214
+ const [name] = args;
215
+ if (!name) {
216
+ console.error('Usage: quilltap instances set-passphrase <name>');
217
+ process.exit(1);
218
+ }
219
+ const inst = resolveInstance(name);
220
+
221
+ const wantClear = (await promptLine('Clear stored passphrase? [y/N] ')).trim().toLowerCase();
222
+ if (wantClear === 'y' || wantClear === 'yes') {
223
+ setInstancePassphrase(inst.name, '');
224
+ console.log(`Cleared passphrase for "${inst.name}".`);
225
+ return;
226
+ }
227
+
228
+ const passphrase = await promptHiddenWithConfirm('New passphrase');
229
+ if (!passphrase) {
230
+ console.log('Empty passphrase — not changing the existing entry. Use the "clear" prompt above to remove it.');
231
+ return;
232
+ }
233
+
234
+ const state = await verifyPassphrase(inst.path, passphrase);
235
+ if (state === 'valid') {
236
+ console.log('Passphrase verified against .dbkey.');
237
+ } else if (state === 'wrong') {
238
+ console.error('Passphrase does not unlock this instance\'s .dbkey. Not saving.');
239
+ process.exit(1);
240
+ } else if (state === 'no-encryption') {
241
+ console.error('This instance\'s .dbkey does not require a passphrase. Not saving.');
242
+ process.exit(1);
243
+ } else if (state === 'no-dbkey') {
244
+ console.log('No .dbkey on disk yet — passphrase will be saved without verification.');
245
+ }
246
+
247
+ setInstancePassphrase(inst.name, passphrase);
248
+ console.log(`Updated passphrase for "${inst.name}".`);
249
+ }
250
+
251
+ function cmdDefault(args, opts = {}) {
252
+ if (args.length === 0) {
253
+ const current = getDefaultInstance();
254
+ if (opts.json) {
255
+ console.log(JSON.stringify({ defaultInstance: current }));
256
+ } else if (current) {
257
+ console.log(current);
258
+ } else {
259
+ console.log('(none)');
260
+ }
261
+ return;
262
+ }
263
+ const [name] = args;
264
+ if (name === '--clear') {
265
+ clearDefaultInstance();
266
+ console.log('Cleared default instance.');
267
+ return;
268
+ }
269
+ const key = setDefaultInstance(name);
270
+ console.log(`Set default instance to "${key}".`);
271
+ }
272
+
273
+ function cmdRename(args) {
274
+ if (args.length < 2) {
275
+ console.error('Usage: quilltap instances rename <old> <new>');
276
+ process.exit(1);
277
+ }
278
+ const [oldName, newName] = args;
279
+ const { oldKey, newKey } = renameInstance(oldName, newName);
280
+ console.log(`Renamed instance "${oldKey}" → "${newKey}".`);
281
+ }
282
+
283
+ async function instancesCommand(args) {
284
+ if (args.length === 0) {
285
+ cmdList();
286
+ return;
287
+ }
288
+ const verb = args[0];
289
+ const rest = args.slice(1);
290
+
291
+ try {
292
+ switch (verb) {
293
+ case '-h':
294
+ case '--help':
295
+ case 'help':
296
+ printHelp();
297
+ return;
298
+ case 'list':
299
+ case 'ls': {
300
+ const namesOnly = rest.includes('--names-only');
301
+ const json = rest.includes('--json');
302
+ cmdList({ namesOnly, json });
303
+ return;
304
+ }
305
+ case 'show':
306
+ cmdShow(rest[0]);
307
+ return;
308
+ case 'path':
309
+ case 'where':
310
+ cmdPath();
311
+ return;
312
+ case 'add':
313
+ case 'create':
314
+ await cmdAdd(rest);
315
+ return;
316
+ case 'remove':
317
+ case 'rm':
318
+ case 'delete':
319
+ cmdRemove(rest);
320
+ return;
321
+ case 'set-passphrase':
322
+ case 'passphrase':
323
+ await cmdSetPassphrase(rest);
324
+ return;
325
+ case 'default':
326
+ cmdDefault(rest);
327
+ return;
328
+ case 'rename':
329
+ cmdRename(rest);
330
+ return;
331
+ default:
332
+ console.error(`Unknown instances verb: ${verb}`);
333
+ console.error('Run "quilltap instances --help" for usage.');
334
+ process.exit(1);
335
+ }
336
+ } catch (err) {
337
+ console.error(`Error: ${err.message}`);
338
+ process.exit(1);
339
+ }
340
+ }
341
+
342
+ module.exports = { instancesCommand };