@graphorin/cli 0.6.1 → 0.7.0

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +3 -3
  3. package/dist/bin/graphorin.js +51 -13
  4. package/dist/bin/graphorin.js.map +1 -1
  5. package/dist/commands/audit.d.ts.map +1 -1
  6. package/dist/commands/audit.js +2 -1
  7. package/dist/commands/audit.js.map +1 -1
  8. package/dist/commands/consolidator.d.ts +56 -1
  9. package/dist/commands/consolidator.d.ts.map +1 -1
  10. package/dist/commands/consolidator.js +88 -2
  11. package/dist/commands/consolidator.js.map +1 -1
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/index.d.ts +4 -4
  14. package/dist/commands/index.js +4 -4
  15. package/dist/commands/init.d.ts +11 -4
  16. package/dist/commands/init.d.ts.map +1 -1
  17. package/dist/commands/init.js +15 -11
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/memory.d.ts +26 -1
  20. package/dist/commands/memory.d.ts.map +1 -1
  21. package/dist/commands/memory.js +56 -3
  22. package/dist/commands/memory.js.map +1 -1
  23. package/dist/commands/pricing.d.ts +6 -0
  24. package/dist/commands/pricing.d.ts.map +1 -1
  25. package/dist/commands/pricing.js +5 -2
  26. package/dist/commands/pricing.js.map +1 -1
  27. package/dist/commands/secrets.d.ts.map +1 -1
  28. package/dist/commands/secrets.js +2 -2
  29. package/dist/commands/secrets.js.map +1 -1
  30. package/dist/commands/skills.d.ts.map +1 -1
  31. package/dist/commands/skills.js +1 -1
  32. package/dist/commands/skills.js.map +1 -1
  33. package/dist/commands/storage.d.ts +41 -1
  34. package/dist/commands/storage.d.ts.map +1 -1
  35. package/dist/commands/storage.js +75 -1
  36. package/dist/commands/storage.js.map +1 -1
  37. package/dist/commands/token.d.ts.map +1 -1
  38. package/dist/commands/token.js +2 -2
  39. package/dist/commands/token.js.map +1 -1
  40. package/dist/commands/tools-lint.js +1 -1
  41. package/dist/commands/tools-lint.js.map +1 -1
  42. package/dist/commands/traces.d.ts +14 -2
  43. package/dist/commands/traces.d.ts.map +1 -1
  44. package/dist/commands/traces.js +39 -22
  45. package/dist/commands/traces.js.map +1 -1
  46. package/dist/commands/triggers.d.ts.map +1 -1
  47. package/dist/commands/triggers.js +5 -2
  48. package/dist/commands/triggers.js.map +1 -1
  49. package/dist/index.d.ts +4 -4
  50. package/dist/index.js +4 -5
  51. package/dist/index.js.map +1 -1
  52. package/dist/internal/output.js +6 -0
  53. package/dist/internal/output.js.map +1 -1
  54. package/dist/internal/store-context.js +13 -2
  55. package/dist/internal/store-context.js.map +1 -1
  56. package/dist/package.js +1 -1
  57. package/dist/package.js.map +1 -1
  58. package/package.json +18 -14
  59. package/src/bin/graphorin.ts +1387 -0
  60. package/src/commands/audit.ts +256 -0
  61. package/src/commands/auth.ts +238 -0
  62. package/src/commands/consolidator.ts +382 -0
  63. package/src/commands/doctor.ts +253 -0
  64. package/src/commands/guard.ts +144 -0
  65. package/src/commands/index.ts +223 -0
  66. package/src/commands/init.ts +194 -0
  67. package/src/commands/memory.ts +1052 -0
  68. package/src/commands/migrate-config.ts +77 -0
  69. package/src/commands/migrate-export.ts +117 -0
  70. package/src/commands/migrate.ts +83 -0
  71. package/src/commands/pricing.ts +244 -0
  72. package/src/commands/secrets.ts +309 -0
  73. package/src/commands/skills.ts +272 -0
  74. package/src/commands/start.ts +180 -0
  75. package/src/commands/storage.ts +659 -0
  76. package/src/commands/telemetry.ts +91 -0
  77. package/src/commands/token.ts +361 -0
  78. package/src/commands/tools-lint.ts +430 -0
  79. package/src/commands/traces.ts +188 -0
  80. package/src/commands/triggers.ts +237 -0
  81. package/src/index.ts +30 -0
  82. package/src/internal/exit.ts +62 -0
  83. package/src/internal/load-config.ts +107 -0
  84. package/src/internal/offline.ts +81 -0
  85. package/src/internal/output.ts +146 -0
  86. package/src/internal/prompts.ts +58 -0
  87. package/src/internal/store-context.ts +165 -0
@@ -0,0 +1,659 @@
1
+ /**
2
+ * `graphorin storage` - manage the SQLite store and its encryption-
3
+ * at-rest opt-in.
4
+ *
5
+ * Surface (per Phase 15 § Storage):
6
+ *
7
+ * - `graphorin storage status` - reports cipher peer + WAL + size +
8
+ * encryption mode.
9
+ * - `graphorin storage encrypt --passphrase-from <ref>` - opt-in
10
+ * encryption migration. Requires the `@graphorin/store-sqlite-
11
+ * encrypted` sub-pack from Phase 16.
12
+ * - `graphorin storage rekey --new-passphrase-from <ref>` - re-key
13
+ * an already-encrypted DB.
14
+ * - `graphorin storage cleanup-backups` - drop stale `.bak` /
15
+ * `.bak.<ts>` files left by previous encrypt / rekey runs.
16
+ * - `graphorin storage backup <dest>` - online, consistent copy via
17
+ * the driver's page-level backup API (store-02). Safe under a live
18
+ * writer, preserves rowids (FTS5 mappings survive). Never use
19
+ * `VACUUM INTO` - rowid renumbering corrupts FTS mappings.
20
+ *
21
+ * `encrypt`, `rekey`, and `cleanup-backups` need the cipher peer
22
+ * (`better-sqlite3-multiple-ciphers`) which ships in the optional
23
+ * Phase 16 sub-pack. When the peer is missing the CLI exits `2`
24
+ * (`UNSUPPORTED`) with an actionable hint instead of silently
25
+ * doing nothing.
26
+ *
27
+ * @packageDocumentation
28
+ */
29
+
30
+ import { readdir, stat, unlink } from 'node:fs/promises';
31
+ import { dirname, isAbsolute, join, basename as pathBasename, resolve } from 'node:path';
32
+ import process from 'node:process';
33
+
34
+ import { resolveSecret, type SecretValue } from '@graphorin/security';
35
+ import { parseServerConfig } from '@graphorin/server';
36
+
37
+ import { EXIT_CODES } from '../internal/exit.js';
38
+ import { loadConfig } from '../internal/load-config.js';
39
+ import {
40
+ brand,
41
+ type CommonOutputOptions,
42
+ defaultPrintSink,
43
+ emitReport,
44
+ statusMarker,
45
+ } from '../internal/output.js';
46
+
47
+ /** @stable */
48
+ export interface StorageCommonOptions extends CommonOutputOptions {
49
+ readonly config?: string;
50
+ }
51
+
52
+ /** @stable */
53
+ export interface StorageStatusResult {
54
+ readonly path: string;
55
+ readonly mode: 'lib' | 'server';
56
+ readonly encryption: { readonly enabled: boolean; readonly cipher?: string };
57
+ readonly cipherPeer: { readonly installed: boolean; readonly hint?: string };
58
+ readonly mainDb: { readonly exists: boolean; readonly sizeBytes?: number };
59
+ readonly walFile: { readonly exists: boolean; readonly sizeBytes?: number };
60
+ readonly auditDb: {
61
+ readonly enabled: boolean;
62
+ readonly path?: string;
63
+ readonly exists?: boolean;
64
+ };
65
+ }
66
+
67
+ /** @stable */
68
+ export async function runStorageStatus(
69
+ options: StorageCommonOptions = {},
70
+ ): Promise<StorageStatusResult> {
71
+ const loaded = await loadConfig(options.config);
72
+ const config = parseServerConfig(loaded.config);
73
+ const cipherPeer = await probeCipherPeer();
74
+ const mainDb = await statSafely(resolveStoragePath(config.storage.path));
75
+ const walFile = await statSafely(`${resolveStoragePath(config.storage.path)}-wal`);
76
+ let auditPath: string | undefined;
77
+ let auditExists: boolean | undefined;
78
+ if (config.audit.enabled) {
79
+ auditPath = resolveStoragePath(config.audit.path ?? deriveAuditPath(config.storage.path));
80
+ auditExists = (await statSafely(auditPath)).exists;
81
+ }
82
+ const out: StorageStatusResult = Object.freeze({
83
+ path: resolveStoragePath(config.storage.path),
84
+ mode: config.storage.mode,
85
+ encryption: Object.freeze({
86
+ enabled: config.storage.encryption.enabled,
87
+ ...(config.storage.encryption.cipher !== undefined
88
+ ? { cipher: config.storage.encryption.cipher }
89
+ : {}),
90
+ }),
91
+ cipherPeer: Object.freeze({
92
+ installed: cipherPeer.installed,
93
+ ...(cipherPeer.hint !== undefined ? { hint: cipherPeer.hint } : {}),
94
+ }),
95
+ mainDb: Object.freeze({
96
+ exists: mainDb.exists,
97
+ ...(mainDb.size !== undefined ? { sizeBytes: mainDb.size } : {}),
98
+ }),
99
+ walFile: Object.freeze({
100
+ exists: walFile.exists,
101
+ ...(walFile.size !== undefined ? { sizeBytes: walFile.size } : {}),
102
+ }),
103
+ auditDb: Object.freeze({
104
+ enabled: config.audit.enabled,
105
+ ...(auditPath !== undefined ? { path: auditPath } : {}),
106
+ ...(auditExists !== undefined ? { exists: auditExists } : {}),
107
+ }),
108
+ });
109
+ emitReport(options, out, () => {
110
+ const print = options.print ?? defaultPrintSink;
111
+ print(brand(`storage status (${loaded.path})`));
112
+ print(
113
+ ` ${marker(out.mainDb.exists)} main: ${out.path} (${formatSize(out.mainDb.sizeBytes)})`,
114
+ );
115
+ print(
116
+ ` ${marker(out.walFile.exists)} wal: ${out.path}-wal (${formatSize(out.walFile.sizeBytes)})`,
117
+ );
118
+ print(
119
+ ` ${marker(out.encryption.enabled)} encryption: ${out.encryption.enabled ? `enabled (cipher=${out.encryption.cipher ?? 'sqlcipher'})` : 'disabled'}`,
120
+ );
121
+ print(
122
+ ` ${marker(out.cipherPeer.installed)} cipher peer (better-sqlite3-multiple-ciphers): ${out.cipherPeer.installed ? 'installed' : 'missing'}`,
123
+ );
124
+ if (out.cipherPeer.hint !== undefined) print(` -> ${out.cipherPeer.hint}`);
125
+ if (out.auditDb.enabled) {
126
+ print(` ${marker(out.auditDb.exists === true)} audit.db: ${out.auditDb.path}`);
127
+ }
128
+ });
129
+ return out;
130
+ }
131
+
132
+ /** @stable */
133
+ export interface StorageBackupOptions extends StorageCommonOptions {
134
+ /** Destination file path for the backup copy. */
135
+ readonly dest: string;
136
+ /** Replace an existing destination file. Default: refuse. */
137
+ readonly overwrite?: boolean;
138
+ }
139
+
140
+ /** @stable */
141
+ export interface StorageBackupResult {
142
+ readonly source: string;
143
+ readonly dest: string;
144
+ readonly sizeBytes?: number;
145
+ }
146
+
147
+ /**
148
+ * store-02: online backup via the driver's page-level `backup()` API -
149
+ * consistent under a live writer (the daemon can keep running),
150
+ * preserves rowids so FTS5 external-content mappings survive, and for
151
+ * an encrypted store produces an equally-encrypted copy (same key).
152
+ * This is the ONLY supported SQL-level backup: `VACUUM INTO`
153
+ * renumbers rowids and corrupts the FTS mapping on restore.
154
+ *
155
+ * @stable
156
+ */
157
+ export async function runStorageBackup(
158
+ options: StorageBackupOptions,
159
+ ): Promise<StorageBackupResult> {
160
+ const { openStoreContext } = await import('../internal/store-context.js');
161
+ const dest = isAbsolute(options.dest) ? options.dest : resolve(process.cwd(), options.dest);
162
+ const destStat = await statSafely(dest);
163
+ if (destStat.exists && options.overwrite !== true) {
164
+ throw new Error(
165
+ `[graphorin/cli] backup destination already exists: ${dest}. Pass --overwrite to replace it.`,
166
+ );
167
+ }
168
+ const ctx = await openStoreContext({
169
+ ...(options.config !== undefined ? { config: options.config } : {}),
170
+ });
171
+ let source: string;
172
+ try {
173
+ source = ctx.store.connection.path;
174
+ if (source === dest) {
175
+ throw new Error('[graphorin/cli] backup destination must differ from the store path.');
176
+ }
177
+ await ctx.store.connection.raw().backup(dest);
178
+ } finally {
179
+ await ctx.close();
180
+ }
181
+ const written = await statSafely(dest);
182
+ const out: StorageBackupResult = Object.freeze({
183
+ source,
184
+ dest,
185
+ ...(written.size !== undefined ? { sizeBytes: written.size } : {}),
186
+ });
187
+ emitReport(options, out, () => {
188
+ const print = options.print ?? defaultPrintSink;
189
+ print(brand('storage backup'));
190
+ print(` ${statusMarker('ok')} ${out.source} -> ${out.dest} (${formatSize(out.sizeBytes)})`);
191
+ });
192
+ return out;
193
+ }
194
+
195
+ /** @stable */
196
+ export interface StorageCompactOptions extends StorageCommonOptions {
197
+ /**
198
+ * Free pages released per `PRAGMA incremental_vacuum(N)` batch. Small
199
+ * batches keep the writer lock short on a huge freelist.
200
+ * @default 1000
201
+ */
202
+ readonly batchPages?: number;
203
+ }
204
+
205
+ /** @stable */
206
+ export interface StorageCompactResult {
207
+ readonly path: string;
208
+ /** Raw `PRAGMA auto_vacuum` value: 0 none, 1 full, 2 incremental. */
209
+ readonly autoVacuum: number;
210
+ /** `true` when the database supports incremental compaction. */
211
+ readonly supported: boolean;
212
+ readonly freelistBefore?: number;
213
+ readonly freelistAfter?: number;
214
+ readonly pageSize?: number;
215
+ /** `page_size * (freelistBefore - freelistAfter)`. */
216
+ readonly reclaimedBytes?: number;
217
+ }
218
+
219
+ /**
220
+ * W-064: `graphorin storage compact` - return pruned pages to the OS.
221
+ * `VACUUM` stays forbidden (it renumbers implicit rowids and corrupts
222
+ * the FTS5 external-content mappings), but `PRAGMA incremental_vacuum`
223
+ * relocates free pages via the ptrmap WITHOUT rebuilding tables, so it
224
+ * is rowid-safe. Requires `auto_vacuum=2`, which `openConnection` sets
225
+ * on every database it CREATES from this version on; on an older
226
+ * database the command reports the limitation honestly (exit 0, file
227
+ * untouched) - the only way out is recreating the store (fresh init +
228
+ * `migrate-export` / import, or re-remember), because switching
229
+ * auto_vacuum on retroactively needs the very VACUUM that is banned.
230
+ * The vacuum runs in batches so a huge freelist never holds the writer
231
+ * lock in one long bite; the WAL is checkpoint-TRUNCATEd first so
232
+ * freed pages do not linger in the -wal file.
233
+ *
234
+ * @stable
235
+ */
236
+ export async function runStorageCompact(
237
+ options: StorageCompactOptions = {},
238
+ ): Promise<StorageCompactResult> {
239
+ const { openStoreContext } = await import('../internal/store-context.js');
240
+ const batchPages = options.batchPages ?? 1000;
241
+ const ctx = await openStoreContext({
242
+ ...(options.config !== undefined ? { config: options.config } : {}),
243
+ // Compaction is a page-level operation: never migrate the schema
244
+ // (an older server may own this database), never refuse on a
245
+ // pending migration either.
246
+ skipInit: true,
247
+ });
248
+ let out: StorageCompactResult;
249
+ try {
250
+ const conn = ctx.store.connection;
251
+ const autoVacuum = Number(conn.pragma('auto_vacuum', { simple: true }));
252
+ if (autoVacuum !== 2) {
253
+ out = Object.freeze({
254
+ path: conn.path,
255
+ autoVacuum,
256
+ supported: false,
257
+ });
258
+ } else {
259
+ const pageSize = Number(conn.pragma('page_size', { simple: true }));
260
+ // Flush the WAL first so already-checkpointed free pages are in
261
+ // the main file and the -wal file shrinks too.
262
+ conn.pragma('wal_checkpoint(TRUNCATE)');
263
+ const freelistBefore = Number(conn.pragma('freelist_count', { simple: true }));
264
+ let freelist = freelistBefore;
265
+ while (freelist > 0) {
266
+ conn.pragma(`incremental_vacuum(${batchPages})`);
267
+ const next = Number(conn.pragma('freelist_count', { simple: true }));
268
+ if (next >= freelist) break; // no progress - stop rather than spin
269
+ freelist = next;
270
+ }
271
+ const freelistAfter = freelist;
272
+ // In WAL mode the main file only shrinks at a checkpoint - flush
273
+ // again so the reclaimed bytes leave the disk immediately.
274
+ conn.pragma('wal_checkpoint(TRUNCATE)');
275
+ out = Object.freeze({
276
+ path: conn.path,
277
+ autoVacuum,
278
+ supported: true,
279
+ freelistBefore,
280
+ freelistAfter,
281
+ pageSize,
282
+ reclaimedBytes: Math.max(0, (freelistBefore - freelistAfter) * pageSize),
283
+ });
284
+ }
285
+ } finally {
286
+ await ctx.close();
287
+ }
288
+ emitReport(options, out, () => {
289
+ const print = options.print ?? defaultPrintSink;
290
+ print(brand('storage compact'));
291
+ if (!out.supported) {
292
+ print(
293
+ ` ${statusMarker('warn')} auto_vacuum=${out.autoVacuum} - incremental compaction unavailable.`,
294
+ );
295
+ print(' This database was created before incremental auto-vacuum was enabled;');
296
+ print(
297
+ ' its file keeps the high-water-mark size (freed pages are reused, not returned to the OS).',
298
+ );
299
+ print(
300
+ ' To reclaim disk: initialise a fresh store (new databases get auto_vacuum=2) and move the data',
301
+ );
302
+ print(
303
+ ' across (graphorin migrate-export / import); VACUUM stays forbidden - it corrupts FTS5 rowid mappings.',
304
+ );
305
+ return;
306
+ }
307
+ print(
308
+ ` ${statusMarker('ok')} ${out.path}: freelist ${out.freelistBefore} -> ${out.freelistAfter} pages, reclaimed ${formatSize(out.reclaimedBytes)}`,
309
+ );
310
+ });
311
+ return out;
312
+ }
313
+
314
+ /** @stable */
315
+ export interface StorageEncryptOptions extends StorageCommonOptions {
316
+ /** SecretRef URI for the new passphrase. */
317
+ readonly passphraseFrom: string;
318
+ /**
319
+ * Optional explicit target path for the encrypted output. Default:
320
+ * `<storage.path>.encrypted`.
321
+ */
322
+ readonly targetPath?: string;
323
+ /**
324
+ * If `true`, atomically swap the encrypted target into the
325
+ * `storage.path` location after the integrity check, leaving the
326
+ * original under `<storage.path>.bak.<timestamp>`. Default `false`.
327
+ */
328
+ readonly swap?: boolean;
329
+ }
330
+
331
+ /** @stable */
332
+ export interface StorageEncryptResult {
333
+ readonly sourcePath: string;
334
+ readonly targetPath: string;
335
+ readonly cipher: string;
336
+ readonly integrityOk: boolean;
337
+ readonly swap?: { readonly originalRenamedTo: string };
338
+ }
339
+
340
+ /**
341
+ * `graphorin storage encrypt --passphrase-from <ref>` - encrypt a
342
+ * previously unencrypted SQLite store. Delegates to the optional Phase
343
+ * 16 sub-pack `@graphorin/store-sqlite-encrypted` once installed; when
344
+ * the sub-pack is missing the CLI exits `2` (`UNSUPPORTED`) with an
345
+ * actionable hint.
346
+ *
347
+ * @stable
348
+ */
349
+ export async function runStorageEncrypt(
350
+ options: StorageEncryptOptions,
351
+ ): Promise<StorageEncryptResult> {
352
+ const subpack = await loadEncryptedSubpack();
353
+ if (subpack === null) {
354
+ return failUnsupported(
355
+ options,
356
+ `'graphorin storage encrypt' requires the optional sub-pack '@graphorin/store-sqlite-encrypted' (Phase 16) and the cipher peer 'better-sqlite3-multiple-ciphers'.`,
357
+ 'Install @graphorin/store-sqlite-encrypted (which pulls the cipher peer transitively) before running this command.',
358
+ );
359
+ }
360
+ const loaded = await loadConfig(options.config);
361
+ const config = parseServerConfig(loaded.config);
362
+ const sourcePath = resolveStoragePath(config.storage.path);
363
+ const targetPath = options.targetPath ?? `${sourcePath}.encrypted`;
364
+ const passphrase = await resolvePassphraseRef(options.passphraseFrom);
365
+ try {
366
+ const result = await passphrase.use((raw) =>
367
+ subpack.encryptDatabase({
368
+ sourcePath,
369
+ targetPath,
370
+ passphrase: raw,
371
+ ...(options.swap === true ? { swap: true } : {}),
372
+ overwriteTarget: false,
373
+ }),
374
+ );
375
+ const out: StorageEncryptResult = Object.freeze({
376
+ sourcePath,
377
+ targetPath: result.targetPath,
378
+ cipher: result.cipher,
379
+ integrityOk: result.integrityCheck.ok,
380
+ ...(result.swap !== undefined ? { swap: result.swap } : {}),
381
+ });
382
+ emitReport(options, out, () => {
383
+ const print = options.print ?? defaultPrintSink;
384
+ print(brand(`encrypt: ${out.sourcePath} -> ${out.targetPath} (cipher=${out.cipher})`));
385
+ print(` ${marker(out.integrityOk)} cipher_integrity_check`);
386
+ if (out.swap !== undefined) {
387
+ print(` ${statusMarker('ok')} swapped; original kept at ${out.swap.originalRenamedTo}`);
388
+ }
389
+ });
390
+ return out;
391
+ } finally {
392
+ passphrase.dispose();
393
+ }
394
+ }
395
+
396
+ /** @stable */
397
+ export interface StorageRekeyOptions extends StorageCommonOptions {
398
+ readonly oldPassphraseFrom: string;
399
+ readonly newPassphraseFrom: string;
400
+ }
401
+
402
+ /** @stable */
403
+ export interface StorageRekeyResult {
404
+ readonly path: string;
405
+ readonly cipher: string;
406
+ readonly integrityOk: boolean;
407
+ }
408
+
409
+ /** @stable */
410
+ export async function runStorageRekey(options: StorageRekeyOptions): Promise<StorageRekeyResult> {
411
+ const subpack = await loadEncryptedSubpack();
412
+ if (subpack === null) {
413
+ return failUnsupported(
414
+ options,
415
+ `'graphorin storage rekey' requires the optional sub-pack '@graphorin/store-sqlite-encrypted' (Phase 16).`,
416
+ 'Install @graphorin/store-sqlite-encrypted before running this command.',
417
+ );
418
+ }
419
+ const loaded = await loadConfig(options.config);
420
+ const config = parseServerConfig(loaded.config);
421
+ const path = resolveStoragePath(config.storage.path);
422
+ const oldPassphrase = await resolvePassphraseRef(options.oldPassphraseFrom);
423
+ const newPassphrase = await resolvePassphraseRef(options.newPassphraseFrom);
424
+ try {
425
+ const result = await oldPassphrase.use((oldRaw) =>
426
+ newPassphrase.use((newRaw) =>
427
+ subpack.rekeyDatabase({
428
+ path,
429
+ oldPassphrase: oldRaw,
430
+ newPassphrase: newRaw,
431
+ }),
432
+ ),
433
+ );
434
+ const out: StorageRekeyResult = Object.freeze({
435
+ path: result.path,
436
+ cipher: result.cipher,
437
+ integrityOk: result.integrityCheck.ok,
438
+ });
439
+ emitReport(options, out, () => {
440
+ const print = options.print ?? defaultPrintSink;
441
+ print(brand(`rekey: ${out.path} (cipher=${out.cipher})`));
442
+ print(` ${marker(out.integrityOk)} cipher_integrity_check`);
443
+ });
444
+ return out;
445
+ } finally {
446
+ oldPassphrase.dispose();
447
+ newPassphrase.dispose();
448
+ }
449
+ }
450
+
451
+ /** @stable */
452
+ export interface StorageCleanupBackupsOptions extends StorageCommonOptions {
453
+ /**
454
+ * Skip the actual delete; print what would be removed. Default `false`.
455
+ * Tests pass `true` to assert the discovery without touching files.
456
+ */
457
+ readonly dryRun?: boolean;
458
+ }
459
+
460
+ /** @stable */
461
+ export interface StorageCleanupBackupsResult {
462
+ readonly directory: string;
463
+ readonly removed: ReadonlyArray<string>;
464
+ readonly dryRun: boolean;
465
+ }
466
+
467
+ /**
468
+ * Drop stale `.bak`, `.bak.<ts>`, and `.tmp.<ts>` siblings of the
469
+ * configured storage path. Useful after `encrypt` / `rekey` runs that
470
+ * leave intermediate copies around.
471
+ *
472
+ * @stable
473
+ */
474
+ export async function runStorageCleanupBackups(
475
+ options: StorageCleanupBackupsOptions = {},
476
+ ): Promise<StorageCleanupBackupsResult> {
477
+ const loaded = await loadConfig(options.config);
478
+ const config = parseServerConfig(loaded.config);
479
+ const dbPath = resolveStoragePath(config.storage.path);
480
+ const dir = dirname(dbPath);
481
+ // E6: node:path basename, NOT dbPath.split('/') - on Windows the path is
482
+ // backslash-separated, so the split returned the whole path, no readdir
483
+ // entry ever matched, and cleanup-backups was a silent no-op.
484
+ const basename = pathBasename(dbPath);
485
+ let entries: string[];
486
+ try {
487
+ entries = await readdir(dir);
488
+ } catch (err) {
489
+ throw new Error(
490
+ `[graphorin/cli] cannot read storage directory '${dir}': ${(err as Error).message}`,
491
+ );
492
+ }
493
+ const candidates = entries.filter((name) => isStaleBackup(basename, name));
494
+ const removed: string[] = [];
495
+ for (const name of candidates) {
496
+ const full = join(dir, name);
497
+ if (options.dryRun !== true) {
498
+ try {
499
+ await unlink(full);
500
+ removed.push(full);
501
+ } catch {
502
+ // best-effort cleanup; surface in summary
503
+ }
504
+ } else {
505
+ removed.push(full);
506
+ }
507
+ }
508
+ const out: StorageCleanupBackupsResult = Object.freeze({
509
+ directory: dir,
510
+ removed: Object.freeze(removed),
511
+ dryRun: options.dryRun === true,
512
+ });
513
+ emitReport(options, out, () => {
514
+ const print = options.print ?? defaultPrintSink;
515
+ if (out.removed.length === 0) {
516
+ print(brand(`no stale backups found in ${dir}.`));
517
+ return;
518
+ }
519
+ print(
520
+ brand(
521
+ `${out.dryRun ? 'would remove' : 'removed'} ${out.removed.length} stale backup file(s) in ${dir}:`,
522
+ ),
523
+ );
524
+ for (const name of out.removed) print(` - ${name}`);
525
+ });
526
+ return out;
527
+ }
528
+
529
+ function isStaleBackup(baseName: string, candidate: string): boolean {
530
+ if (candidate === baseName) return false;
531
+ if (!candidate.startsWith(baseName)) return false;
532
+ const suffix = candidate.slice(baseName.length);
533
+ if (suffix === '.bak') return true;
534
+ if (/^\.bak\.\d+$/.test(suffix)) return true;
535
+ if (/^\.tmp\.\d+$/.test(suffix)) return true;
536
+ return false;
537
+ }
538
+
539
+ async function probeCipherPeer(): Promise<{
540
+ readonly installed: boolean;
541
+ readonly hint?: string;
542
+ }> {
543
+ try {
544
+ const moduleName = 'better-sqlite3-multiple-ciphers';
545
+ await import(/* @vite-ignore */ moduleName);
546
+ return Object.freeze({ installed: true });
547
+ } catch {
548
+ return Object.freeze({
549
+ installed: false,
550
+ hint: "install '@graphorin/store-sqlite-encrypted' (Phase 16) which transitively installs the cipher peer.",
551
+ });
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Encryption sub-pack surface consumed by the CLI runners. Declared
557
+ * here so the dynamic-import path stays typed without forcing the CLI
558
+ * to take a hard dependency on the optional package.
559
+ *
560
+ * @internal
561
+ */
562
+ interface EncryptedSubpack {
563
+ encryptDatabase(args: {
564
+ sourcePath: string;
565
+ targetPath: string;
566
+ passphrase: string;
567
+ swap?: boolean;
568
+ overwriteTarget?: boolean;
569
+ }): Promise<{
570
+ sourcePath: string;
571
+ targetPath: string;
572
+ cipher: string;
573
+ integrityCheck: { ok: boolean; rows: ReadonlyArray<string> };
574
+ swap?: { originalRenamedTo: string };
575
+ }>;
576
+ rekeyDatabase(args: { path: string; oldPassphrase: string; newPassphrase: string }): Promise<{
577
+ path: string;
578
+ cipher: string;
579
+ integrityCheck: { ok: boolean; rows: ReadonlyArray<string> };
580
+ }>;
581
+ }
582
+
583
+ /** @internal */
584
+ async function loadEncryptedSubpack(): Promise<EncryptedSubpack | null> {
585
+ try {
586
+ // Computed module name keeps TypeScript's resolution off the
587
+ // build-time graph so the CLI typechecks without the optional
588
+ // package installed.
589
+ const moduleName = '@graphorin/store-sqlite-encrypted';
590
+ const mod = (await import(/* @vite-ignore */ moduleName)) as Partial<EncryptedSubpack>;
591
+ if (typeof mod?.encryptDatabase === 'function' && typeof mod?.rekeyDatabase === 'function') {
592
+ return mod as EncryptedSubpack;
593
+ }
594
+ return null;
595
+ } catch {
596
+ return null;
597
+ }
598
+ }
599
+
600
+ /** @internal */
601
+ async function resolvePassphraseRef(ref: string): Promise<SecretValue> {
602
+ try {
603
+ return await resolveSecret(ref);
604
+ } catch (err) {
605
+ throw new Error(
606
+ `[graphorin/cli] failed to resolve storage passphrase '${ref}': ${(err as Error).message}`,
607
+ { cause: err },
608
+ );
609
+ }
610
+ }
611
+
612
+ async function statSafely(
613
+ path: string,
614
+ ): Promise<{ readonly exists: boolean; readonly size?: number }> {
615
+ try {
616
+ const s = await stat(path);
617
+ return { exists: true, size: s.size };
618
+ } catch {
619
+ return { exists: false };
620
+ }
621
+ }
622
+
623
+ // IP-20: resolve a relative storage path against the CWD - the SAME rule the
624
+ // server (`createServer` → `createSqliteStore`) and `openStoreContext` use - so
625
+ // `graphorin storage status / encrypt` from any directory reports the same
626
+ // database the server and the other CLI commands (`memory`, …) open. Resolving
627
+ // against the config-file dir made `storage status` the lone outlier: from a
628
+ // foreign CWD it inspected a different `data.db` than everything else.
629
+ function resolveStoragePath(target: string): string {
630
+ return isAbsolute(target) ? target : resolve(target);
631
+ }
632
+
633
+ function deriveAuditPath(storagePath: string): string {
634
+ const idx = storagePath.lastIndexOf('/');
635
+ if (idx < 0) return 'audit.db';
636
+ return `${storagePath.slice(0, idx)}/audit.db`;
637
+ }
638
+
639
+ function formatSize(bytes: number | undefined): string {
640
+ if (bytes === undefined) return 'missing';
641
+ if (bytes < 1024) return `${bytes} B`;
642
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
643
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
644
+ }
645
+
646
+ function marker(ok: boolean): string {
647
+ return ok ? statusMarker('ok') : statusMarker('warn');
648
+ }
649
+
650
+ function failUnsupported(options: CommonOutputOptions, message: string, hint: string): never {
651
+ const print = options.print ?? defaultPrintSink;
652
+ print(brand(message));
653
+ print(brand(`hint: ${hint}`));
654
+ process.exit(EXIT_CODES.UNSUPPORTED);
655
+ // `process.exit` returns `never` at runtime, but the type system
656
+ // narrows the return so explicit callers do not need a cast.
657
+ /* c8 ignore next */
658
+ throw new Error('unreachable');
659
+ }