spindb 0.5.2 → 0.5.4

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 (38) hide show
  1. package/README.md +188 -9
  2. package/cli/commands/connect.ts +334 -105
  3. package/cli/commands/create.ts +106 -67
  4. package/cli/commands/deps.ts +19 -4
  5. package/cli/commands/edit.ts +245 -0
  6. package/cli/commands/engines.ts +434 -0
  7. package/cli/commands/info.ts +279 -0
  8. package/cli/commands/list.ts +1 -1
  9. package/cli/commands/menu.ts +664 -167
  10. package/cli/commands/restore.ts +11 -25
  11. package/cli/commands/start.ts +25 -20
  12. package/cli/commands/url.ts +79 -0
  13. package/cli/index.ts +9 -3
  14. package/cli/ui/prompts.ts +20 -12
  15. package/cli/ui/theme.ts +1 -1
  16. package/config/engine-defaults.ts +24 -1
  17. package/config/os-dependencies.ts +151 -113
  18. package/config/paths.ts +7 -36
  19. package/core/binary-manager.ts +12 -6
  20. package/core/config-manager.ts +17 -5
  21. package/core/dependency-manager.ts +144 -15
  22. package/core/error-handler.ts +336 -0
  23. package/core/platform-service.ts +634 -0
  24. package/core/port-manager.ts +11 -3
  25. package/core/process-manager.ts +12 -2
  26. package/core/start-with-retry.ts +167 -0
  27. package/core/transaction-manager.ts +170 -0
  28. package/engines/mysql/binary-detection.ts +177 -100
  29. package/engines/mysql/index.ts +240 -131
  30. package/engines/mysql/restore.ts +257 -0
  31. package/engines/mysql/version-validator.ts +373 -0
  32. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  33. package/engines/postgresql/binary-urls.ts +5 -3
  34. package/engines/postgresql/index.ts +35 -4
  35. package/engines/postgresql/restore.ts +54 -5
  36. package/engines/postgresql/version-validator.ts +262 -0
  37. package/package.json +6 -2
  38. package/cli/commands/postgres-tools.ts +0 -216
@@ -22,15 +22,36 @@ import {
22
22
  } from '../ui/theme'
23
23
  import { existsSync } from 'fs'
24
24
  import { readdir, rm, lstat } from 'fs/promises'
25
- import { spawn } from 'child_process'
26
- import { platform, tmpdir } from 'os'
25
+ import { spawn, exec } from 'child_process'
26
+ import { promisify } from 'util'
27
+ import { tmpdir } from 'os'
27
28
  import { join } from 'path'
28
29
  import { paths } from '../../config/paths'
30
+ import { platformService } from '../../core/platform-service'
29
31
  import { portManager } from '../../core/port-manager'
30
32
  import { defaults } from '../../config/defaults'
33
+ import { getPostgresHomebrewPackage } from '../../config/engine-defaults'
31
34
  import type { EngineName } from '../../types'
32
35
  import inquirer from 'inquirer'
33
- import { getMissingDependencies } from '../../core/dependency-manager'
36
+ import {
37
+ getMissingDependencies,
38
+ isUsqlInstalled,
39
+ isPgcliInstalled,
40
+ isMycliInstalled,
41
+ detectPackageManager,
42
+ installUsql,
43
+ installPgcli,
44
+ installMycli,
45
+ getUsqlManualInstructions,
46
+ getPgcliManualInstructions,
47
+ getMycliManualInstructions,
48
+ } from '../../core/dependency-manager'
49
+ import {
50
+ getMysqldPath,
51
+ getMysqlVersion,
52
+ isMariaDB,
53
+ getMysqlInstallInfo,
54
+ } from '../../engines/mysql/binary-detection'
34
55
 
35
56
  type MenuChoice =
36
57
  | {
@@ -48,6 +69,19 @@ const engineIcons: Record<string, string> = {
48
69
  mysql: '🐬',
49
70
  }
50
71
 
72
+ /**
73
+ * Helper to pause and wait for user to press Enter
74
+ */
75
+ async function pressEnterToContinue(): Promise<void> {
76
+ await inquirer.prompt([
77
+ {
78
+ type: 'input',
79
+ name: 'continue',
80
+ message: chalk.gray('Press Enter to continue...'),
81
+ },
82
+ ])
83
+ }
84
+
51
85
  async function showMainMenu(): Promise<void> {
52
86
  console.clear()
53
87
  console.log(header('SpinDB - Local Database Manager'))
@@ -79,12 +113,12 @@ async function showMainMenu(): Promise<void> {
79
113
  const choices: MenuChoice[] = [
80
114
  ...(hasContainers
81
115
  ? [
82
- { name: `${chalk.cyan('◉')} List containers`, value: 'list' },
116
+ { name: `${chalk.cyan('◉')} Containers`, value: 'list' },
83
117
  { name: `${chalk.green('+')} Create new container`, value: 'create' },
84
118
  ]
85
119
  : [
86
120
  { name: `${chalk.green('+')} Create new container`, value: 'create' },
87
- { name: `${chalk.cyan('◉')} List containers`, value: 'list' },
121
+ { name: `${chalk.cyan('◉')} Containers`, value: 'list' },
88
122
  ]),
89
123
  {
90
124
  name: canStart
@@ -95,7 +129,7 @@ async function showMainMenu(): Promise<void> {
95
129
  },
96
130
  {
97
131
  name: canStop
98
- ? `${chalk.yellow('■')} Stop a container`
132
+ ? `${chalk.red('■')} Stop a container`
99
133
  : chalk.gray('■ Stop a container'),
100
134
  value: 'stop',
101
135
  disabled: canStop ? false : 'No running containers',
@@ -202,7 +236,9 @@ async function handleCreate(): Promise<void> {
202
236
  missingDeps = await getMissingDependencies(engine)
203
237
  if (missingDeps.length > 0) {
204
238
  console.log(
205
- error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
239
+ error(
240
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
241
+ ),
206
242
  )
207
243
  return
208
244
  }
@@ -301,25 +337,14 @@ async function handleCreate(): Promise<void> {
301
337
  console.log(chalk.gray(' Connection string:'))
302
338
  console.log(chalk.cyan(` ${connectionString}`))
303
339
 
304
- // Copy connection string to clipboard using platform-specific command
340
+ // Copy connection string to clipboard using platform service
305
341
  try {
306
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
307
- const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
308
-
309
- await new Promise<void>((resolve, reject) => {
310
- const proc = spawn(cmd, args, {
311
- stdio: ['pipe', 'inherit', 'inherit'],
312
- })
313
- proc.stdin?.write(connectionString)
314
- proc.stdin?.end()
315
- proc.on('close', (code) => {
316
- if (code === 0) resolve()
317
- else reject(new Error(`Clipboard command exited with code ${code}`))
318
- })
319
- proc.on('error', reject)
320
- })
321
-
322
- console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
342
+ const copied = await platformService.copyToClipboard(connectionString)
343
+ if (copied) {
344
+ console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
345
+ } else {
346
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
347
+ }
323
348
  } catch {
324
349
  console.log(chalk.gray(' (Could not copy to clipboard)'))
325
350
  }
@@ -415,7 +440,7 @@ async function handleList(): Promise<void> {
415
440
  console.log()
416
441
  const containerChoices = [
417
442
  ...containers.map((c) => ({
418
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${
443
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || ''} ${c.engine} ${c.version}, port ${c.port})`)} ${
419
444
  c.status === 'running'
420
445
  ? chalk.green('● running')
421
446
  : chalk.gray('○ stopped')
@@ -435,6 +460,7 @@ async function handleList(): Promise<void> {
435
460
  name: 'selectedContainer',
436
461
  message: 'Select a container for more options:',
437
462
  choices: containerChoices,
463
+ pageSize: 15,
438
464
  },
439
465
  ])
440
466
 
@@ -473,7 +499,7 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
473
499
  // Start or Stop depending on current state
474
500
  !isRunning
475
501
  ? { name: `${chalk.green('▶')} Start container`, value: 'start' }
476
- : { name: `${chalk.yellow('■')} Stop container`, value: 'stop' },
502
+ : { name: `${chalk.red('■')} Stop container`, value: 'stop' },
477
503
  {
478
504
  name: isRunning
479
505
  ? `${chalk.blue('⌘')} Open shell`
@@ -496,10 +522,16 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
496
522
  disabled: !isRunning ? false : 'Stop container first',
497
523
  },
498
524
  { name: `${chalk.magenta('⎘')} Copy connection string`, value: 'copy' },
499
- { name: `${chalk.red('✕')} Delete container`, value: 'delete' },
525
+ {
526
+ name: !isRunning
527
+ ? `${chalk.red('✕')} Delete container`
528
+ : chalk.gray('✕ Delete container'),
529
+ value: 'delete',
530
+ disabled: !isRunning ? false : 'Stop container first',
531
+ },
500
532
  new inquirer.Separator(),
501
- { name: `${chalk.blue('←')} Back to container list`, value: 'back' },
502
- { name: `${chalk.blue('🏠')} Back to main menu`, value: 'main' },
533
+ { name: `${chalk.blue('←')} Back to containers`, value: 'back' },
534
+ { name: `${chalk.blue('')} Back to main menu`, value: 'main' },
503
535
  ]
504
536
 
505
537
  const { action } = await inquirer.prompt<{ action: string }>([
@@ -508,6 +540,7 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
508
540
  name: 'action',
509
541
  message: 'What would you like to do?',
510
542
  choices: actionChoices,
543
+ pageSize: 15,
511
544
  },
512
545
  ])
513
546
 
@@ -648,50 +681,26 @@ async function handleCopyConnectionString(
648
681
  const engine = getEngine(config.engine)
649
682
  const connectionString = engine.getConnectionString(config)
650
683
 
651
- // Copy to clipboard using platform-specific command
652
- const { platform } = await import('os')
653
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
654
- const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
655
-
656
- try {
657
- await new Promise<void>((resolve, reject) => {
658
- const proc = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'] })
659
- proc.stdin?.write(connectionString)
660
- proc.stdin?.end()
661
- proc.on('close', (code) => {
662
- if (code === 0) resolve()
663
- else reject(new Error(`Clipboard command exited with code ${code}`))
664
- })
665
- proc.on('error', reject)
666
- })
684
+ // Copy to clipboard using platform service
685
+ const copied = await platformService.copyToClipboard(connectionString)
667
686
 
668
- console.log()
687
+ console.log()
688
+ if (copied) {
669
689
  console.log(success('Connection string copied to clipboard'))
670
690
  console.log(chalk.gray(` ${connectionString}`))
671
- console.log()
672
-
673
- await inquirer.prompt([
674
- {
675
- type: 'input',
676
- name: 'continue',
677
- message: chalk.gray('Press Enter to continue...'),
678
- },
679
- ])
680
- } catch {
681
- // Fallback: just display the string
682
- console.log()
691
+ } else {
683
692
  console.log(warning('Could not copy to clipboard. Connection string:'))
684
693
  console.log(chalk.cyan(` ${connectionString}`))
685
- console.log()
686
-
687
- await inquirer.prompt([
688
- {
689
- type: 'input',
690
- name: 'continue',
691
- message: chalk.gray('Press Enter to continue...'),
692
- },
693
- ])
694
694
  }
695
+ console.log()
696
+
697
+ await inquirer.prompt([
698
+ {
699
+ type: 'input',
700
+ name: 'continue',
701
+ message: chalk.gray('Press Enter to continue...'),
702
+ },
703
+ ])
695
704
  }
696
705
 
697
706
  async function handleOpenShell(containerName: string): Promise<void> {
@@ -704,15 +713,222 @@ async function handleOpenShell(containerName: string): Promise<void> {
704
713
  const engine = getEngine(config.engine)
705
714
  const connectionString = engine.getConnectionString(config)
706
715
 
716
+ // Check which enhanced shells are installed
717
+ const usqlInstalled = await isUsqlInstalled()
718
+ const pgcliInstalled = await isPgcliInstalled()
719
+ const mycliInstalled = await isMycliInstalled()
720
+
721
+ type ShellChoice =
722
+ | 'default'
723
+ | 'usql'
724
+ | 'install-usql'
725
+ | 'pgcli'
726
+ | 'install-pgcli'
727
+ | 'mycli'
728
+ | 'install-mycli'
729
+ | 'back'
730
+
731
+ const defaultShellName = config.engine === 'mysql' ? 'mysql' : 'psql'
732
+ const engineSpecificCli = config.engine === 'mysql' ? 'mycli' : 'pgcli'
733
+ const engineSpecificInstalled =
734
+ config.engine === 'mysql' ? mycliInstalled : pgcliInstalled
735
+
736
+ const choices: Array<{ name: string; value: ShellChoice }> = [
737
+ {
738
+ name: `>_ Use default shell (${defaultShellName})`,
739
+ value: 'default',
740
+ },
741
+ ]
742
+
743
+ // Engine-specific enhanced CLI (pgcli for PostgreSQL, mycli for MySQL)
744
+ if (engineSpecificInstalled) {
745
+ choices.push({
746
+ name: `⚡ Use ${engineSpecificCli} (enhanced features, recommended)`,
747
+ value: config.engine === 'mysql' ? 'mycli' : 'pgcli',
748
+ })
749
+ } else {
750
+ choices.push({
751
+ name: `↓ Install ${engineSpecificCli} (enhanced features, recommended)`,
752
+ value: config.engine === 'mysql' ? 'install-mycli' : 'install-pgcli',
753
+ })
754
+ }
755
+
756
+ // usql - universal option
757
+ if (usqlInstalled) {
758
+ choices.push({
759
+ name: '⚡ Use usql (universal SQL client)',
760
+ value: 'usql',
761
+ })
762
+ } else {
763
+ choices.push({
764
+ name: '↓ Install usql (universal SQL client)',
765
+ value: 'install-usql',
766
+ })
767
+ }
768
+
769
+ choices.push({
770
+ name: `${chalk.blue('←')} Back`,
771
+ value: 'back',
772
+ })
773
+
774
+ const { shellChoice } = await inquirer.prompt<{ shellChoice: ShellChoice }>([
775
+ {
776
+ type: 'list',
777
+ name: 'shellChoice',
778
+ message: 'Select shell option:',
779
+ choices,
780
+ pageSize: 10,
781
+ },
782
+ ])
783
+
784
+ if (shellChoice === 'back') {
785
+ return
786
+ }
787
+
788
+ // Handle pgcli installation
789
+ if (shellChoice === 'install-pgcli') {
790
+ console.log()
791
+ console.log(info('Installing pgcli for enhanced PostgreSQL shell...'))
792
+ const pm = await detectPackageManager()
793
+ if (pm) {
794
+ const result = await installPgcli(pm)
795
+ if (result.success) {
796
+ console.log(success('pgcli installed successfully!'))
797
+ console.log()
798
+ await launchShell(containerName, config, connectionString, 'pgcli')
799
+ } else {
800
+ console.error(error(`Failed to install pgcli: ${result.error}`))
801
+ console.log()
802
+ console.log(chalk.gray('Manual installation:'))
803
+ for (const instruction of getPgcliManualInstructions()) {
804
+ console.log(chalk.cyan(` ${instruction}`))
805
+ }
806
+ console.log()
807
+ await pressEnterToContinue()
808
+ }
809
+ } else {
810
+ console.error(error('No supported package manager found'))
811
+ console.log()
812
+ console.log(chalk.gray('Manual installation:'))
813
+ for (const instruction of getPgcliManualInstructions()) {
814
+ console.log(chalk.cyan(` ${instruction}`))
815
+ }
816
+ console.log()
817
+ await pressEnterToContinue()
818
+ }
819
+ return
820
+ }
821
+
822
+ // Handle mycli installation
823
+ if (shellChoice === 'install-mycli') {
824
+ console.log()
825
+ console.log(info('Installing mycli for enhanced MySQL shell...'))
826
+ const pm = await detectPackageManager()
827
+ if (pm) {
828
+ const result = await installMycli(pm)
829
+ if (result.success) {
830
+ console.log(success('mycli installed successfully!'))
831
+ console.log()
832
+ await launchShell(containerName, config, connectionString, 'mycli')
833
+ } else {
834
+ console.error(error(`Failed to install mycli: ${result.error}`))
835
+ console.log()
836
+ console.log(chalk.gray('Manual installation:'))
837
+ for (const instruction of getMycliManualInstructions()) {
838
+ console.log(chalk.cyan(` ${instruction}`))
839
+ }
840
+ console.log()
841
+ await pressEnterToContinue()
842
+ }
843
+ } else {
844
+ console.error(error('No supported package manager found'))
845
+ console.log()
846
+ console.log(chalk.gray('Manual installation:'))
847
+ for (const instruction of getMycliManualInstructions()) {
848
+ console.log(chalk.cyan(` ${instruction}`))
849
+ }
850
+ console.log()
851
+ await pressEnterToContinue()
852
+ }
853
+ return
854
+ }
855
+
856
+ // Handle usql installation
857
+ if (shellChoice === 'install-usql') {
858
+ console.log()
859
+ console.log(info('Installing usql for enhanced shell experience...'))
860
+ const pm = await detectPackageManager()
861
+ if (pm) {
862
+ const result = await installUsql(pm)
863
+ if (result.success) {
864
+ console.log(success('usql installed successfully!'))
865
+ console.log()
866
+ await launchShell(containerName, config, connectionString, 'usql')
867
+ } else {
868
+ console.error(error(`Failed to install usql: ${result.error}`))
869
+ console.log()
870
+ console.log(chalk.gray('Manual installation:'))
871
+ for (const instruction of getUsqlManualInstructions()) {
872
+ console.log(chalk.cyan(` ${instruction}`))
873
+ }
874
+ console.log()
875
+ await pressEnterToContinue()
876
+ }
877
+ } else {
878
+ console.error(error('No supported package manager found'))
879
+ console.log()
880
+ console.log(chalk.gray('Manual installation:'))
881
+ for (const instruction of getUsqlManualInstructions()) {
882
+ console.log(chalk.cyan(` ${instruction}`))
883
+ }
884
+ console.log()
885
+ await pressEnterToContinue()
886
+ }
887
+ return
888
+ }
889
+
890
+ // Launch the selected shell
891
+ await launchShell(containerName, config, connectionString, shellChoice)
892
+ }
893
+
894
+ async function launchShell(
895
+ containerName: string,
896
+ config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>,
897
+ connectionString: string,
898
+ shellType: 'default' | 'usql' | 'pgcli' | 'mycli',
899
+ ): Promise<void> {
707
900
  console.log(info(`Connecting to ${containerName}...`))
708
901
  console.log()
709
902
 
710
- // Determine shell command based on engine
903
+ // Determine shell command based on engine and shell type
711
904
  let shellCmd: string
712
905
  let shellArgs: string[]
713
906
  let installHint: string
714
907
 
715
- if (config.engine === 'mysql') {
908
+ if (shellType === 'pgcli') {
909
+ // pgcli accepts connection strings
910
+ shellCmd = 'pgcli'
911
+ shellArgs = [connectionString]
912
+ installHint = 'brew install pgcli'
913
+ } else if (shellType === 'mycli') {
914
+ // mycli: mycli -h host -P port -u user database
915
+ shellCmd = 'mycli'
916
+ shellArgs = [
917
+ '-h',
918
+ '127.0.0.1',
919
+ '-P',
920
+ String(config.port),
921
+ '-u',
922
+ 'root',
923
+ config.database,
924
+ ]
925
+ installHint = 'brew install mycli'
926
+ } else if (shellType === 'usql') {
927
+ // usql accepts connection strings directly for both PostgreSQL and MySQL
928
+ shellCmd = 'usql'
929
+ shellArgs = [connectionString]
930
+ installHint = 'brew tap xo/xo && brew install xo/xo/usql'
931
+ } else if (config.engine === 'mysql') {
716
932
  shellCmd = 'mysql'
717
933
  // MySQL connection: mysql -u root -h 127.0.0.1 -P port database
718
934
  shellArgs = [
@@ -743,7 +959,7 @@ async function handleOpenShell(containerName: string): Promise<void> {
743
959
  console.log(chalk.gray(' Connect manually with:'))
744
960
  console.log(chalk.cyan(` ${connectionString}`))
745
961
  console.log()
746
- console.log(chalk.gray(` Install ${config.engine} client:`))
962
+ console.log(chalk.gray(` Install ${shellCmd}:`))
747
963
  console.log(chalk.cyan(` ${installHint}`))
748
964
  }
749
965
  })
@@ -866,7 +1082,7 @@ async function handleRestore(): Promise<void> {
866
1082
  // Build choices: running containers + create new option
867
1083
  const choices = [
868
1084
  ...running.map((c) => ({
869
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
1085
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || ''} ${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
870
1086
  value: c.name,
871
1087
  short: c.name,
872
1088
  })),
@@ -886,6 +1102,7 @@ async function handleRestore(): Promise<void> {
886
1102
  name: 'selectedContainer',
887
1103
  message: 'Select container to restore to:',
888
1104
  choices,
1105
+ pageSize: 15,
889
1106
  },
890
1107
  ])
891
1108
 
@@ -931,7 +1148,9 @@ async function handleRestore(): Promise<void> {
931
1148
  missingDeps = await getMissingDependencies(config.engine)
932
1149
  if (missingDeps.length > 0) {
933
1150
  console.log(
934
- error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
1151
+ error(
1152
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
1153
+ ),
935
1154
  )
936
1155
  return
937
1156
  }
@@ -1078,7 +1297,7 @@ async function handleRestore(): Promise<void> {
1078
1297
  backupPath = stripQuotes(rawBackupPath)
1079
1298
  }
1080
1299
 
1081
- const databaseName = await promptDatabaseName(containerName)
1300
+ const databaseName = await promptDatabaseName(containerName, config.engine)
1082
1301
 
1083
1302
  const engine = getEngine(config.engine)
1084
1303
 
@@ -1168,7 +1387,7 @@ async function handleRestore(): Promise<void> {
1168
1387
 
1169
1388
  try {
1170
1389
  const { updatePostgresClientTools } = await import(
1171
- '../../core/postgres-binary-manager'
1390
+ '../../engines/postgresql/binary-manager'
1172
1391
  )
1173
1392
  const updateSuccess = await updatePostgresClientTools()
1174
1393
 
@@ -1189,24 +1408,26 @@ async function handleRestore(): Promise<void> {
1189
1408
  console.log(
1190
1409
  error('Automatic upgrade failed. Please upgrade manually:'),
1191
1410
  )
1411
+ const pgPackage = getPostgresHomebrewPackage()
1412
+ const latestMajor = pgPackage.split('@')[1]
1192
1413
  console.log(
1193
1414
  warning(
1194
- ' macOS: brew install postgresql@17 && brew link --force postgresql@17',
1415
+ ` macOS: brew install ${pgPackage} && brew link --force ${pgPackage}`,
1195
1416
  ),
1196
1417
  )
1197
1418
  console.log(
1198
1419
  chalk.gray(
1199
- ' This installs PostgreSQL 17 client tools: pg_restore, pg_dump, psql, and libpq',
1420
+ ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
1200
1421
  ),
1201
1422
  )
1202
1423
  console.log(
1203
1424
  warning(
1204
- ' Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-17',
1425
+ ` Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-${latestMajor}`,
1205
1426
  ),
1206
1427
  )
1207
1428
  console.log(
1208
1429
  chalk.gray(
1209
- ' This installs PostgreSQL 17 client tools: pg_restore, pg_dump, psql, and libpq',
1430
+ ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
1210
1431
  ),
1211
1432
  )
1212
1433
  await new Promise((resolve) => {
@@ -1270,24 +1491,11 @@ async function handleRestore(): Promise<void> {
1270
1491
  console.log(chalk.gray(' Connection string:'))
1271
1492
  console.log(chalk.cyan(` ${connectionString}`))
1272
1493
 
1273
- // Copy connection string to clipboard using platform-specific command
1274
- try {
1275
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
1276
- const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
1277
-
1278
- await new Promise<void>((resolve, reject) => {
1279
- const proc = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'] })
1280
- proc.stdin?.write(connectionString)
1281
- proc.stdin?.end()
1282
- proc.on('close', (code) => {
1283
- if (code === 0) resolve()
1284
- else reject(new Error(`Clipboard command exited with code ${code}`))
1285
- })
1286
- proc.on('error', reject)
1287
- })
1288
-
1494
+ // Copy connection string to clipboard using platform service
1495
+ const copied = await platformService.copyToClipboard(connectionString)
1496
+ if (copied) {
1289
1497
  console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
1290
- } catch {
1498
+ } else {
1291
1499
  console.log(chalk.gray(' (Could not copy to clipboard)'))
1292
1500
  }
1293
1501
 
@@ -1389,7 +1597,9 @@ async function handleStartContainer(containerName: string): Promise<void> {
1389
1597
  ),
1390
1598
  )
1391
1599
  console.log(
1392
- info('Run: sudo systemctl stop mariadb && sudo systemctl disable mariadb'),
1600
+ info(
1601
+ 'Run: sudo systemctl stop mariadb && sudo systemctl disable mariadb',
1602
+ ),
1393
1603
  )
1394
1604
  return
1395
1605
  }
@@ -1468,7 +1678,7 @@ async function handleEditContainer(
1468
1678
  },
1469
1679
  new inquirer.Separator(),
1470
1680
  { name: `${chalk.blue('←')} Back to container`, value: 'back' },
1471
- { name: `${chalk.blue('🏠')} Back to main menu`, value: 'main' },
1681
+ { name: `${chalk.blue('')} Back to main menu`, value: 'main' },
1472
1682
  ]
1473
1683
 
1474
1684
  const { field } = await inquirer.prompt<{ field: string }>([
@@ -1477,6 +1687,7 @@ async function handleEditContainer(
1477
1687
  name: 'field',
1478
1688
  message: 'Select field to edit:',
1479
1689
  choices: editChoices,
1690
+ pageSize: 10,
1480
1691
  },
1481
1692
  ])
1482
1693
 
@@ -1643,72 +1854,135 @@ async function handleDelete(containerName: string): Promise<void> {
1643
1854
  deleteSpinner.succeed(`Container "${containerName}" deleted`)
1644
1855
  }
1645
1856
 
1646
- type InstalledEngine = {
1647
- engine: string
1857
+ type InstalledPostgresEngine = {
1858
+ engine: 'postgresql'
1648
1859
  version: string
1649
1860
  platform: string
1650
1861
  arch: string
1651
1862
  path: string
1652
1863
  sizeBytes: number
1864
+ source: 'downloaded'
1653
1865
  }
1654
1866
 
1655
- async function getInstalledEngines(): Promise<InstalledEngine[]> {
1656
- const binDir = paths.bin
1867
+ type InstalledMysqlEngine = {
1868
+ engine: 'mysql'
1869
+ version: string
1870
+ path: string
1871
+ source: 'system'
1872
+ isMariaDB: boolean
1873
+ }
1874
+
1875
+ type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
1876
+
1877
+ const execAsync = promisify(exec)
1657
1878
 
1658
- if (!existsSync(binDir)) {
1659
- return []
1879
+ /**
1880
+ * Get the actual PostgreSQL version from the binary
1881
+ */
1882
+ async function getPostgresVersionFromBinary(
1883
+ binPath: string,
1884
+ ): Promise<string | null> {
1885
+ const postgresPath = join(binPath, 'bin', 'postgres')
1886
+ if (!existsSync(postgresPath)) {
1887
+ return null
1660
1888
  }
1661
1889
 
1662
- const entries = await readdir(binDir, { withFileTypes: true })
1663
- const engines: InstalledEngine[] = []
1890
+ try {
1891
+ const { stdout } = await execAsync(`"${postgresPath}" --version`)
1892
+ // Output: postgres (PostgreSQL) 17.7
1893
+ const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
1894
+ return match ? match[1] : null
1895
+ } catch {
1896
+ return null
1897
+ }
1898
+ }
1664
1899
 
1665
- for (const entry of entries) {
1666
- if (entry.isDirectory()) {
1667
- // Parse directory name: postgresql-17-darwin-arm64
1668
- const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
1669
- if (match) {
1670
- const [, engine, version, platform, arch] = match
1671
- const dirPath = join(binDir, entry.name)
1900
+ async function getInstalledEngines(): Promise<InstalledEngine[]> {
1901
+ const engines: InstalledEngine[] = []
1672
1902
 
1673
- // Get directory size (using lstat to avoid following symlinks)
1674
- let sizeBytes = 0
1675
- try {
1676
- const files = await readdir(dirPath, { recursive: true })
1677
- for (const file of files) {
1678
- try {
1679
- const filePath = join(dirPath, file.toString())
1680
- const fileStat = await lstat(filePath)
1681
- // Only count regular files (not symlinks or directories)
1682
- if (fileStat.isFile()) {
1683
- sizeBytes += fileStat.size
1903
+ // Get PostgreSQL engines from ~/.spindb/bin/
1904
+ const binDir = paths.bin
1905
+ if (existsSync(binDir)) {
1906
+ const entries = await readdir(binDir, { withFileTypes: true })
1907
+
1908
+ for (const entry of entries) {
1909
+ if (entry.isDirectory()) {
1910
+ // Parse directory name: postgresql-17-darwin-arm64
1911
+ const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
1912
+ if (match && match[1] === 'postgresql') {
1913
+ const [, , majorVersion, platform, arch] = match
1914
+ const dirPath = join(binDir, entry.name)
1915
+
1916
+ // Get actual version from the binary
1917
+ const actualVersion =
1918
+ (await getPostgresVersionFromBinary(dirPath)) || majorVersion
1919
+
1920
+ // Get directory size (using lstat to avoid following symlinks)
1921
+ let sizeBytes = 0
1922
+ try {
1923
+ const files = await readdir(dirPath, { recursive: true })
1924
+ for (const file of files) {
1925
+ try {
1926
+ const filePath = join(dirPath, file.toString())
1927
+ const fileStat = await lstat(filePath)
1928
+ // Only count regular files (not symlinks or directories)
1929
+ if (fileStat.isFile()) {
1930
+ sizeBytes += fileStat.size
1931
+ }
1932
+ } catch {
1933
+ // Skip files we can't stat
1684
1934
  }
1685
- } catch {
1686
- // Skip files we can't stat
1687
1935
  }
1936
+ } catch {
1937
+ // Skip directories we can't read
1688
1938
  }
1689
- } catch {
1690
- // Skip directories we can't read
1691
- }
1692
1939
 
1693
- engines.push({
1694
- engine,
1695
- version,
1696
- platform,
1697
- arch,
1698
- path: dirPath,
1699
- sizeBytes,
1700
- })
1940
+ engines.push({
1941
+ engine: 'postgresql',
1942
+ version: actualVersion,
1943
+ platform,
1944
+ arch,
1945
+ path: dirPath,
1946
+ sizeBytes,
1947
+ source: 'downloaded',
1948
+ })
1949
+ }
1701
1950
  }
1702
1951
  }
1703
1952
  }
1704
1953
 
1705
- // Sort by engine name, then by version (descending)
1706
- engines.sort((a, b) => {
1707
- if (a.engine !== b.engine) return a.engine.localeCompare(b.engine)
1708
- return compareVersions(b.version, a.version)
1709
- })
1954
+ // Detect system-installed MySQL
1955
+ const mysqldPath = await getMysqldPath()
1956
+ if (mysqldPath) {
1957
+ const version = await getMysqlVersion(mysqldPath)
1958
+ if (version) {
1959
+ const mariadb = await isMariaDB()
1960
+ engines.push({
1961
+ engine: 'mysql',
1962
+ version,
1963
+ path: mysqldPath,
1964
+ source: 'system',
1965
+ isMariaDB: mariadb,
1966
+ })
1967
+ }
1968
+ }
1969
+
1970
+ // Sort PostgreSQL by version (descending), MySQL stays at end
1971
+ const pgEngines = engines.filter(
1972
+ (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
1973
+ )
1974
+ const mysqlEngine = engines.find(
1975
+ (e): e is InstalledMysqlEngine => e.engine === 'mysql',
1976
+ )
1977
+
1978
+ pgEngines.sort((a, b) => compareVersions(b.version, a.version))
1979
+
1980
+ const result: InstalledEngine[] = [...pgEngines]
1981
+ if (mysqlEngine) {
1982
+ result.push(mysqlEngine)
1983
+ }
1710
1984
 
1711
- return engines
1985
+ return result
1712
1986
  }
1713
1987
 
1714
1988
  function compareVersions(a: string, b: string): number {
@@ -1742,54 +2016,104 @@ async function handleEngines(): Promise<void> {
1742
2016
  console.log(info('No engines installed yet.'))
1743
2017
  console.log(
1744
2018
  chalk.gray(
1745
- ' Engines are downloaded automatically when you create a container.',
2019
+ ' PostgreSQL engines are downloaded automatically when you create a container.',
2020
+ ),
2021
+ )
2022
+ console.log(
2023
+ chalk.gray(
2024
+ ' MySQL requires system installation (brew install mysql or apt install mysql-server).',
1746
2025
  ),
1747
2026
  )
1748
2027
  return
1749
2028
  }
1750
2029
 
1751
- // Calculate total size
1752
- const totalSize = engines.reduce((acc, e) => acc + e.sizeBytes, 0)
2030
+ // Separate PostgreSQL and MySQL
2031
+ const pgEngines = engines.filter(
2032
+ (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
2033
+ )
2034
+ const mysqlEngine = engines.find(
2035
+ (e): e is InstalledMysqlEngine => e.engine === 'mysql',
2036
+ )
2037
+
2038
+ // Calculate total size for PostgreSQL
2039
+ const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
1753
2040
 
1754
2041
  // Table header
1755
2042
  console.log()
1756
2043
  console.log(
1757
2044
  chalk.gray(' ') +
1758
- chalk.bold.white('ENGINE'.padEnd(12)) +
2045
+ chalk.bold.white('ENGINE'.padEnd(14)) +
1759
2046
  chalk.bold.white('VERSION'.padEnd(12)) +
1760
- chalk.bold.white('PLATFORM'.padEnd(20)) +
2047
+ chalk.bold.white('SOURCE'.padEnd(18)) +
1761
2048
  chalk.bold.white('SIZE'),
1762
2049
  )
1763
2050
  console.log(chalk.gray(' ' + '─'.repeat(55)))
1764
2051
 
1765
- // Table rows
1766
- for (const engine of engines) {
2052
+ // PostgreSQL rows
2053
+ for (const engine of pgEngines) {
2054
+ const icon = engineIcons[engine.engine] || '▣'
2055
+ const platformInfo = `${engine.platform}-${engine.arch}`
2056
+
1767
2057
  console.log(
1768
2058
  chalk.gray(' ') +
1769
- chalk.cyan(engine.engine.padEnd(12)) +
2059
+ chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
1770
2060
  chalk.yellow(engine.version.padEnd(12)) +
1771
- chalk.gray(`${engine.platform}-${engine.arch}`.padEnd(20)) +
2061
+ chalk.gray(platformInfo.padEnd(18)) +
1772
2062
  chalk.white(formatBytes(engine.sizeBytes)),
1773
2063
  )
1774
2064
  }
1775
2065
 
2066
+ // MySQL row
2067
+ if (mysqlEngine) {
2068
+ const icon = engineIcons.mysql
2069
+ const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
2070
+
2071
+ console.log(
2072
+ chalk.gray(' ') +
2073
+ chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
2074
+ chalk.yellow(mysqlEngine.version.padEnd(12)) +
2075
+ chalk.gray('system'.padEnd(18)) +
2076
+ chalk.gray('(system-installed)'),
2077
+ )
2078
+ }
2079
+
1776
2080
  console.log(chalk.gray(' ' + '─'.repeat(55)))
1777
- console.log(
1778
- chalk.gray(' ') +
1779
- chalk.bold.white(`${engines.length} version(s)`.padEnd(44)) +
1780
- chalk.bold.white(formatBytes(totalSize)),
1781
- )
2081
+
2082
+ // Summary
2083
+ console.log()
2084
+ if (pgEngines.length > 0) {
2085
+ console.log(
2086
+ chalk.gray(
2087
+ ` PostgreSQL: ${pgEngines.length} version(s), ${formatBytes(totalPgSize)}`,
2088
+ ),
2089
+ )
2090
+ }
2091
+ if (mysqlEngine) {
2092
+ console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
2093
+ }
1782
2094
  console.log()
1783
2095
 
1784
- // Menu options
1785
- const choices: MenuChoice[] = [
1786
- ...engines.map((e) => ({
2096
+ // Menu options - only allow deletion of PostgreSQL engines
2097
+ const choices: MenuChoice[] = []
2098
+
2099
+ for (const e of pgEngines) {
2100
+ choices.push({
1787
2101
  name: `${chalk.red('✕')} Delete ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
1788
2102
  value: `delete:${e.path}:${e.engine}:${e.version}`,
1789
- })),
1790
- new inquirer.Separator(),
1791
- { name: `${chalk.blue('←')} Back to main menu`, value: 'back' },
1792
- ]
2103
+ })
2104
+ }
2105
+
2106
+ // MySQL info option (not disabled, shows info icon)
2107
+ if (mysqlEngine) {
2108
+ const displayName = mysqlEngine.isMariaDB ? 'MariaDB' : 'MySQL'
2109
+ choices.push({
2110
+ name: `${chalk.blue('ℹ')} ${displayName} ${mysqlEngine.version} ${chalk.gray('(system-installed)')}`,
2111
+ value: `mysql-info:${mysqlEngine.path}`,
2112
+ })
2113
+ }
2114
+
2115
+ choices.push(new inquirer.Separator())
2116
+ choices.push({ name: `${chalk.blue('←')} Back to main menu`, value: 'back' })
1793
2117
 
1794
2118
  const { action } = await inquirer.prompt<{ action: string }>([
1795
2119
  {
@@ -1811,6 +2135,13 @@ async function handleEngines(): Promise<void> {
1811
2135
  // Return to engines menu
1812
2136
  await handleEngines()
1813
2137
  }
2138
+
2139
+ if (action.startsWith('mysql-info:')) {
2140
+ const mysqldPath = action.replace('mysql-info:', '')
2141
+ await handleMysqlInfo(mysqldPath)
2142
+ // Return to engines menu
2143
+ await handleEngines()
2144
+ }
1814
2145
  }
1815
2146
 
1816
2147
  async function handleDeleteEngine(
@@ -1869,6 +2200,174 @@ async function handleDeleteEngine(
1869
2200
  }
1870
2201
  }
1871
2202
 
2203
+ async function handleMysqlInfo(mysqldPath: string): Promise<void> {
2204
+ console.clear()
2205
+
2206
+ // Get install info
2207
+ const installInfo = await getMysqlInstallInfo(mysqldPath)
2208
+ const displayName = installInfo.isMariaDB ? 'MariaDB' : 'MySQL'
2209
+
2210
+ // Get version
2211
+ const version = await getMysqlVersion(mysqldPath)
2212
+
2213
+ console.log(header(`${displayName} Information`))
2214
+ console.log()
2215
+
2216
+ // Check for containers using MySQL
2217
+ const containers = await containerManager.list()
2218
+ const mysqlContainers = containers.filter((c) => c.engine === 'mysql')
2219
+
2220
+ // Track running containers for uninstall instructions
2221
+ const runningContainers: string[] = []
2222
+
2223
+ if (mysqlContainers.length > 0) {
2224
+ console.log(
2225
+ warning(
2226
+ `${mysqlContainers.length} container(s) are using ${displayName}:`,
2227
+ ),
2228
+ )
2229
+ console.log()
2230
+ for (const c of mysqlContainers) {
2231
+ const isRunning = await processManager.isRunning(c.name, {
2232
+ engine: c.engine,
2233
+ })
2234
+ if (isRunning) {
2235
+ runningContainers.push(c.name)
2236
+ }
2237
+ const status = isRunning
2238
+ ? chalk.green('● running')
2239
+ : chalk.gray('○ stopped')
2240
+ console.log(chalk.gray(` • ${c.name} ${status}`))
2241
+ }
2242
+ console.log()
2243
+ console.log(
2244
+ chalk.yellow(
2245
+ ' Uninstalling will break these containers. Delete them first.',
2246
+ ),
2247
+ )
2248
+ console.log()
2249
+ }
2250
+
2251
+ // Show installation details
2252
+ console.log(chalk.white(' Installation Details:'))
2253
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
2254
+ console.log(
2255
+ chalk.gray(' ') +
2256
+ chalk.white('Version:'.padEnd(18)) +
2257
+ chalk.yellow(version || 'unknown'),
2258
+ )
2259
+ console.log(
2260
+ chalk.gray(' ') +
2261
+ chalk.white('Binary Path:'.padEnd(18)) +
2262
+ chalk.gray(mysqldPath),
2263
+ )
2264
+ console.log(
2265
+ chalk.gray(' ') +
2266
+ chalk.white('Package Manager:'.padEnd(18)) +
2267
+ chalk.cyan(installInfo.packageManager),
2268
+ )
2269
+ console.log(
2270
+ chalk.gray(' ') +
2271
+ chalk.white('Package Name:'.padEnd(18)) +
2272
+ chalk.cyan(installInfo.packageName),
2273
+ )
2274
+ console.log()
2275
+
2276
+ // Uninstall instructions
2277
+ console.log(chalk.white(' To uninstall:'))
2278
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
2279
+
2280
+ let stepNum = 1
2281
+
2282
+ // Step: Stop running containers first
2283
+ if (runningContainers.length > 0) {
2284
+ console.log(chalk.gray(` # ${stepNum}. Stop running SpinDB containers`))
2285
+ console.log(chalk.cyan(' spindb stop <container-name>'))
2286
+ console.log()
2287
+ stepNum++
2288
+ }
2289
+
2290
+ // Step: Delete SpinDB containers
2291
+ if (mysqlContainers.length > 0) {
2292
+ console.log(chalk.gray(` # ${stepNum}. Delete SpinDB containers`))
2293
+ console.log(chalk.cyan(' spindb delete <container-name>'))
2294
+ console.log()
2295
+ stepNum++
2296
+ }
2297
+
2298
+ if (installInfo.packageManager === 'homebrew') {
2299
+ console.log(
2300
+ chalk.gray(
2301
+ ` # ${stepNum}. Stop Homebrew service (if running separately)`,
2302
+ ),
2303
+ )
2304
+ console.log(chalk.cyan(` brew services stop ${installInfo.packageName}`))
2305
+ console.log()
2306
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2307
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2308
+ } else if (installInfo.packageManager === 'apt') {
2309
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2310
+ console.log(
2311
+ chalk.cyan(
2312
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
2313
+ ),
2314
+ )
2315
+ console.log()
2316
+ console.log(chalk.gray(` # ${stepNum + 1}. Disable auto-start on boot`))
2317
+ console.log(
2318
+ chalk.cyan(
2319
+ ` sudo systemctl disable ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
2320
+ ),
2321
+ )
2322
+ console.log()
2323
+ console.log(chalk.gray(` # ${stepNum + 2}. Uninstall the package`))
2324
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2325
+ console.log()
2326
+ console.log(chalk.gray(` # ${stepNum + 3}. Remove data files (optional)`))
2327
+ console.log(
2328
+ chalk.cyan(' sudo apt purge mysql-server mysql-client mysql-common'),
2329
+ )
2330
+ console.log(chalk.cyan(' sudo rm -rf /var/lib/mysql /etc/mysql'))
2331
+ } else if (
2332
+ installInfo.packageManager === 'yum' ||
2333
+ installInfo.packageManager === 'dnf'
2334
+ ) {
2335
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2336
+ console.log(
2337
+ chalk.cyan(
2338
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
2339
+ ),
2340
+ )
2341
+ console.log()
2342
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2343
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2344
+ } else if (installInfo.packageManager === 'pacman') {
2345
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2346
+ console.log(
2347
+ chalk.cyan(
2348
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
2349
+ ),
2350
+ )
2351
+ console.log()
2352
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2353
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2354
+ } else {
2355
+ console.log(chalk.gray(' Use your system package manager to uninstall.'))
2356
+ console.log(chalk.gray(` The binary is located at: ${mysqldPath}`))
2357
+ }
2358
+
2359
+ console.log()
2360
+
2361
+ // Wait for user
2362
+ await inquirer.prompt([
2363
+ {
2364
+ type: 'input',
2365
+ name: 'continue',
2366
+ message: chalk.gray('Press Enter to go back...'),
2367
+ },
2368
+ ])
2369
+ }
2370
+
1872
2371
  export const menuCommand = new Command('menu')
1873
2372
  .description('Interactive menu for managing containers')
1874
2373
  .action(async () => {
@@ -1890,9 +2389,7 @@ export const menuCommand = new Command('menu')
1890
2389
  : 'psql'
1891
2390
  const installed = await promptInstallDependencies(missingTool)
1892
2391
  if (installed) {
1893
- console.log(
1894
- chalk.yellow(' Please re-run spindb to continue.'),
1895
- )
2392
+ console.log(chalk.yellow(' Please re-run spindb to continue.'))
1896
2393
  }
1897
2394
  process.exit(1)
1898
2395
  }