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.
@@ -13,7 +13,11 @@
13
13
 
14
14
  const path = require('path');
15
15
  const fs = require('fs');
16
- const { resolveDataDir, loadDbKey } = require('./db-helpers');
16
+ const {
17
+ resolveDataDirAndPassphrase,
18
+ printDefaultInstanceHint,
19
+ loadDbKey,
20
+ } = require('./db-helpers');
17
21
 
18
22
  const RESET = '\x1b[0m';
19
23
  const BOLD = '\x1b[1m';
@@ -39,6 +43,7 @@ so the comparison is non-destructive.
39
43
 
40
44
  Options:
41
45
  -d, --data-dir <path> Override data directory (instance root)
46
+ -i, --instance <name> Use a registered instance (see 'quilltap instances')
42
47
  --passphrase <pass> Decrypt .dbkey if peppered
43
48
  --port <number> Server port for API calls (default: 3000)
44
49
  --out <dir> Output directory (default: cwd)
@@ -58,6 +63,7 @@ Examples:
58
63
  function parseFlags(args) {
59
64
  const flags = {
60
65
  dataDir: '',
66
+ instance: '',
61
67
  passphrase: '',
62
68
  port: 3000,
63
69
  out: process.cwd(),
@@ -73,6 +79,10 @@ function parseFlags(args) {
73
79
  case '--data-dir':
74
80
  flags.dataDir = args[++i];
75
81
  break;
82
+ case '-i':
83
+ case '--instance':
84
+ flags.instance = args[++i];
85
+ break;
76
86
  case '--passphrase':
77
87
  flags.passphrase = args[++i];
78
88
  break;
@@ -128,7 +138,13 @@ function tryParseJsonColumn(value, fallback) {
128
138
  * columns are parsed; the binary `embedding` column is dropped from output.
129
139
  */
130
140
  async function readExistingMemories(flags, chatId) {
131
- const dataDir = resolveDataDir(flags.dataDir);
141
+ const resolved = resolveDataDirAndPassphrase({
142
+ dataDir: flags.dataDir,
143
+ instance: flags.instance,
144
+ passphrase: flags.passphrase,
145
+ });
146
+ printDefaultInstanceHint(resolved);
147
+ const { dataDir, passphrase } = resolved;
132
148
  const dbPath = path.join(dataDir, 'quilltap.db');
133
149
  if (!fs.existsSync(dbPath)) {
134
150
  console.error(`Database not found: ${dbPath}`);
@@ -137,7 +153,7 @@ async function readExistingMemories(flags, chatId) {
137
153
 
138
154
  let pepper;
139
155
  try {
140
- pepper = await loadDbKey(dataDir, flags.passphrase);
156
+ pepper = await loadDbKey(dataDir, passphrase);
141
157
  } catch (err) {
142
158
  console.error(`Error: ${err.message}`);
143
159
  process.exit(1);
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ resolveDataDirAndPassphrase,
7
+ printDefaultInstanceHint,
8
+ openMainDb,
9
+ loadDbKey,
10
+ } = require('./db-helpers');
11
+
12
+ // ---------- argument parsing ----------
13
+
14
+ function parseFlags(args) {
15
+ const flags = {
16
+ dataDir: '',
17
+ instance: '',
18
+ passphrase: '',
19
+ json: false,
20
+ help: false,
21
+ dryRun: false,
22
+ };
23
+ const positional = [];
24
+ let i = 0;
25
+ while (i < args.length) {
26
+ const a = args[i];
27
+ switch (a) {
28
+ case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
29
+ case '--instance': flags.instance = args[++i]; break;
30
+ case '--passphrase': flags.passphrase = args[++i]; break;
31
+ case '--json': flags.json = true; break;
32
+ case '-h': case '--help': flags.help = true; break;
33
+ case '--dry-run': flags.dryRun = true; break;
34
+ default:
35
+ if (!a.startsWith('-')) positional.push(a);
36
+ else console.error(`unknown flag: ${a}`);
37
+ break;
38
+ }
39
+ i++;
40
+ }
41
+ return { flags, positional };
42
+ }
43
+
44
+ // ---------- migration list parsing ----------
45
+
46
+ // Reads the registered migration list from migrations/scripts/index.ts and resolves each
47
+ // entry's real `id:` value by reading the imported source file. Heuristic camelCase→kebab
48
+ // conversion is unreliable (some migrations omit `-v1`, others use `-v2`), so we always
49
+ // trust the file.
50
+ function extractMigrationsFromSource() {
51
+ const scriptsDir = path.join(__dirname, '../../..', 'migrations/scripts');
52
+ const indexPath = path.join(scriptsDir, 'index.ts');
53
+ if (!fs.existsSync(indexPath)) {
54
+ return [];
55
+ }
56
+ const indexContent = fs.readFileSync(indexPath, 'utf-8');
57
+
58
+ // Pass 1: build name → { filePath, comment } from imports and preceding comments.
59
+ const lines = indexContent.split('\n');
60
+ const importInfo = {};
61
+ let lastComment = '';
62
+ for (const line of lines) {
63
+ if (line.match(/^\s*\/\/\s+/)) {
64
+ lastComment = line.replace(/^\s*\/\/\s+/, '').trim();
65
+ continue;
66
+ }
67
+ const importMatch = line.match(/import\s+\{\s*(\w+)\s*\}\s+from\s+['"]\.\/([^'"]+)['"]/);
68
+ if (importMatch) {
69
+ const [, name, filePath] = importMatch;
70
+ importInfo[name] = { filePath, comment: lastComment || '' };
71
+ lastComment = '';
72
+ } else if (line.trim() && !line.match(/^\s*\/\//) && !line.includes('import type')) {
73
+ // Reset comment if we hit a non-comment non-import line
74
+ if (!line.match(/^\s*$/)) lastComment = '';
75
+ }
76
+ }
77
+
78
+ // Pass 2: find the active migrations array section and collect names in order.
79
+ const arrayStart = indexContent.indexOf('export const migrations: Migration[] = [');
80
+ if (arrayStart === -1) return [];
81
+ const arrayEnd = indexContent.indexOf('];', arrayStart);
82
+ if (arrayEnd === -1) return [];
83
+ const arrayContent = indexContent.substring(arrayStart, arrayEnd);
84
+
85
+ const activeNames = arrayContent.match(/\b([a-zA-Z]\w*Migration)\b/g) || [];
86
+ const seen = new Set();
87
+
88
+ // Pass 3: for each active name, read the corresponding file and extract the real `id:`.
89
+ const migrations = [];
90
+ for (const name of activeNames) {
91
+ if (seen.has(name)) continue;
92
+ seen.add(name);
93
+ const info = importInfo[name];
94
+ if (!info) continue;
95
+ const filePath = path.join(scriptsDir, info.filePath + '.ts');
96
+ if (!fs.existsSync(filePath)) continue;
97
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
98
+ const idMatch = fileContent.match(/^\s*id:\s*['"]([^'"]+)['"]/m);
99
+ if (!idMatch) continue;
100
+ migrations.push({
101
+ id: idMatch[1],
102
+ description: info.comment || idMatch[1],
103
+ });
104
+ }
105
+
106
+ return migrations;
107
+ }
108
+
109
+ // ---------- help text ----------
110
+
111
+ function printHelp() {
112
+ console.log(`Usage: quilltap migrations <command> [options]
113
+
114
+ Commands:
115
+ status Show applied and pending migrations
116
+ pending List pending migrations
117
+ run --dry-run Simulate what would run on next startup
118
+
119
+ Options:
120
+ -d, --data-dir <path> Use a specific data directory
121
+ --instance <name> Use a named instance
122
+ --passphrase <pass> Provide passphrase (prompts if needed)
123
+ --json Output as JSON
124
+ -h, --help Show this help message
125
+
126
+ Examples:
127
+ quilltap migrations status
128
+ quilltap migrations pending --instance Friday
129
+ quilltap migrations run --dry-run --json
130
+ `);
131
+ }
132
+
133
+ // ---------- database access ----------
134
+
135
+ function getAppliedMigrations(db) {
136
+ try {
137
+ const rows = db.prepare(`
138
+ SELECT id, completedAt, quilltapVersion, itemsAffected, message
139
+ FROM migrations_state
140
+ ORDER BY completedAt ASC
141
+ `).all();
142
+ return rows || [];
143
+ } catch (err) {
144
+ // Table may not exist in brand-new instances
145
+ return [];
146
+ }
147
+ }
148
+
149
+ // ---------- command handlers ----------
150
+
151
+ async function openResolvedMainDb(flags) {
152
+ const resolved = resolveDataDirAndPassphrase(flags);
153
+ printDefaultInstanceHint(resolved);
154
+ const pepper = await loadDbKey(resolved.dataDir, resolved.passphrase);
155
+ return openMainDb(resolved.dataDir, pepper, { readonly: true });
156
+ }
157
+
158
+ async function handleStatus(flags) {
159
+ let db;
160
+ try {
161
+ db = await openResolvedMainDb(flags);
162
+ } catch (err) {
163
+ console.error(`Error opening database: ${err.message}`);
164
+ process.exit(1);
165
+ }
166
+
167
+ const applied = getAppliedMigrations(db);
168
+ const source = extractMigrationsFromSource();
169
+
170
+ const appliedIds = new Set(applied.map(m => m.id));
171
+ const sourceIds = new Set(source.map(m => m.id));
172
+ const pending = source.filter(m => !appliedIds.has(m.id));
173
+ const retired = applied.filter(m => !sourceIds.has(m.id));
174
+
175
+ if (flags.json) {
176
+ console.log(JSON.stringify({
177
+ sourceTotal: source.length,
178
+ recordedApplied: applied.length,
179
+ pending: pending.length,
180
+ retired: retired.length,
181
+ pendingList: pending,
182
+ retiredList: retired.map(m => ({ id: m.id, completedAt: m.completedAt })),
183
+ mostRecent: applied.length > 0 ? applied[applied.length - 1] : null,
184
+ }, null, 2));
185
+ } else {
186
+ console.log(`Migrations in source: ${source.length}`);
187
+ console.log(`Recorded as applied: ${applied.length}` +
188
+ (retired.length > 0 ? ` (${retired.length} retired from active list)` : ''));
189
+ console.log(`Not yet recorded: ${pending.length}` +
190
+ (pending.length > 0 ? ' (may include migrations whose shouldRun() returns false on this instance)' : ''));
191
+ if (applied.length > 0) {
192
+ const latest = applied[applied.length - 1];
193
+ console.log(`Most recent applied: ${latest.id} at ${latest.completedAt}`);
194
+ }
195
+ if (pending.length > 0) {
196
+ console.log('');
197
+ console.log('Not yet recorded as applied:');
198
+ pending.forEach(m => {
199
+ console.log(` ${m.id.padEnd(50)} ${m.description}`);
200
+ });
201
+ }
202
+ }
203
+
204
+ db.close();
205
+ }
206
+
207
+ async function handlePending(flags) {
208
+ let db;
209
+ try {
210
+ db = await openResolvedMainDb(flags);
211
+ } catch (err) {
212
+ console.error(`Error opening database: ${err.message}`);
213
+ process.exit(1);
214
+ }
215
+
216
+ const applied = getAppliedMigrations(db);
217
+ const source = extractMigrationsFromSource();
218
+
219
+ const appliedIds = new Set(applied.map(m => m.id));
220
+ const pending = source.filter(m => !appliedIds.has(m.id));
221
+
222
+ if (flags.json) {
223
+ console.log(JSON.stringify(pending, null, 2));
224
+ } else {
225
+ if (pending.length === 0) {
226
+ console.log('No pending migrations.');
227
+ } else {
228
+ pending.forEach(m => {
229
+ console.log(`${m.id.padEnd(50)} ${m.description}`);
230
+ });
231
+ }
232
+ }
233
+
234
+ db.close();
235
+ }
236
+
237
+ async function handleRun(flags) {
238
+ if (!flags.dryRun) {
239
+ console.error('Error: migrations run requires --dry-run flag.');
240
+ console.error('Actual migration execution happens at server startup, where the loading screen');
241
+ console.error('and progress reporting are available. To see what would run on the next startup,');
242
+ console.error('use: quilltap migrations run --dry-run');
243
+ process.exit(1);
244
+ }
245
+
246
+ let db;
247
+ try {
248
+ db = await openResolvedMainDb(flags);
249
+ } catch (err) {
250
+ console.error(`Error opening database: ${err.message}`);
251
+ process.exit(1);
252
+ }
253
+
254
+ const applied = getAppliedMigrations(db);
255
+ const source = extractMigrationsFromSource();
256
+
257
+ const appliedIds = new Set(applied.map(m => m.id));
258
+ const pending = source.filter(m => !appliedIds.has(m.id));
259
+
260
+ if (flags.json) {
261
+ console.log(JSON.stringify({
262
+ pending: pending.length,
263
+ migrations: pending.map(m => ({
264
+ id: m.id,
265
+ description: m.description,
266
+ note: 'shouldRun() predicate is evaluated at startup; inspect migration source for details',
267
+ })),
268
+ }, null, 2));
269
+ } else {
270
+ console.log(`Dry run: ${pending.length} migrations would run on next startup`);
271
+ if (pending.length > 0) {
272
+ console.log('');
273
+ pending.forEach(m => {
274
+ console.log(` ${m.id.padEnd(50)} ${m.description}`);
275
+ });
276
+ console.log('');
277
+ console.log('Note: shouldRun() predicate is evaluated at startup.');
278
+ console.log('Inspect the migration source in migrations/scripts/ for conditional logic.');
279
+ } else {
280
+ console.log('');
281
+ console.log('All migrations have been applied.');
282
+ }
283
+ }
284
+
285
+ db.close();
286
+ }
287
+
288
+ // ---------- main entry point ----------
289
+
290
+ async function migrationsCommand(args) {
291
+ const { flags, positional } = parseFlags(args);
292
+
293
+ if (flags.help || positional.length === 0) {
294
+ printHelp();
295
+ return;
296
+ }
297
+
298
+ const verb = positional[0];
299
+
300
+ try {
301
+ switch (verb) {
302
+ case 'status':
303
+ await handleStatus(flags);
304
+ break;
305
+ case 'pending':
306
+ await handlePending(flags);
307
+ break;
308
+ case 'run':
309
+ await handleRun(flags);
310
+ break;
311
+ default:
312
+ console.error(`unknown migrations command: ${verb}`);
313
+ console.error('Use "quilltap migrations --help" for usage.');
314
+ process.exit(1);
315
+ }
316
+ } catch (err) {
317
+ console.error(`Error: ${err.message}`);
318
+ process.exit(1);
319
+ }
320
+ }
321
+
322
+ module.exports = {
323
+ migrationsCommand,
324
+ };
@@ -1076,6 +1076,7 @@ ${c.bold}Registry Operator Commands:${c.reset}
1076
1076
 
1077
1077
  ${c.bold}Options:${c.reset}
1078
1078
  --data-dir <path> Override data directory
1079
+ -i, --instance <name> Use a registered instance (see 'quilltap instances')
1079
1080
  -h, --help Show this help
1080
1081
 
1081
1082
  ${c.bold}Examples:${c.reset}
@@ -1098,6 +1099,7 @@ ${c.bold}Examples:${c.reset}
1098
1099
 
1099
1100
  async function themesCommand(args) {
1100
1101
  let dataDirOverride = '';
1102
+ let instanceName = '';
1101
1103
  let showHelp = false;
1102
1104
  let command = '';
1103
1105
  const positional = [];
@@ -1109,6 +1111,7 @@ async function themesCommand(args) {
1109
1111
  while (i < args.length) {
1110
1112
  switch (args[i]) {
1111
1113
  case '--data-dir': case '-d': dataDirOverride = args[++i]; break;
1114
+ case '--instance': case '-i': instanceName = args[++i]; break;
1112
1115
  case '--output': case '-o': outputPath = args[++i]; break;
1113
1116
  case '--help': case '-h': showHelp = true; break;
1114
1117
  default:
@@ -1136,6 +1139,21 @@ async function themesCommand(args) {
1136
1139
  process.exit(0);
1137
1140
  }
1138
1141
 
1142
+ if (instanceName && dataDirOverride) {
1143
+ console.error('Error: Specify either --instance or --data-dir, not both.');
1144
+ process.exit(1);
1145
+ }
1146
+ if (instanceName) {
1147
+ try {
1148
+ const { resolveInstance } = require('./instances');
1149
+ const inst = resolveInstance(instanceName);
1150
+ dataDirOverride = inst.path;
1151
+ } catch (err) {
1152
+ console.error(`Error: ${err.message}`);
1153
+ process.exit(1);
1154
+ }
1155
+ }
1156
+
1139
1157
  switch (command) {
1140
1158
  case 'list':
1141
1159
  await listThemes(dataDirOverride);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.5.0-dev",
3
+ "version": "4.6.0-dev",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",