spindb 0.1.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 (41) hide show
  1. package/.claude/settings.local.json +20 -0
  2. package/.env.example +1 -0
  3. package/.prettierignore +4 -0
  4. package/.prettierrc +6 -0
  5. package/CLAUDE.md +162 -0
  6. package/README.md +204 -0
  7. package/TODO.md +66 -0
  8. package/bin/cli.js +7 -0
  9. package/eslint.config.js +18 -0
  10. package/package.json +52 -0
  11. package/seeds/mysql/sample-db.sql +22 -0
  12. package/seeds/postgres/sample-db.sql +27 -0
  13. package/src/bin/cli.ts +8 -0
  14. package/src/cli/commands/clone.ts +101 -0
  15. package/src/cli/commands/config.ts +215 -0
  16. package/src/cli/commands/connect.ts +106 -0
  17. package/src/cli/commands/create.ts +148 -0
  18. package/src/cli/commands/delete.ts +94 -0
  19. package/src/cli/commands/list.ts +69 -0
  20. package/src/cli/commands/menu.ts +675 -0
  21. package/src/cli/commands/restore.ts +161 -0
  22. package/src/cli/commands/start.ts +95 -0
  23. package/src/cli/commands/stop.ts +91 -0
  24. package/src/cli/index.ts +38 -0
  25. package/src/cli/ui/prompts.ts +197 -0
  26. package/src/cli/ui/spinner.ts +94 -0
  27. package/src/cli/ui/theme.ts +113 -0
  28. package/src/config/defaults.ts +49 -0
  29. package/src/config/paths.ts +53 -0
  30. package/src/core/binary-manager.ts +239 -0
  31. package/src/core/config-manager.ts +259 -0
  32. package/src/core/container-manager.ts +234 -0
  33. package/src/core/port-manager.ts +84 -0
  34. package/src/core/process-manager.ts +353 -0
  35. package/src/engines/base-engine.ts +103 -0
  36. package/src/engines/index.ts +46 -0
  37. package/src/engines/postgresql/binary-urls.ts +52 -0
  38. package/src/engines/postgresql/index.ts +298 -0
  39. package/src/engines/postgresql/restore.ts +173 -0
  40. package/src/types/index.ts +97 -0
  41. package/tsconfig.json +24 -0
@@ -0,0 +1,675 @@
1
+ import { Command } from 'commander'
2
+ import inquirer from 'inquirer'
3
+ import chalk from 'chalk'
4
+ import { containerManager } from '@/core/container-manager'
5
+ import { processManager } from '@/core/process-manager'
6
+ import { getEngine } from '@/engines'
7
+ import { portManager } from '@/core/port-manager'
8
+ import { defaults } from '@/config/defaults'
9
+ import {
10
+ promptCreateOptions,
11
+ promptContainerSelect,
12
+ promptConfirm,
13
+ promptDatabaseName,
14
+ } from '@/cli/ui/prompts'
15
+ import { createSpinner } from '@/cli/ui/spinner'
16
+ import {
17
+ header,
18
+ success,
19
+ error,
20
+ warning,
21
+ info,
22
+ connectionBox,
23
+ } from '@/cli/ui/theme'
24
+ import { existsSync } from 'fs'
25
+ import { spawn } from 'child_process'
26
+
27
+ interface MenuChoice {
28
+ name: string
29
+ value: string
30
+ disabled?: boolean | string
31
+ }
32
+
33
+ async function showMainMenu(): Promise<void> {
34
+ console.clear()
35
+ console.log(header('SpinDB - Local Database Manager'))
36
+ console.log()
37
+
38
+ const containers = await containerManager.list()
39
+ const running = containers.filter((c) => c.status === 'running').length
40
+ const stopped = containers.filter((c) => c.status !== 'running').length
41
+
42
+ console.log(
43
+ chalk.gray(
44
+ ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
45
+ ),
46
+ )
47
+ console.log()
48
+
49
+ const choices: MenuChoice[] = [
50
+ { name: `${chalk.green('+')} Create new container`, value: 'create' },
51
+ { name: `${chalk.cyan('◉')} List containers`, value: 'list' },
52
+ {
53
+ name: `${chalk.green('▶')} Start a container`,
54
+ value: 'start',
55
+ disabled: stopped === 0 ? 'No stopped containers' : false,
56
+ },
57
+ {
58
+ name: `${chalk.yellow('■')} Stop a container`,
59
+ value: 'stop',
60
+ disabled: running === 0 ? 'No running containers' : false,
61
+ },
62
+ {
63
+ name: `${chalk.blue('⌘')} Open psql shell`,
64
+ value: 'connect',
65
+ disabled: running === 0 ? 'No running containers' : false,
66
+ },
67
+ {
68
+ name: `${chalk.magenta('↓')} Restore backup`,
69
+ value: 'restore',
70
+ disabled: running === 0 ? 'No running containers' : false,
71
+ },
72
+ {
73
+ name: `${chalk.cyan('⧉')} Clone a container`,
74
+ value: 'clone',
75
+ disabled: containers.length === 0 ? 'No containers' : false,
76
+ },
77
+ {
78
+ name: `${chalk.white('⚙')} Change port`,
79
+ value: 'port',
80
+ disabled: stopped === 0 ? 'No stopped containers' : false,
81
+ },
82
+ {
83
+ name: `${chalk.red('✕')} Delete a container`,
84
+ value: 'delete',
85
+ disabled: containers.length === 0 ? 'No containers' : false,
86
+ },
87
+ new inquirer.Separator(),
88
+ { name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
89
+ ]
90
+
91
+ const { action } = await inquirer.prompt<{ action: string }>([
92
+ {
93
+ type: 'list',
94
+ name: 'action',
95
+ message: 'What would you like to do?',
96
+ choices,
97
+ pageSize: 12,
98
+ },
99
+ ])
100
+
101
+ switch (action) {
102
+ case 'create':
103
+ await handleCreate()
104
+ break
105
+ case 'list':
106
+ await handleList()
107
+ break
108
+ case 'start':
109
+ await handleStart()
110
+ break
111
+ case 'stop':
112
+ await handleStop()
113
+ break
114
+ case 'connect':
115
+ await handleConnect()
116
+ break
117
+ case 'restore':
118
+ await handleRestore()
119
+ break
120
+ case 'clone':
121
+ await handleClone()
122
+ break
123
+ case 'port':
124
+ await handleChangePort()
125
+ break
126
+ case 'delete':
127
+ await handleDelete()
128
+ break
129
+ case 'exit':
130
+ console.log(chalk.gray('\n Goodbye!\n'))
131
+ process.exit(0)
132
+ }
133
+
134
+ // Return to menu after action
135
+ await promptReturnToMenu()
136
+ }
137
+
138
+ async function promptReturnToMenu(): Promise<void> {
139
+ console.log()
140
+ const { returnToMenu } = await inquirer.prompt<{ returnToMenu: string }>([
141
+ {
142
+ type: 'list',
143
+ name: 'returnToMenu',
144
+ message: 'Return to main menu?',
145
+ choices: [
146
+ { name: 'Yes', value: 'yes' },
147
+ { name: 'No', value: 'no' },
148
+ ],
149
+ default: 'yes',
150
+ },
151
+ ])
152
+
153
+ if (returnToMenu === 'yes') {
154
+ await showMainMenu()
155
+ } else {
156
+ console.log(chalk.gray('\n Goodbye!\n'))
157
+ process.exit(0)
158
+ }
159
+ }
160
+
161
+ async function handleCreate(): Promise<void> {
162
+ console.log()
163
+ const answers = await promptCreateOptions()
164
+ const { name: containerName, engine, version } = answers
165
+
166
+ console.log()
167
+ console.log(header('Creating Database Container'))
168
+ console.log()
169
+
170
+ const dbEngine = getEngine(engine)
171
+
172
+ // Find available port
173
+ const portSpinner = createSpinner('Finding available port...')
174
+ portSpinner.start()
175
+
176
+ const { port, isDefault } = await portManager.findAvailablePort()
177
+ if (isDefault) {
178
+ portSpinner.succeed(`Using default port ${port}`)
179
+ } else {
180
+ portSpinner.warn(`Default port 5432 is in use, using port ${port}`)
181
+ }
182
+
183
+ // Ensure binaries
184
+ const binarySpinner = createSpinner(
185
+ `Checking PostgreSQL ${version} binaries...`,
186
+ )
187
+ binarySpinner.start()
188
+
189
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
190
+ if (isInstalled) {
191
+ binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
192
+ } else {
193
+ binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
194
+ await dbEngine.ensureBinaries(version, ({ message }) => {
195
+ binarySpinner.text = message
196
+ })
197
+ binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
198
+ }
199
+
200
+ // Create container
201
+ const createSpinnerInstance = createSpinner('Creating container...')
202
+ createSpinnerInstance.start()
203
+
204
+ await containerManager.create(containerName, {
205
+ engine: dbEngine.name,
206
+ version,
207
+ port,
208
+ })
209
+
210
+ createSpinnerInstance.succeed('Container created')
211
+
212
+ // Initialize database
213
+ const initSpinner = createSpinner('Initializing database...')
214
+ initSpinner.start()
215
+
216
+ await dbEngine.initDataDir(containerName, version, {
217
+ superuser: defaults.superuser,
218
+ })
219
+
220
+ initSpinner.succeed('Database initialized')
221
+
222
+ // Start container
223
+ const startSpinner = createSpinner('Starting PostgreSQL...')
224
+ startSpinner.start()
225
+
226
+ const config = await containerManager.getConfig(containerName)
227
+ if (config) {
228
+ await dbEngine.start(config)
229
+ await containerManager.updateConfig(containerName, { status: 'running' })
230
+ }
231
+
232
+ startSpinner.succeed('PostgreSQL started')
233
+
234
+ // Show success
235
+ if (config) {
236
+ const connectionString = dbEngine.getConnectionString(config)
237
+ console.log()
238
+ console.log(connectionBox(containerName, connectionString, port))
239
+ }
240
+ }
241
+
242
+ async function handleList(): Promise<void> {
243
+ console.log()
244
+ const containers = await containerManager.list()
245
+
246
+ if (containers.length === 0) {
247
+ console.log(
248
+ info('No containers found. Create one with the "Create" option.'),
249
+ )
250
+ return
251
+ }
252
+
253
+ // Table header
254
+ console.log()
255
+ console.log(
256
+ chalk.gray(' ') +
257
+ chalk.bold.white('NAME'.padEnd(20)) +
258
+ chalk.bold.white('ENGINE'.padEnd(12)) +
259
+ chalk.bold.white('VERSION'.padEnd(10)) +
260
+ chalk.bold.white('PORT'.padEnd(8)) +
261
+ chalk.bold.white('STATUS'),
262
+ )
263
+ console.log(chalk.gray(' ' + '─'.repeat(60)))
264
+
265
+ // Table rows
266
+ for (const container of containers) {
267
+ const statusDisplay =
268
+ container.status === 'running'
269
+ ? chalk.green('● running')
270
+ : chalk.gray('○ stopped')
271
+
272
+ console.log(
273
+ chalk.gray(' ') +
274
+ chalk.cyan(container.name.padEnd(20)) +
275
+ chalk.white(container.engine.padEnd(12)) +
276
+ chalk.yellow(container.version.padEnd(10)) +
277
+ chalk.green(String(container.port).padEnd(8)) +
278
+ statusDisplay,
279
+ )
280
+ }
281
+
282
+ console.log()
283
+
284
+ const running = containers.filter((c) => c.status === 'running').length
285
+ const stopped = containers.filter((c) => c.status !== 'running').length
286
+ console.log(
287
+ chalk.gray(
288
+ ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
289
+ ),
290
+ )
291
+ }
292
+
293
+ async function handleStart(): Promise<void> {
294
+ const containers = await containerManager.list()
295
+ const stopped = containers.filter((c) => c.status !== 'running')
296
+
297
+ if (stopped.length === 0) {
298
+ console.log(warning('All containers are already running'))
299
+ return
300
+ }
301
+
302
+ const containerName = await promptContainerSelect(
303
+ stopped,
304
+ 'Select container to start:',
305
+ )
306
+ if (!containerName) return
307
+
308
+ const config = await containerManager.getConfig(containerName)
309
+ if (!config) {
310
+ console.error(error(`Container "${containerName}" not found`))
311
+ return
312
+ }
313
+
314
+ // Check port availability
315
+ const portAvailable = await portManager.isPortAvailable(config.port)
316
+ if (!portAvailable) {
317
+ const { port: newPort } = await portManager.findAvailablePort()
318
+ console.log(
319
+ warning(`Port ${config.port} is in use, switching to port ${newPort}`),
320
+ )
321
+ config.port = newPort
322
+ await containerManager.updateConfig(containerName, { port: newPort })
323
+ }
324
+
325
+ const engine = getEngine(config.engine)
326
+
327
+ const spinner = createSpinner(`Starting ${containerName}...`)
328
+ spinner.start()
329
+
330
+ await engine.start(config)
331
+ await containerManager.updateConfig(containerName, { status: 'running' })
332
+
333
+ spinner.succeed(`Container "${containerName}" started`)
334
+
335
+ const connectionString = engine.getConnectionString(config)
336
+ console.log()
337
+ console.log(chalk.gray(' Connection string:'))
338
+ console.log(chalk.cyan(` ${connectionString}`))
339
+ }
340
+
341
+ async function handleStop(): Promise<void> {
342
+ const containers = await containerManager.list()
343
+ const running = containers.filter((c) => c.status === 'running')
344
+
345
+ if (running.length === 0) {
346
+ console.log(warning('No running containers'))
347
+ return
348
+ }
349
+
350
+ const containerName = await promptContainerSelect(
351
+ running,
352
+ 'Select container to stop:',
353
+ )
354
+ if (!containerName) return
355
+
356
+ const config = await containerManager.getConfig(containerName)
357
+ if (!config) {
358
+ console.error(error(`Container "${containerName}" not found`))
359
+ return
360
+ }
361
+
362
+ const engine = getEngine(config.engine)
363
+
364
+ const spinner = createSpinner(`Stopping ${containerName}...`)
365
+ spinner.start()
366
+
367
+ await engine.stop(config)
368
+ await containerManager.updateConfig(containerName, { status: 'stopped' })
369
+
370
+ spinner.succeed(`Container "${containerName}" stopped`)
371
+ }
372
+
373
+ async function handleConnect(): Promise<void> {
374
+ const containers = await containerManager.list()
375
+ const running = containers.filter((c) => c.status === 'running')
376
+
377
+ if (running.length === 0) {
378
+ console.log(warning('No running containers'))
379
+ return
380
+ }
381
+
382
+ const containerName = await promptContainerSelect(
383
+ running,
384
+ 'Select container to connect to:',
385
+ )
386
+ if (!containerName) return
387
+
388
+ const config = await containerManager.getConfig(containerName)
389
+ if (!config) {
390
+ console.error(error(`Container "${containerName}" not found`))
391
+ return
392
+ }
393
+
394
+ const engine = getEngine(config.engine)
395
+ const connectionString = engine.getConnectionString(config)
396
+
397
+ console.log(info(`Connecting to ${containerName}...`))
398
+ console.log()
399
+
400
+ // Spawn psql
401
+ const psqlProcess = spawn('psql', [connectionString], {
402
+ stdio: 'inherit',
403
+ })
404
+
405
+ psqlProcess.on('error', (err: NodeJS.ErrnoException) => {
406
+ if (err.code === 'ENOENT') {
407
+ console.log(warning('psql not found on your system.'))
408
+ console.log()
409
+ console.log(chalk.gray(' Connect manually with:'))
410
+ console.log(chalk.cyan(` ${connectionString}`))
411
+ console.log()
412
+ console.log(chalk.gray(' Install PostgreSQL client:'))
413
+ console.log(chalk.cyan(' brew install libpq && brew link --force libpq'))
414
+ }
415
+ })
416
+
417
+ await new Promise<void>((resolve) => {
418
+ psqlProcess.on('close', () => resolve())
419
+ })
420
+ }
421
+
422
+ async function handleRestore(): Promise<void> {
423
+ const containers = await containerManager.list()
424
+ const running = containers.filter((c) => c.status === 'running')
425
+
426
+ if (running.length === 0) {
427
+ console.log(warning('No running containers. Start one first.'))
428
+ return
429
+ }
430
+
431
+ const containerName = await promptContainerSelect(
432
+ running,
433
+ 'Select container to restore to:',
434
+ )
435
+ if (!containerName) return
436
+
437
+ const config = await containerManager.getConfig(containerName)
438
+ if (!config) {
439
+ console.error(error(`Container "${containerName}" not found`))
440
+ return
441
+ }
442
+
443
+ // Get backup file path
444
+ const { backupPath } = await inquirer.prompt<{ backupPath: string }>([
445
+ {
446
+ type: 'input',
447
+ name: 'backupPath',
448
+ message: 'Path to backup file:',
449
+ validate: (input: string) => {
450
+ if (!input) return 'Backup path is required'
451
+ if (!existsSync(input)) return 'File not found'
452
+ return true
453
+ },
454
+ },
455
+ ])
456
+
457
+ const databaseName = await promptDatabaseName(containerName)
458
+
459
+ const engine = getEngine(config.engine)
460
+
461
+ // Detect format
462
+ const detectSpinner = createSpinner('Detecting backup format...')
463
+ detectSpinner.start()
464
+
465
+ const format = await engine.detectBackupFormat(backupPath)
466
+ detectSpinner.succeed(`Detected: ${format.description}`)
467
+
468
+ // Create database
469
+ const dbSpinner = createSpinner(`Creating database "${databaseName}"...`)
470
+ dbSpinner.start()
471
+
472
+ await engine.createDatabase(config, databaseName)
473
+ dbSpinner.succeed(`Database "${databaseName}" ready`)
474
+
475
+ // Restore
476
+ const restoreSpinner = createSpinner('Restoring backup...')
477
+ restoreSpinner.start()
478
+
479
+ const result = await engine.restore(config, backupPath, {
480
+ database: databaseName,
481
+ createDatabase: false,
482
+ })
483
+
484
+ if (result.code === 0 || !result.stderr) {
485
+ restoreSpinner.succeed('Backup restored successfully')
486
+ } else {
487
+ restoreSpinner.warn('Restore completed with warnings')
488
+ }
489
+
490
+ const connectionString = engine.getConnectionString(config, databaseName)
491
+ console.log()
492
+ console.log(success(`Database "${databaseName}" restored`))
493
+ console.log(chalk.gray(' Connection string:'))
494
+ console.log(chalk.cyan(` ${connectionString}`))
495
+ }
496
+
497
+ async function handleClone(): Promise<void> {
498
+ const containers = await containerManager.list()
499
+ const stopped = containers.filter((c) => c.status !== 'running')
500
+
501
+ if (containers.length === 0) {
502
+ console.log(warning('No containers found'))
503
+ return
504
+ }
505
+
506
+ if (stopped.length === 0) {
507
+ console.log(
508
+ warning(
509
+ 'All containers are running. Stop a container first to clone it.',
510
+ ),
511
+ )
512
+ return
513
+ }
514
+
515
+ const sourceName = await promptContainerSelect(
516
+ stopped,
517
+ 'Select container to clone:',
518
+ )
519
+ if (!sourceName) return
520
+
521
+ const { targetName } = await inquirer.prompt<{ targetName: string }>([
522
+ {
523
+ type: 'input',
524
+ name: 'targetName',
525
+ message: 'Name for the cloned container:',
526
+ default: `${sourceName}-copy`,
527
+ validate: (input: string) => {
528
+ if (!input) return 'Name is required'
529
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
530
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
531
+ }
532
+ return true
533
+ },
534
+ },
535
+ ])
536
+
537
+ const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
538
+ spinner.start()
539
+
540
+ const newConfig = await containerManager.clone(sourceName, targetName)
541
+
542
+ spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
543
+
544
+ const engine = getEngine(newConfig.engine)
545
+ const connectionString = engine.getConnectionString(newConfig)
546
+
547
+ console.log()
548
+ console.log(connectionBox(targetName, connectionString, newConfig.port))
549
+ }
550
+
551
+ async function handleDelete(): Promise<void> {
552
+ const containers = await containerManager.list()
553
+
554
+ if (containers.length === 0) {
555
+ console.log(warning('No containers found'))
556
+ return
557
+ }
558
+
559
+ const containerName = await promptContainerSelect(
560
+ containers,
561
+ 'Select container to delete:',
562
+ )
563
+ if (!containerName) return
564
+
565
+ const config = await containerManager.getConfig(containerName)
566
+ if (!config) {
567
+ console.error(error(`Container "${containerName}" not found`))
568
+ return
569
+ }
570
+
571
+ const confirmed = await promptConfirm(
572
+ `Are you sure you want to delete "${containerName}"? This cannot be undone.`,
573
+ false,
574
+ )
575
+
576
+ if (!confirmed) {
577
+ console.log(warning('Deletion cancelled'))
578
+ return
579
+ }
580
+
581
+ const isRunning = await processManager.isRunning(containerName)
582
+
583
+ if (isRunning) {
584
+ const stopSpinner = createSpinner(`Stopping ${containerName}...`)
585
+ stopSpinner.start()
586
+
587
+ const engine = getEngine(config.engine)
588
+ await engine.stop(config)
589
+
590
+ stopSpinner.succeed(`Stopped "${containerName}"`)
591
+ }
592
+
593
+ const deleteSpinner = createSpinner(`Deleting ${containerName}...`)
594
+ deleteSpinner.start()
595
+
596
+ await containerManager.delete(containerName, { force: true })
597
+
598
+ deleteSpinner.succeed(`Container "${containerName}" deleted`)
599
+ }
600
+
601
+ async function handleChangePort(): Promise<void> {
602
+ const containers = await containerManager.list()
603
+ const stopped = containers.filter((c) => c.status !== 'running')
604
+
605
+ if (stopped.length === 0) {
606
+ console.log(
607
+ warning(
608
+ 'No stopped containers. Stop a container first to change its port.',
609
+ ),
610
+ )
611
+ return
612
+ }
613
+
614
+ const containerName = await promptContainerSelect(
615
+ stopped,
616
+ 'Select container to change port:',
617
+ )
618
+ if (!containerName) return
619
+
620
+ const config = await containerManager.getConfig(containerName)
621
+ if (!config) {
622
+ console.error(error(`Container "${containerName}" not found`))
623
+ return
624
+ }
625
+
626
+ console.log(chalk.gray(` Current port: ${config.port}`))
627
+ console.log()
628
+
629
+ const { newPort } = await inquirer.prompt<{ newPort: number }>([
630
+ {
631
+ type: 'input',
632
+ name: 'newPort',
633
+ message: 'New port:',
634
+ default: String(config.port),
635
+ validate: (input: string) => {
636
+ const num = parseInt(input, 10)
637
+ if (isNaN(num) || num < 1 || num > 65535) {
638
+ return 'Port must be a number between 1 and 65535'
639
+ }
640
+ return true
641
+ },
642
+ filter: (input: string) => parseInt(input, 10),
643
+ },
644
+ ])
645
+
646
+ if (newPort === config.port) {
647
+ console.log(info('Port unchanged'))
648
+ return
649
+ }
650
+
651
+ // Check if port is available
652
+ const portAvailable = await portManager.isPortAvailable(newPort)
653
+ if (!portAvailable) {
654
+ console.log(warning(`Port ${newPort} is already in use`))
655
+ return
656
+ }
657
+
658
+ await containerManager.updateConfig(containerName, { port: newPort })
659
+
660
+ console.log(
661
+ success(`Changed ${containerName} port from ${config.port} to ${newPort}`),
662
+ )
663
+ }
664
+
665
+ export const menuCommand = new Command('menu')
666
+ .description('Interactive menu for managing containers')
667
+ .action(async () => {
668
+ try {
669
+ await showMainMenu()
670
+ } catch (err) {
671
+ const e = err as Error
672
+ console.error(error(e.message))
673
+ process.exit(1)
674
+ }
675
+ })