spindb 0.8.2 → 0.9.1

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 (40) hide show
  1. package/README.md +87 -7
  2. package/cli/commands/clone.ts +6 -0
  3. package/cli/commands/connect.ts +115 -14
  4. package/cli/commands/create.ts +170 -8
  5. package/cli/commands/doctor.ts +320 -0
  6. package/cli/commands/edit.ts +209 -9
  7. package/cli/commands/engines.ts +34 -3
  8. package/cli/commands/info.ts +81 -26
  9. package/cli/commands/list.ts +64 -9
  10. package/cli/commands/logs.ts +9 -3
  11. package/cli/commands/menu/backup-handlers.ts +52 -21
  12. package/cli/commands/menu/container-handlers.ts +433 -127
  13. package/cli/commands/menu/engine-handlers.ts +128 -4
  14. package/cli/commands/menu/index.ts +5 -1
  15. package/cli/commands/menu/shell-handlers.ts +105 -21
  16. package/cli/commands/menu/sql-handlers.ts +16 -4
  17. package/cli/commands/menu/update-handlers.ts +278 -0
  18. package/cli/commands/restore.ts +83 -23
  19. package/cli/commands/run.ts +27 -11
  20. package/cli/commands/url.ts +17 -9
  21. package/cli/constants.ts +1 -0
  22. package/cli/helpers.ts +41 -1
  23. package/cli/index.ts +2 -0
  24. package/cli/ui/prompts.ts +148 -7
  25. package/config/engine-defaults.ts +14 -0
  26. package/config/os-dependencies.ts +66 -0
  27. package/config/paths.ts +8 -0
  28. package/core/container-manager.ts +191 -32
  29. package/core/dependency-manager.ts +18 -0
  30. package/core/error-handler.ts +31 -0
  31. package/core/port-manager.ts +2 -0
  32. package/core/process-manager.ts +25 -3
  33. package/engines/index.ts +4 -0
  34. package/engines/mysql/backup.ts +53 -36
  35. package/engines/mysql/index.ts +48 -5
  36. package/engines/postgresql/index.ts +6 -0
  37. package/engines/sqlite/index.ts +606 -0
  38. package/engines/sqlite/registry.ts +185 -0
  39. package/package.json +1 -1
  40. package/types/index.ts +26 -0
@@ -0,0 +1,606 @@
1
+ /**
2
+ * SQLite Engine
3
+ *
4
+ * SQLite is a file-based embedded database with no server process.
5
+ * Key differences from PostgreSQL/MySQL:
6
+ * - No start/stop operations (file-based)
7
+ * - No port management
8
+ * - Database files stored in user project directories (not ~/.spindb/)
9
+ * - Uses a registry to track file paths
10
+ */
11
+
12
+ import { spawn, execFile } from 'child_process'
13
+ import { promisify } from 'util'
14
+ import { existsSync, statSync, createReadStream, createWriteStream } from 'fs'
15
+ import { copyFile, unlink, mkdir, open, writeFile } from 'fs/promises'
16
+ import { resolve, dirname, join } from 'path'
17
+ import { tmpdir } from 'os'
18
+ import { BaseEngine } from '../base-engine'
19
+ import { sqliteRegistry } from './registry'
20
+ import { configManager } from '../../core/config-manager'
21
+ import { getEngineDefaults } from '../../config/engine-defaults'
22
+ import type {
23
+ ContainerConfig,
24
+ ProgressCallback,
25
+ BackupFormat,
26
+ BackupOptions,
27
+ BackupResult,
28
+ RestoreResult,
29
+ DumpResult,
30
+ StatusResult,
31
+ } from '../../types'
32
+
33
+ const execFileAsync = promisify(execFile)
34
+ const engineDef = getEngineDefaults('sqlite')
35
+
36
+ export class SQLiteEngine extends BaseEngine {
37
+ name = 'sqlite'
38
+ displayName = 'SQLite'
39
+ defaultPort = 0 // File-based, no port
40
+ supportedVersions = engineDef.supportedVersions
41
+
42
+ /**
43
+ * SQLite uses system binaries - no download URL
44
+ */
45
+ getBinaryUrl(): string {
46
+ throw new Error(
47
+ 'SQLite uses system-installed binaries. Install sqlite3:\n' +
48
+ ' macOS: brew install sqlite (or use built-in /usr/bin/sqlite3)\n' +
49
+ ' Ubuntu/Debian: sudo apt install sqlite3',
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Verify sqlite3 binary exists
55
+ */
56
+ async verifyBinary(): Promise<boolean> {
57
+ return this.isBinaryInstalled('3')
58
+ }
59
+
60
+ /**
61
+ * Check if sqlite3 is installed on the system
62
+ */
63
+ async isBinaryInstalled(_version: string): Promise<boolean> {
64
+ const sqlite3Path = await this.getSqlite3Path()
65
+ return sqlite3Path !== null
66
+ }
67
+
68
+ /**
69
+ * Ensure sqlite3 is available
70
+ * SQLite uses system binaries, so this just verifies it exists
71
+ */
72
+ async ensureBinaries(
73
+ _version: string,
74
+ _onProgress?: ProgressCallback,
75
+ ): Promise<string> {
76
+ const sqlite3Path = await this.getSqlite3Path()
77
+ if (!sqlite3Path) {
78
+ throw new Error(
79
+ 'sqlite3 not found. Install SQLite:\n' +
80
+ ' macOS: brew install sqlite (or use built-in /usr/bin/sqlite3)\n' +
81
+ ' Ubuntu/Debian: sudo apt install sqlite3\n' +
82
+ ' Fedora: sudo dnf install sqlite',
83
+ )
84
+ }
85
+ return sqlite3Path
86
+ }
87
+
88
+ /**
89
+ * Get path to sqlite3 binary
90
+ * First checks config manager, then falls back to system PATH
91
+ */
92
+ async getSqlite3Path(): Promise<string | null> {
93
+ // Check config manager first
94
+ const configPath = await configManager.getBinaryPath('sqlite3')
95
+ if (configPath) {
96
+ return configPath
97
+ }
98
+
99
+ // Check system PATH
100
+ try {
101
+ // TODO - update when windows support is added
102
+ const { stdout } = await execFileAsync('which', ['sqlite3'])
103
+ const path = stdout.trim()
104
+ return path || null
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get path to litecli (enhanced SQLite CLI)
112
+ */
113
+ async getLitecliPath(): Promise<string | null> {
114
+ // Check config manager first
115
+ const configPath = await configManager.getBinaryPath('litecli')
116
+ if (configPath) {
117
+ return configPath
118
+ }
119
+
120
+ // Check system PATH
121
+ try {
122
+ const { stdout } = await execFileAsync('which', ['litecli'])
123
+ const path = stdout.trim()
124
+ return path || null
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Initialize a new SQLite database file
132
+ * Creates an empty database at the specified path (or CWD)
133
+ */
134
+ async initDataDir(
135
+ containerName: string,
136
+ _version: string,
137
+ options: Record<string, unknown> = {},
138
+ ): Promise<string> {
139
+ // Determine file path - default to CWD
140
+ const pathOption = options.path as string | undefined
141
+ const filePath = pathOption || `./${containerName}.sqlite`
142
+ const absolutePath = resolve(filePath)
143
+
144
+ // Ensure parent directory exists
145
+ const dir = dirname(absolutePath)
146
+ if (!existsSync(dir)) {
147
+ await mkdir(dir, { recursive: true })
148
+ }
149
+
150
+ // Check if file already exists
151
+ if (existsSync(absolutePath)) {
152
+ throw new Error(`File already exists: ${absolutePath}`)
153
+ }
154
+
155
+ // Check if this path is already registered
156
+ if (await sqliteRegistry.isPathRegistered(absolutePath)) {
157
+ throw new Error(`Path is already registered: ${absolutePath}`)
158
+ }
159
+
160
+ // Create empty database by running a simple query
161
+ const sqlite3 = await this.getSqlite3Path()
162
+ if (!sqlite3) {
163
+ throw new Error('sqlite3 not found')
164
+ }
165
+
166
+ await execFileAsync(sqlite3, [absolutePath, 'SELECT 1'])
167
+
168
+ // Register in the SQLite registry
169
+ await sqliteRegistry.add({
170
+ name: containerName,
171
+ filePath: absolutePath,
172
+ created: new Date().toISOString(),
173
+ })
174
+
175
+ return absolutePath
176
+ }
177
+
178
+ /**
179
+ * Start is a no-op for SQLite (file-based, no server)
180
+ * Just verifies the file exists
181
+ */
182
+ async start(
183
+ container: ContainerConfig,
184
+ _onProgress?: ProgressCallback,
185
+ ): Promise<{ port: number; connectionString: string }> {
186
+ const entry = await sqliteRegistry.get(container.name)
187
+ if (!entry) {
188
+ throw new Error(
189
+ `SQLite container "${container.name}" not found in registry`,
190
+ )
191
+ }
192
+ if (!existsSync(entry.filePath)) {
193
+ throw new Error(`SQLite database file not found: ${entry.filePath}`)
194
+ }
195
+
196
+ return {
197
+ port: 0,
198
+ connectionString: this.getConnectionString(container),
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Stop is a no-op for SQLite (file-based, no server)
204
+ */
205
+ async stop(_container: ContainerConfig): Promise<void> {
206
+ // No-op: SQLite is file-based, no server to stop
207
+ }
208
+
209
+ /**
210
+ * Get status - check if the file exists
211
+ */
212
+ async status(container: ContainerConfig): Promise<StatusResult> {
213
+ const entry = await sqliteRegistry.get(container.name)
214
+ if (!entry) {
215
+ return {
216
+ running: false,
217
+ message: 'Not registered in SQLite registry',
218
+ }
219
+ }
220
+ if (!existsSync(entry.filePath)) {
221
+ return {
222
+ running: false,
223
+ message: `File not found: ${entry.filePath}`,
224
+ }
225
+ }
226
+ return {
227
+ running: true,
228
+ message: 'Database file exists',
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Get connection string for SQLite
234
+ * Returns sqlite:// URL format
235
+ */
236
+ getConnectionString(container: ContainerConfig, _database?: string): string {
237
+ // container.database stores the file path for SQLite
238
+ const filePath = container.database
239
+ return `sqlite:///${filePath}`
240
+ }
241
+
242
+ /**
243
+ * Open interactive SQLite shell
244
+ * Prefers litecli if available, falls back to sqlite3
245
+ */
246
+ async connect(container: ContainerConfig, _database?: string): Promise<void> {
247
+ const entry = await sqliteRegistry.get(container.name)
248
+ if (!entry) {
249
+ throw new Error(
250
+ `SQLite container "${container.name}" not found in registry`,
251
+ )
252
+ }
253
+ if (!existsSync(entry.filePath)) {
254
+ throw new Error(`SQLite database file not found: ${entry.filePath}`)
255
+ }
256
+
257
+ // Try litecli first, fall back to sqlite3
258
+ const litecli = await this.getLitecliPath()
259
+ const sqlite3 = await this.getSqlite3Path()
260
+
261
+ const cmd = litecli || sqlite3
262
+ if (!cmd) {
263
+ throw new Error(
264
+ 'sqlite3 not found. Install SQLite:\n' +
265
+ ' macOS: brew install sqlite\n' +
266
+ ' Ubuntu/Debian: sudo apt install sqlite3',
267
+ )
268
+ }
269
+
270
+ return new Promise((resolve, reject) => {
271
+ const proc = spawn(cmd, [entry.filePath], { stdio: 'inherit' })
272
+
273
+ proc.on('error', (err: NodeJS.ErrnoException) => {
274
+ reject(err)
275
+ })
276
+
277
+ proc.on('close', () => resolve())
278
+ })
279
+ }
280
+
281
+ /**
282
+ * Create database is a no-op for SQLite
283
+ * In SQLite, the file IS the database
284
+ */
285
+ async createDatabase(
286
+ _container: ContainerConfig,
287
+ _database: string,
288
+ ): Promise<void> {
289
+ // No-op: SQLite file IS the database
290
+ // If you need multiple "databases", create multiple containers
291
+ }
292
+
293
+ /**
294
+ * Drop database - deletes the file and removes from registry
295
+ */
296
+ async dropDatabase(
297
+ container: ContainerConfig,
298
+ _database: string,
299
+ ): Promise<void> {
300
+ const entry = await sqliteRegistry.get(container.name)
301
+ if (entry && existsSync(entry.filePath)) {
302
+ await unlink(entry.filePath)
303
+ }
304
+ await sqliteRegistry.remove(container.name)
305
+ }
306
+
307
+ /**
308
+ * Get database size by checking file size
309
+ */
310
+ async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
311
+ const entry = await sqliteRegistry.get(container.name)
312
+ if (!entry || !existsSync(entry.filePath)) {
313
+ return null
314
+ }
315
+ const stats = statSync(entry.filePath)
316
+ return stats.size
317
+ }
318
+
319
+ /**
320
+ * Detect backup format
321
+ * SQLite backups are either .sql (dump) or .sqlite/.db (file copy)
322
+ */
323
+ async detectBackupFormat(filePath: string): Promise<BackupFormat> {
324
+ if (filePath.endsWith('.sql')) {
325
+ return {
326
+ format: 'sql',
327
+ description: 'SQLite SQL dump',
328
+ restoreCommand: 'sqlite3 <db> < <file>',
329
+ }
330
+ }
331
+ return {
332
+ format: 'sqlite',
333
+ description: 'SQLite database file (binary copy)',
334
+ restoreCommand: 'cp <file> <db>',
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Create a backup of the SQLite database
340
+ */
341
+ async backup(
342
+ container: ContainerConfig,
343
+ outputPath: string,
344
+ options: BackupOptions,
345
+ ): Promise<BackupResult> {
346
+ const entry = await sqliteRegistry.get(container.name)
347
+ if (!entry || !existsSync(entry.filePath)) {
348
+ throw new Error('SQLite database file not found')
349
+ }
350
+
351
+ if (options.format === 'sql') {
352
+ // Use .dump command for SQL format
353
+ const sqlite3 = await this.getSqlite3Path()
354
+ if (!sqlite3) {
355
+ throw new Error('sqlite3 not found')
356
+ }
357
+
358
+ // Pipe .dump output to file (avoids shell injection)
359
+ await this.dumpToFile(sqlite3, entry.filePath, outputPath)
360
+ } else {
361
+ // Binary copy for 'dump' format
362
+ await copyFile(entry.filePath, outputPath)
363
+ }
364
+
365
+ const stats = statSync(outputPath)
366
+ return {
367
+ path: outputPath,
368
+ format: options.format,
369
+ size: stats.size,
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Restore a backup to the SQLite database
375
+ */
376
+ async restore(
377
+ container: ContainerConfig,
378
+ backupPath: string,
379
+ _options?: Record<string, unknown>,
380
+ ): Promise<RestoreResult> {
381
+ const entry = await sqliteRegistry.get(container.name)
382
+ if (!entry) {
383
+ throw new Error(`Container "${container.name}" not registered`)
384
+ }
385
+
386
+ const format = await this.detectBackupFormat(backupPath)
387
+
388
+ if (format.format === 'sql') {
389
+ // Restore SQL dump
390
+ const sqlite3 = await this.getSqlite3Path()
391
+ if (!sqlite3) {
392
+ throw new Error('sqlite3 not found')
393
+ }
394
+
395
+ // Pipe file to sqlite3 stdin (avoids shell injection)
396
+ await this.runSqlFile(sqlite3, entry.filePath, backupPath)
397
+ return { format: 'sql' }
398
+ } else {
399
+ // Binary file copy
400
+ await copyFile(backupPath, entry.filePath)
401
+ return { format: 'sqlite' }
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Create a dump from a SQLite file (for clone operations)
407
+ * Supports:
408
+ * - Local file paths: ./mydb.sqlite, /path/to/db.sqlite
409
+ * - Local sqlite:// URLs: sqlite:///path/to/db.sqlite
410
+ * - Remote HTTP/HTTPS: https://example.com/backup.sqlite
411
+ */
412
+ async dumpFromConnectionString(
413
+ connectionString: string,
414
+ outputPath: string,
415
+ ): Promise<DumpResult> {
416
+ let filePath = connectionString
417
+ let tempFile: string | null = null
418
+
419
+ // Handle HTTP/HTTPS URLs - download to temp file
420
+ if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
421
+ tempFile = join(tmpdir(), `spindb-download-${Date.now()}.sqlite`)
422
+ await this.downloadFile(filePath, tempFile)
423
+
424
+ // Validate it's a valid SQLite database
425
+ if (!(await this.isValidSqliteFile(tempFile))) {
426
+ await unlink(tempFile)
427
+ throw new Error('Downloaded file is not a valid SQLite database')
428
+ }
429
+
430
+ filePath = tempFile
431
+ }
432
+ // Handle sqlite:// URLs (strip prefix for local file)
433
+ else if (filePath.startsWith('sqlite:///')) {
434
+ filePath = filePath.slice('sqlite:///'.length)
435
+ } else if (filePath.startsWith('sqlite://')) {
436
+ filePath = filePath.slice('sqlite://'.length)
437
+ }
438
+
439
+ // Verify local file exists
440
+ if (!existsSync(filePath)) {
441
+ throw new Error(`SQLite database file not found: ${filePath}`)
442
+ }
443
+
444
+ const sqlite3 = await this.getSqlite3Path()
445
+ if (!sqlite3) {
446
+ throw new Error('sqlite3 not found')
447
+ }
448
+
449
+ // Pipe .dump output to file (avoids shell injection)
450
+ await this.dumpToFile(sqlite3, filePath, outputPath)
451
+
452
+ // Clean up temp file if we downloaded it
453
+ if (tempFile && existsSync(tempFile)) {
454
+ await unlink(tempFile)
455
+ }
456
+
457
+ return { filePath: outputPath }
458
+ }
459
+
460
+ /**
461
+ * Dump SQLite database to a file using spawn (avoids shell injection)
462
+ * Equivalent to: sqlite3 dbPath .dump > outputPath
463
+ */
464
+ private async dumpToFile(
465
+ sqlite3Path: string,
466
+ dbPath: string,
467
+ outputPath: string,
468
+ ): Promise<void> {
469
+ return new Promise((resolve, reject) => {
470
+ const output = createWriteStream(outputPath)
471
+ const proc = spawn(sqlite3Path, [dbPath, '.dump'])
472
+
473
+ proc.stdout.pipe(output)
474
+
475
+ proc.stderr.on('data', (data: Buffer) => {
476
+ // Collect stderr but don't fail immediately - sqlite3 may write warnings
477
+ console.error(data.toString())
478
+ })
479
+
480
+ proc.on('error', (err) => {
481
+ output.close()
482
+ reject(err)
483
+ })
484
+
485
+ proc.on('close', (code) => {
486
+ output.close()
487
+ if (code === 0) {
488
+ resolve()
489
+ } else {
490
+ reject(new Error(`sqlite3 dump failed with exit code ${code}`))
491
+ }
492
+ })
493
+ })
494
+ }
495
+
496
+ /**
497
+ * Run a SQL file against SQLite database using spawn (avoids shell injection)
498
+ * Equivalent to: sqlite3 dbPath < sqlFilePath
499
+ */
500
+ private async runSqlFile(
501
+ sqlite3Path: string,
502
+ dbPath: string,
503
+ sqlFilePath: string,
504
+ ): Promise<void> {
505
+ return new Promise((resolve, reject) => {
506
+ const input = createReadStream(sqlFilePath)
507
+ const proc = spawn(sqlite3Path, [dbPath])
508
+
509
+ input.pipe(proc.stdin)
510
+
511
+ proc.stderr.on('data', (data: Buffer) => {
512
+ // Collect stderr but don't fail immediately - sqlite3 may write warnings
513
+ console.error(data.toString())
514
+ })
515
+
516
+ input.on('error', (err) => {
517
+ proc.kill()
518
+ reject(err)
519
+ })
520
+
521
+ proc.on('error', (err) => {
522
+ reject(err)
523
+ })
524
+
525
+ proc.on('close', (code) => {
526
+ if (code === 0) {
527
+ resolve()
528
+ } else {
529
+ reject(
530
+ new Error(`sqlite3 script execution failed with exit code ${code}`),
531
+ )
532
+ }
533
+ })
534
+ })
535
+ }
536
+
537
+ /**
538
+ * Download a file from HTTP/HTTPS URL
539
+ */
540
+ private async downloadFile(url: string, destPath: string): Promise<void> {
541
+ const response = await fetch(url)
542
+ if (!response.ok) {
543
+ throw new Error(
544
+ `Failed to download: ${response.status} ${response.statusText}`,
545
+ )
546
+ }
547
+
548
+ const buffer = await response.arrayBuffer()
549
+ await writeFile(destPath, Buffer.from(buffer))
550
+ }
551
+
552
+ /**
553
+ * Validate a file is a valid SQLite database
554
+ * SQLite files start with "SQLite format 3\0" (first 16 bytes)
555
+ */
556
+ private async isValidSqliteFile(filePath: string): Promise<boolean> {
557
+ try {
558
+ const buffer = Buffer.alloc(16)
559
+ const fd = await open(filePath, 'r')
560
+ await fd.read(buffer, 0, 16, 0)
561
+ await fd.close()
562
+ // Check for SQLite magic header
563
+ return buffer.toString('utf8', 0, 15) === 'SQLite format 3'
564
+ } catch {
565
+ return false
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Run a SQL file or inline SQL statement
571
+ */
572
+ async runScript(
573
+ container: ContainerConfig,
574
+ options: { file?: string; sql?: string; database?: string },
575
+ ): Promise<void> {
576
+ const entry = await sqliteRegistry.get(container.name)
577
+ if (!entry || !existsSync(entry.filePath)) {
578
+ throw new Error('SQLite database file not found')
579
+ }
580
+
581
+ const sqlite3 = await this.getSqlite3Path()
582
+ if (!sqlite3) {
583
+ throw new Error('sqlite3 not found')
584
+ }
585
+
586
+ if (options.file) {
587
+ // Run SQL file - pipe file to stdin (avoids shell injection)
588
+ await this.runSqlFile(sqlite3, entry.filePath, options.file)
589
+ } else if (options.sql) {
590
+ // Run inline SQL - pass as argument (avoids shell injection)
591
+ await execFileAsync(sqlite3, [entry.filePath, options.sql])
592
+ } else {
593
+ throw new Error('Either file or sql option must be provided')
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Get available versions - SQLite uses system version
599
+ */
600
+ async fetchAvailableVersions(): Promise<Record<string, string[]>> {
601
+ // SQLite uses system version, just return supported versions
602
+ return { '3': ['3'] }
603
+ }
604
+ }
605
+
606
+ export const sqliteEngine = new SQLiteEngine()