spindb 0.4.0 → 0.5.2

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.
@@ -5,6 +5,7 @@ import { processManager } from '../../core/process-manager'
5
5
  import { getEngine } from '../../engines'
6
6
  import {
7
7
  promptContainerSelect,
8
+ promptContainerName,
8
9
  promptDatabaseName,
9
10
  promptCreateOptions,
10
11
  promptConfirm,
@@ -27,7 +28,9 @@ import { join } from 'path'
27
28
  import { paths } from '../../config/paths'
28
29
  import { portManager } from '../../core/port-manager'
29
30
  import { defaults } from '../../config/defaults'
31
+ import type { EngineName } from '../../types'
30
32
  import inquirer from 'inquirer'
33
+ import { getMissingDependencies } from '../../core/dependency-manager'
31
34
 
32
35
  type MenuChoice =
33
36
  | {
@@ -37,6 +40,14 @@ type MenuChoice =
37
40
  }
38
41
  | inquirer.Separator
39
42
 
43
+ /**
44
+ * Engine icons for display
45
+ */
46
+ const engineIcons: Record<string, string> = {
47
+ postgresql: '🐘',
48
+ mysql: '🐬',
49
+ }
50
+
40
51
  async function showMainMenu(): Promise<void> {
41
52
  console.clear()
42
53
  console.log(header('SpinDB - Local Database Manager'))
@@ -55,7 +66,6 @@ async function showMainMenu(): Promise<void> {
55
66
 
56
67
  const canStart = stopped > 0
57
68
  const canStop = running > 0
58
- const canConnect = running > 0
59
69
  const canRestore = running > 0
60
70
  const canClone = containers.length > 0
61
71
 
@@ -90,13 +100,6 @@ async function showMainMenu(): Promise<void> {
90
100
  value: 'stop',
91
101
  disabled: canStop ? false : 'No running containers',
92
102
  },
93
- {
94
- name: canConnect
95
- ? `${chalk.blue('⌘')} Open psql shell`
96
- : chalk.gray('⌘ Open psql shell'),
97
- value: 'connect',
98
- disabled: canConnect ? false : 'No running containers',
99
- },
100
103
  {
101
104
  name: canRestore
102
105
  ? `${chalk.magenta('↓')} Restore backup`
@@ -145,9 +148,6 @@ async function showMainMenu(): Promise<void> {
145
148
  case 'stop':
146
149
  await handleStop()
147
150
  break
148
- case 'connect':
149
- await handleConnect()
150
- break
151
151
  case 'restore':
152
152
  await handleRestore()
153
153
  break
@@ -169,7 +169,8 @@ async function showMainMenu(): Promise<void> {
169
169
  async function handleCreate(): Promise<void> {
170
170
  console.log()
171
171
  const answers = await promptCreateOptions()
172
- const { name: containerName, engine, version, port, database } = answers
172
+ let { name: containerName } = answers
173
+ const { engine, version, port, database } = answers
173
174
 
174
175
  console.log()
175
176
  console.log(header('Creating Database Container'))
@@ -177,6 +178,41 @@ async function handleCreate(): Promise<void> {
177
178
 
178
179
  const dbEngine = getEngine(engine)
179
180
 
181
+ // Check for required client tools BEFORE creating anything
182
+ const depsSpinner = createSpinner('Checking required tools...')
183
+ depsSpinner.start()
184
+
185
+ let missingDeps = await getMissingDependencies(engine)
186
+ if (missingDeps.length > 0) {
187
+ depsSpinner.warn(
188
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
189
+ )
190
+
191
+ // Offer to install
192
+ const installed = await promptInstallDependencies(
193
+ missingDeps[0].binary,
194
+ engine,
195
+ )
196
+
197
+ if (!installed) {
198
+ return
199
+ }
200
+
201
+ // Verify installation worked
202
+ missingDeps = await getMissingDependencies(engine)
203
+ if (missingDeps.length > 0) {
204
+ console.log(
205
+ error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
206
+ )
207
+ return
208
+ }
209
+
210
+ console.log(chalk.green(' ✓ All required tools are now available'))
211
+ console.log()
212
+ } else {
213
+ depsSpinner.succeed('Required tools available')
214
+ }
215
+
180
216
  // Check if port is currently in use
181
217
  const portAvailable = await portManager.isPortAvailable(port)
182
218
 
@@ -197,12 +233,18 @@ async function handleCreate(): Promise<void> {
197
233
  binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
198
234
  }
199
235
 
236
+ // Check if container name already exists and prompt for new name if needed
237
+ while (await containerManager.exists(containerName)) {
238
+ console.log(chalk.yellow(` Container "${containerName}" already exists.`))
239
+ containerName = await promptContainerName()
240
+ }
241
+
200
242
  // Create container
201
243
  const createSpinnerInstance = createSpinner('Creating container...')
202
244
  createSpinnerInstance.start()
203
245
 
204
246
  await containerManager.create(containerName, {
205
- engine: dbEngine.name,
247
+ engine: dbEngine.name as EngineName,
206
248
  version,
207
249
  port,
208
250
  database,
@@ -318,6 +360,15 @@ async function handleList(): Promise<void> {
318
360
  console.log(
319
361
  info('No containers found. Create one with the "Create" option.'),
320
362
  )
363
+ console.log()
364
+
365
+ await inquirer.prompt([
366
+ {
367
+ type: 'input',
368
+ name: 'continue',
369
+ message: chalk.gray('Press Enter to return to the main menu...'),
370
+ },
371
+ ])
321
372
  return
322
373
  }
323
374
 
@@ -364,7 +415,7 @@ async function handleList(): Promise<void> {
364
415
  console.log()
365
416
  const containerChoices = [
366
417
  ...containers.map((c) => ({
367
- name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${
418
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${
368
419
  c.status === 'running'
369
420
  ? chalk.green('● running')
370
421
  : chalk.gray('○ stopped')
@@ -403,7 +454,9 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
403
454
  }
404
455
 
405
456
  // Check actual running state
406
- const isRunning = await processManager.isRunning(containerName)
457
+ const isRunning = await processManager.isRunning(containerName, {
458
+ engine: config.engine,
459
+ })
407
460
  const status = isRunning ? 'running' : 'stopped'
408
461
 
409
462
  console.clear()
@@ -421,6 +474,13 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
421
474
  !isRunning
422
475
  ? { name: `${chalk.green('▶')} Start container`, value: 'start' }
423
476
  : { name: `${chalk.yellow('■')} Stop container`, value: 'stop' },
477
+ {
478
+ name: isRunning
479
+ ? `${chalk.blue('⌘')} Open shell`
480
+ : chalk.gray('⌘ Open shell'),
481
+ value: 'shell',
482
+ disabled: isRunning ? false : 'Start container first',
483
+ },
424
484
  {
425
485
  name: !isRunning
426
486
  ? `${chalk.white('⚙')} Edit container`
@@ -460,6 +520,10 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
460
520
  await handleStopContainer(containerName)
461
521
  await showContainerSubmenu(containerName)
462
522
  return
523
+ case 'shell':
524
+ await handleOpenShell(containerName)
525
+ await showContainerSubmenu(containerName)
526
+ return
463
527
  case 'edit': {
464
528
  const newName = await handleEditContainer(containerName)
465
529
  if (newName === null) {
@@ -630,21 +694,7 @@ async function handleCopyConnectionString(
630
694
  }
631
695
  }
632
696
 
633
- async function handleConnect(): Promise<void> {
634
- const containers = await containerManager.list()
635
- const running = containers.filter((c) => c.status === 'running')
636
-
637
- if (running.length === 0) {
638
- console.log(warning('No running containers'))
639
- return
640
- }
641
-
642
- const containerName = await promptContainerSelect(
643
- running,
644
- 'Select container to connect to:',
645
- )
646
- if (!containerName) return
647
-
697
+ async function handleOpenShell(containerName: string): Promise<void> {
648
698
  const config = await containerManager.getConfig(containerName)
649
699
  if (!config) {
650
700
  console.error(error(`Container "${containerName}" not found`))
@@ -657,25 +707,49 @@ async function handleConnect(): Promise<void> {
657
707
  console.log(info(`Connecting to ${containerName}...`))
658
708
  console.log()
659
709
 
660
- // Spawn psql
661
- const psqlProcess = spawn('psql', [connectionString], {
710
+ // Determine shell command based on engine
711
+ let shellCmd: string
712
+ let shellArgs: string[]
713
+ let installHint: string
714
+
715
+ if (config.engine === 'mysql') {
716
+ shellCmd = 'mysql'
717
+ // MySQL connection: mysql -u root -h 127.0.0.1 -P port database
718
+ shellArgs = [
719
+ '-u',
720
+ 'root',
721
+ '-h',
722
+ '127.0.0.1',
723
+ '-P',
724
+ String(config.port),
725
+ config.database,
726
+ ]
727
+ installHint = 'brew install mysql-client'
728
+ } else {
729
+ // PostgreSQL (default)
730
+ shellCmd = 'psql'
731
+ shellArgs = [connectionString]
732
+ installHint = 'brew install libpq && brew link --force libpq'
733
+ }
734
+
735
+ const shellProcess = spawn(shellCmd, shellArgs, {
662
736
  stdio: 'inherit',
663
737
  })
664
738
 
665
- psqlProcess.on('error', (err: NodeJS.ErrnoException) => {
739
+ shellProcess.on('error', (err: NodeJS.ErrnoException) => {
666
740
  if (err.code === 'ENOENT') {
667
- console.log(warning('psql not found on your system.'))
741
+ console.log(warning(`${shellCmd} not found on your system.`))
668
742
  console.log()
669
743
  console.log(chalk.gray(' Connect manually with:'))
670
744
  console.log(chalk.cyan(` ${connectionString}`))
671
745
  console.log()
672
- console.log(chalk.gray(' Install PostgreSQL client:'))
673
- console.log(chalk.cyan(' brew install libpq && brew link --force libpq'))
746
+ console.log(chalk.gray(` Install ${config.engine} client:`))
747
+ console.log(chalk.cyan(` ${installHint}`))
674
748
  }
675
749
  })
676
750
 
677
751
  await new Promise<void>((resolve) => {
678
- psqlProcess.on('close', () => resolve())
752
+ shellProcess.on('close', () => resolve())
679
753
  })
680
754
  }
681
755
 
@@ -689,7 +763,8 @@ async function handleCreateForRestore(): Promise<{
689
763
  } | null> {
690
764
  console.log()
691
765
  const answers = await promptCreateOptions()
692
- const { name: containerName, engine, version, port, database } = answers
766
+ let { name: containerName } = answers
767
+ const { engine, version, port, database } = answers
693
768
 
694
769
  console.log()
695
770
  console.log(header('Creating Database Container'))
@@ -723,12 +798,18 @@ async function handleCreateForRestore(): Promise<{
723
798
  binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
724
799
  }
725
800
 
801
+ // Check if container name already exists and prompt for new name if needed
802
+ while (await containerManager.exists(containerName)) {
803
+ console.log(chalk.yellow(` Container "${containerName}" already exists.`))
804
+ containerName = await promptContainerName()
805
+ }
806
+
726
807
  // Create container
727
808
  const createSpinnerInstance = createSpinner('Creating container...')
728
809
  createSpinnerInstance.start()
729
810
 
730
811
  await containerManager.create(containerName, {
731
- engine: dbEngine.name,
812
+ engine: dbEngine.name as EngineName,
732
813
  version,
733
814
  port,
734
815
  database,
@@ -785,7 +866,7 @@ async function handleRestore(): Promise<void> {
785
866
  // Build choices: running containers + create new option
786
867
  const choices = [
787
868
  ...running.map((c) => ({
788
- name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
869
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
789
870
  value: c.name,
790
871
  short: c.name,
791
872
  })),
@@ -826,6 +907,41 @@ async function handleRestore(): Promise<void> {
826
907
  }
827
908
  }
828
909
 
910
+ // Check for required client tools BEFORE doing anything
911
+ const depsSpinner = createSpinner('Checking required tools...')
912
+ depsSpinner.start()
913
+
914
+ let missingDeps = await getMissingDependencies(config.engine)
915
+ if (missingDeps.length > 0) {
916
+ depsSpinner.warn(
917
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
918
+ )
919
+
920
+ // Offer to install
921
+ const installed = await promptInstallDependencies(
922
+ missingDeps[0].binary,
923
+ config.engine,
924
+ )
925
+
926
+ if (!installed) {
927
+ return
928
+ }
929
+
930
+ // Verify installation worked
931
+ missingDeps = await getMissingDependencies(config.engine)
932
+ if (missingDeps.length > 0) {
933
+ console.log(
934
+ error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
935
+ )
936
+ return
937
+ }
938
+
939
+ console.log(chalk.green(' ✓ All required tools are now available'))
940
+ console.log()
941
+ } else {
942
+ depsSpinner.succeed('Required tools available')
943
+ }
944
+
829
945
  // Ask for restore source
830
946
  const { restoreSource } = await inquirer.prompt<{
831
947
  restoreSource: 'file' | 'connection'
@@ -847,7 +963,7 @@ async function handleRestore(): Promise<void> {
847
963
  },
848
964
  ])
849
965
 
850
- let backupPath: string
966
+ let backupPath = ''
851
967
  let isTempFile = false
852
968
 
853
969
  if (restoreSource === 'connection') {
@@ -878,46 +994,64 @@ async function handleRestore(): Promise<void> {
878
994
  const timestamp = Date.now()
879
995
  const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
880
996
 
881
- const dumpSpinner = createSpinner('Creating dump from remote database...')
882
- dumpSpinner.start()
997
+ let dumpSuccess = false
998
+ let attempts = 0
999
+ const maxAttempts = 2 // Allow one retry after installing deps
883
1000
 
884
- try {
885
- await engine.dumpFromConnectionString(connectionString, tempDumpPath)
886
- dumpSpinner.succeed('Dump created from remote database')
887
- backupPath = tempDumpPath
888
- isTempFile = true
889
- } catch (err) {
890
- const e = err as Error
891
- dumpSpinner.fail('Failed to create dump')
1001
+ while (!dumpSuccess && attempts < maxAttempts) {
1002
+ attempts++
1003
+ const dumpSpinner = createSpinner('Creating dump from remote database...')
1004
+ dumpSpinner.start()
892
1005
 
893
- // Clean up temp file if it was created
894
1006
  try {
895
- await rm(tempDumpPath, { force: true })
896
- } catch {
897
- // Ignore cleanup errors
898
- }
1007
+ await engine.dumpFromConnectionString(connectionString, tempDumpPath)
1008
+ dumpSpinner.succeed('Dump created from remote database')
1009
+ backupPath = tempDumpPath
1010
+ isTempFile = true
1011
+ dumpSuccess = true
1012
+ } catch (err) {
1013
+ const e = err as Error
1014
+ dumpSpinner.fail('Failed to create dump')
1015
+
1016
+ // Check if this is a missing tool error
1017
+ if (
1018
+ e.message.includes('pg_dump not found') ||
1019
+ e.message.includes('ENOENT')
1020
+ ) {
1021
+ const installed = await promptInstallDependencies('pg_dump')
1022
+ if (installed) {
1023
+ // Loop will retry
1024
+ continue
1025
+ }
1026
+ } else {
1027
+ console.log()
1028
+ console.log(error('pg_dump error:'))
1029
+ console.log(chalk.gray(` ${e.message}`))
1030
+ console.log()
1031
+ }
899
1032
 
900
- // Check if this is a missing tool error
901
- if (
902
- e.message.includes('pg_dump not found') ||
903
- e.message.includes('ENOENT')
904
- ) {
905
- await promptInstallDependencies('pg_dump')
906
- } else {
907
- console.log()
908
- console.log(error('pg_dump error:'))
909
- console.log(chalk.gray(` ${e.message}`))
910
- console.log()
1033
+ // Clean up temp file if it was created
1034
+ try {
1035
+ await rm(tempDumpPath, { force: true })
1036
+ } catch {
1037
+ // Ignore cleanup errors
1038
+ }
1039
+
1040
+ // Wait for user to see the error
1041
+ await inquirer.prompt([
1042
+ {
1043
+ type: 'input',
1044
+ name: 'continue',
1045
+ message: chalk.gray('Press Enter to continue...'),
1046
+ },
1047
+ ])
1048
+ return
911
1049
  }
1050
+ }
912
1051
 
913
- // Wait for user to see the error
914
- await inquirer.prompt([
915
- {
916
- type: 'input',
917
- name: 'continue',
918
- message: chalk.gray('Press Enter to continue...'),
919
- },
920
- ])
1052
+ // Safety check - should never reach here without backupPath set
1053
+ if (!dumpSuccess) {
1054
+ console.log(error('Failed to create dump after retries'))
921
1055
  return
922
1056
  }
923
1057
  } else {
@@ -1248,6 +1382,15 @@ async function handleStartContainer(containerName: string): Promise<void> {
1248
1382
  `Port ${config.port} is in use. Stop the process using it or change this container's port.`,
1249
1383
  ),
1250
1384
  )
1385
+ console.log()
1386
+ console.log(
1387
+ info(
1388
+ 'Tip: If you installed MariaDB via apt, it may have started a system service.',
1389
+ ),
1390
+ )
1391
+ console.log(
1392
+ info('Run: sudo systemctl stop mariadb && sudo systemctl disable mariadb'),
1393
+ )
1251
1394
  return
1252
1395
  }
1253
1396
 
@@ -1256,15 +1399,31 @@ async function handleStartContainer(containerName: string): Promise<void> {
1256
1399
  const spinner = createSpinner(`Starting ${containerName}...`)
1257
1400
  spinner.start()
1258
1401
 
1259
- await engine.start(config)
1260
- await containerManager.updateConfig(containerName, { status: 'running' })
1402
+ try {
1403
+ await engine.start(config)
1404
+ await containerManager.updateConfig(containerName, { status: 'running' })
1261
1405
 
1262
- spinner.succeed(`Container "${containerName}" started`)
1406
+ spinner.succeed(`Container "${containerName}" started`)
1263
1407
 
1264
- const connectionString = engine.getConnectionString(config)
1265
- console.log()
1266
- console.log(chalk.gray(' Connection string:'))
1267
- console.log(chalk.cyan(` ${connectionString}`))
1408
+ const connectionString = engine.getConnectionString(config)
1409
+ console.log()
1410
+ console.log(chalk.gray(' Connection string:'))
1411
+ console.log(chalk.cyan(` ${connectionString}`))
1412
+ } catch (err) {
1413
+ spinner.fail(`Failed to start "${containerName}"`)
1414
+ const e = err as Error
1415
+ console.log()
1416
+ console.log(error(e.message))
1417
+
1418
+ // Check if there's a log file with more details
1419
+ const logPath = paths.getContainerLogPath(containerName, {
1420
+ engine: config.engine,
1421
+ })
1422
+ if (existsSync(logPath)) {
1423
+ console.log()
1424
+ console.log(info(`Check the log file for details: ${logPath}`))
1425
+ }
1426
+ }
1268
1427
  }
1269
1428
 
1270
1429
  async function handleStopContainer(containerName: string): Promise<void> {
@@ -1462,7 +1621,9 @@ async function handleDelete(containerName: string): Promise<void> {
1462
1621
  return
1463
1622
  }
1464
1623
 
1465
- const isRunning = await processManager.isRunning(containerName)
1624
+ const isRunning = await processManager.isRunning(containerName, {
1625
+ engine: config.engine,
1626
+ })
1466
1627
 
1467
1628
  if (isRunning) {
1468
1629
  const stopSpinner = createSpinner(`Stopping ${containerName}...`)
@@ -1727,7 +1888,12 @@ export const menuCommand = new Command('menu')
1727
1888
  : e.message.includes('pg_dump')
1728
1889
  ? 'pg_dump'
1729
1890
  : 'psql'
1730
- await promptInstallDependencies(missingTool)
1891
+ const installed = await promptInstallDependencies(missingTool)
1892
+ if (installed) {
1893
+ console.log(
1894
+ chalk.yellow(' Please re-run spindb to continue.'),
1895
+ )
1896
+ }
1731
1897
  process.exit(1)
1732
1898
  }
1733
1899