spindb 0.4.1 → 0.5.3

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 (44) hide show
  1. package/README.md +207 -101
  2. package/cli/commands/clone.ts +3 -1
  3. package/cli/commands/connect.ts +54 -24
  4. package/cli/commands/create.ts +309 -189
  5. package/cli/commands/delete.ts +3 -1
  6. package/cli/commands/deps.ts +19 -4
  7. package/cli/commands/edit.ts +245 -0
  8. package/cli/commands/engines.ts +434 -0
  9. package/cli/commands/info.ts +279 -0
  10. package/cli/commands/list.ts +14 -3
  11. package/cli/commands/menu.ts +510 -198
  12. package/cli/commands/restore.ts +66 -43
  13. package/cli/commands/start.ts +50 -19
  14. package/cli/commands/stop.ts +3 -1
  15. package/cli/commands/url.ts +79 -0
  16. package/cli/index.ts +9 -3
  17. package/cli/ui/prompts.ts +99 -34
  18. package/config/defaults.ts +40 -15
  19. package/config/engine-defaults.ts +107 -0
  20. package/config/os-dependencies.ts +119 -124
  21. package/config/paths.ts +82 -56
  22. package/core/binary-manager.ts +44 -6
  23. package/core/config-manager.ts +17 -5
  24. package/core/container-manager.ts +124 -60
  25. package/core/dependency-manager.ts +9 -15
  26. package/core/error-handler.ts +336 -0
  27. package/core/platform-service.ts +634 -0
  28. package/core/port-manager.ts +51 -32
  29. package/core/process-manager.ts +26 -8
  30. package/core/start-with-retry.ts +167 -0
  31. package/core/transaction-manager.ts +170 -0
  32. package/engines/index.ts +7 -2
  33. package/engines/mysql/binary-detection.ts +325 -0
  34. package/engines/mysql/index.ts +808 -0
  35. package/engines/mysql/restore.ts +257 -0
  36. package/engines/mysql/version-validator.ts +373 -0
  37. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  38. package/engines/postgresql/binary-urls.ts +5 -3
  39. package/engines/postgresql/index.ts +17 -9
  40. package/engines/postgresql/restore.ts +54 -5
  41. package/engines/postgresql/version-validator.ts +262 -0
  42. package/package.json +9 -3
  43. package/types/index.ts +29 -5
  44. package/cli/commands/postgres-tools.ts +0 -216
@@ -22,14 +22,24 @@ 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'
34
+ import type { EngineName } from '../../types'
31
35
  import inquirer from 'inquirer'
32
36
  import { getMissingDependencies } from '../../core/dependency-manager'
37
+ import {
38
+ getMysqldPath,
39
+ getMysqlVersion,
40
+ isMariaDB,
41
+ getMysqlInstallInfo,
42
+ } from '../../engines/mysql/binary-detection'
33
43
 
34
44
  type MenuChoice =
35
45
  | {
@@ -39,6 +49,14 @@ type MenuChoice =
39
49
  }
40
50
  | inquirer.Separator
41
51
 
52
+ /**
53
+ * Engine icons for display
54
+ */
55
+ const engineIcons: Record<string, string> = {
56
+ postgresql: '🐘',
57
+ mysql: '🐬',
58
+ }
59
+
42
60
  async function showMainMenu(): Promise<void> {
43
61
  console.clear()
44
62
  console.log(header('SpinDB - Local Database Manager'))
@@ -57,7 +75,6 @@ async function showMainMenu(): Promise<void> {
57
75
 
58
76
  const canStart = stopped > 0
59
77
  const canStop = running > 0
60
- const canConnect = running > 0
61
78
  const canRestore = running > 0
62
79
  const canClone = containers.length > 0
63
80
 
@@ -87,18 +104,11 @@ async function showMainMenu(): Promise<void> {
87
104
  },
88
105
  {
89
106
  name: canStop
90
- ? `${chalk.yellow('■')} Stop a container`
107
+ ? `${chalk.red('■')} Stop a container`
91
108
  : chalk.gray('■ Stop a container'),
92
109
  value: 'stop',
93
110
  disabled: canStop ? false : 'No running containers',
94
111
  },
95
- {
96
- name: canConnect
97
- ? `${chalk.blue('⌘')} Open psql shell`
98
- : chalk.gray('⌘ Open psql shell'),
99
- value: 'connect',
100
- disabled: canConnect ? false : 'No running containers',
101
- },
102
112
  {
103
113
  name: canRestore
104
114
  ? `${chalk.magenta('↓')} Restore backup`
@@ -147,9 +157,6 @@ async function showMainMenu(): Promise<void> {
147
157
  case 'stop':
148
158
  await handleStop()
149
159
  break
150
- case 'connect':
151
- await handleConnect()
152
- break
153
160
  case 'restore':
154
161
  await handleRestore()
155
162
  break
@@ -204,7 +211,9 @@ async function handleCreate(): Promise<void> {
204
211
  missingDeps = await getMissingDependencies(engine)
205
212
  if (missingDeps.length > 0) {
206
213
  console.log(
207
- error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
214
+ error(
215
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
216
+ ),
208
217
  )
209
218
  return
210
219
  }
@@ -246,7 +255,7 @@ async function handleCreate(): Promise<void> {
246
255
  createSpinnerInstance.start()
247
256
 
248
257
  await containerManager.create(containerName, {
249
- engine: dbEngine.name,
258
+ engine: dbEngine.name as EngineName,
250
259
  version,
251
260
  port,
252
261
  database,
@@ -303,25 +312,14 @@ async function handleCreate(): Promise<void> {
303
312
  console.log(chalk.gray(' Connection string:'))
304
313
  console.log(chalk.cyan(` ${connectionString}`))
305
314
 
306
- // Copy connection string to clipboard using platform-specific command
315
+ // Copy connection string to clipboard using platform service
307
316
  try {
308
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
309
- const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
310
-
311
- await new Promise<void>((resolve, reject) => {
312
- const proc = spawn(cmd, args, {
313
- stdio: ['pipe', 'inherit', 'inherit'],
314
- })
315
- proc.stdin?.write(connectionString)
316
- proc.stdin?.end()
317
- proc.on('close', (code) => {
318
- if (code === 0) resolve()
319
- else reject(new Error(`Clipboard command exited with code ${code}`))
320
- })
321
- proc.on('error', reject)
322
- })
323
-
324
- console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
317
+ const copied = await platformService.copyToClipboard(connectionString)
318
+ if (copied) {
319
+ console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
320
+ } else {
321
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
322
+ }
325
323
  } catch {
326
324
  console.log(chalk.gray(' (Could not copy to clipboard)'))
327
325
  }
@@ -362,6 +360,15 @@ async function handleList(): Promise<void> {
362
360
  console.log(
363
361
  info('No containers found. Create one with the "Create" option.'),
364
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
+ ])
365
372
  return
366
373
  }
367
374
 
@@ -408,7 +415,7 @@ async function handleList(): Promise<void> {
408
415
  console.log()
409
416
  const containerChoices = [
410
417
  ...containers.map((c) => ({
411
- 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})`)} ${
412
419
  c.status === 'running'
413
420
  ? chalk.green('● running')
414
421
  : chalk.gray('○ stopped')
@@ -447,7 +454,9 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
447
454
  }
448
455
 
449
456
  // Check actual running state
450
- const isRunning = await processManager.isRunning(containerName)
457
+ const isRunning = await processManager.isRunning(containerName, {
458
+ engine: config.engine,
459
+ })
451
460
  const status = isRunning ? 'running' : 'stopped'
452
461
 
453
462
  console.clear()
@@ -465,6 +474,13 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
465
474
  !isRunning
466
475
  ? { name: `${chalk.green('▶')} Start container`, value: 'start' }
467
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
+ },
468
484
  {
469
485
  name: !isRunning
470
486
  ? `${chalk.white('⚙')} Edit container`
@@ -504,6 +520,10 @@ async function showContainerSubmenu(containerName: string): Promise<void> {
504
520
  await handleStopContainer(containerName)
505
521
  await showContainerSubmenu(containerName)
506
522
  return
523
+ case 'shell':
524
+ await handleOpenShell(containerName)
525
+ await showContainerSubmenu(containerName)
526
+ return
507
527
  case 'edit': {
508
528
  const newName = await handleEditContainer(containerName)
509
529
  if (newName === null) {
@@ -628,67 +648,29 @@ async function handleCopyConnectionString(
628
648
  const engine = getEngine(config.engine)
629
649
  const connectionString = engine.getConnectionString(config)
630
650
 
631
- // Copy to clipboard using platform-specific command
632
- const { platform } = await import('os')
633
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
634
- const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
651
+ // Copy to clipboard using platform service
652
+ const copied = await platformService.copyToClipboard(connectionString)
635
653
 
636
- try {
637
- await new Promise<void>((resolve, reject) => {
638
- const proc = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'] })
639
- proc.stdin?.write(connectionString)
640
- proc.stdin?.end()
641
- proc.on('close', (code) => {
642
- if (code === 0) resolve()
643
- else reject(new Error(`Clipboard command exited with code ${code}`))
644
- })
645
- proc.on('error', reject)
646
- })
647
-
648
- console.log()
654
+ console.log()
655
+ if (copied) {
649
656
  console.log(success('Connection string copied to clipboard'))
650
657
  console.log(chalk.gray(` ${connectionString}`))
651
- console.log()
652
-
653
- await inquirer.prompt([
654
- {
655
- type: 'input',
656
- name: 'continue',
657
- message: chalk.gray('Press Enter to continue...'),
658
- },
659
- ])
660
- } catch {
661
- // Fallback: just display the string
662
- console.log()
658
+ } else {
663
659
  console.log(warning('Could not copy to clipboard. Connection string:'))
664
660
  console.log(chalk.cyan(` ${connectionString}`))
665
- console.log()
666
-
667
- await inquirer.prompt([
668
- {
669
- type: 'input',
670
- name: 'continue',
671
- message: chalk.gray('Press Enter to continue...'),
672
- },
673
- ])
674
- }
675
- }
676
-
677
- async function handleConnect(): Promise<void> {
678
- const containers = await containerManager.list()
679
- const running = containers.filter((c) => c.status === 'running')
680
-
681
- if (running.length === 0) {
682
- console.log(warning('No running containers'))
683
- return
684
661
  }
662
+ console.log()
685
663
 
686
- const containerName = await promptContainerSelect(
687
- running,
688
- 'Select container to connect to:',
689
- )
690
- if (!containerName) return
664
+ await inquirer.prompt([
665
+ {
666
+ type: 'input',
667
+ name: 'continue',
668
+ message: chalk.gray('Press Enter to continue...'),
669
+ },
670
+ ])
671
+ }
691
672
 
673
+ async function handleOpenShell(containerName: string): Promise<void> {
692
674
  const config = await containerManager.getConfig(containerName)
693
675
  if (!config) {
694
676
  console.error(error(`Container "${containerName}" not found`))
@@ -701,25 +683,49 @@ async function handleConnect(): Promise<void> {
701
683
  console.log(info(`Connecting to ${containerName}...`))
702
684
  console.log()
703
685
 
704
- // Spawn psql
705
- const psqlProcess = spawn('psql', [connectionString], {
686
+ // Determine shell command based on engine
687
+ let shellCmd: string
688
+ let shellArgs: string[]
689
+ let installHint: string
690
+
691
+ if (config.engine === 'mysql') {
692
+ shellCmd = 'mysql'
693
+ // MySQL connection: mysql -u root -h 127.0.0.1 -P port database
694
+ shellArgs = [
695
+ '-u',
696
+ 'root',
697
+ '-h',
698
+ '127.0.0.1',
699
+ '-P',
700
+ String(config.port),
701
+ config.database,
702
+ ]
703
+ installHint = 'brew install mysql-client'
704
+ } else {
705
+ // PostgreSQL (default)
706
+ shellCmd = 'psql'
707
+ shellArgs = [connectionString]
708
+ installHint = 'brew install libpq && brew link --force libpq'
709
+ }
710
+
711
+ const shellProcess = spawn(shellCmd, shellArgs, {
706
712
  stdio: 'inherit',
707
713
  })
708
714
 
709
- psqlProcess.on('error', (err: NodeJS.ErrnoException) => {
715
+ shellProcess.on('error', (err: NodeJS.ErrnoException) => {
710
716
  if (err.code === 'ENOENT') {
711
- console.log(warning('psql not found on your system.'))
717
+ console.log(warning(`${shellCmd} not found on your system.`))
712
718
  console.log()
713
719
  console.log(chalk.gray(' Connect manually with:'))
714
720
  console.log(chalk.cyan(` ${connectionString}`))
715
721
  console.log()
716
- console.log(chalk.gray(' Install PostgreSQL client:'))
717
- console.log(chalk.cyan(' brew install libpq && brew link --force libpq'))
722
+ console.log(chalk.gray(` Install ${config.engine} client:`))
723
+ console.log(chalk.cyan(` ${installHint}`))
718
724
  }
719
725
  })
720
726
 
721
727
  await new Promise<void>((resolve) => {
722
- psqlProcess.on('close', () => resolve())
728
+ shellProcess.on('close', () => resolve())
723
729
  })
724
730
  }
725
731
 
@@ -779,7 +785,7 @@ async function handleCreateForRestore(): Promise<{
779
785
  createSpinnerInstance.start()
780
786
 
781
787
  await containerManager.create(containerName, {
782
- engine: dbEngine.name,
788
+ engine: dbEngine.name as EngineName,
783
789
  version,
784
790
  port,
785
791
  database,
@@ -836,7 +842,7 @@ async function handleRestore(): Promise<void> {
836
842
  // Build choices: running containers + create new option
837
843
  const choices = [
838
844
  ...running.map((c) => ({
839
- name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
845
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
840
846
  value: c.name,
841
847
  short: c.name,
842
848
  })),
@@ -901,7 +907,9 @@ async function handleRestore(): Promise<void> {
901
907
  missingDeps = await getMissingDependencies(config.engine)
902
908
  if (missingDeps.length > 0) {
903
909
  console.log(
904
- error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
910
+ error(
911
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
912
+ ),
905
913
  )
906
914
  return
907
915
  }
@@ -1138,7 +1146,7 @@ async function handleRestore(): Promise<void> {
1138
1146
 
1139
1147
  try {
1140
1148
  const { updatePostgresClientTools } = await import(
1141
- '../../core/postgres-binary-manager'
1149
+ '../../engines/postgresql/binary-manager'
1142
1150
  )
1143
1151
  const updateSuccess = await updatePostgresClientTools()
1144
1152
 
@@ -1159,24 +1167,26 @@ async function handleRestore(): Promise<void> {
1159
1167
  console.log(
1160
1168
  error('Automatic upgrade failed. Please upgrade manually:'),
1161
1169
  )
1170
+ const pgPackage = getPostgresHomebrewPackage()
1171
+ const latestMajor = pgPackage.split('@')[1]
1162
1172
  console.log(
1163
1173
  warning(
1164
- ' macOS: brew install postgresql@17 && brew link --force postgresql@17',
1174
+ ` macOS: brew install ${pgPackage} && brew link --force ${pgPackage}`,
1165
1175
  ),
1166
1176
  )
1167
1177
  console.log(
1168
1178
  chalk.gray(
1169
- ' This installs PostgreSQL 17 client tools: pg_restore, pg_dump, psql, and libpq',
1179
+ ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
1170
1180
  ),
1171
1181
  )
1172
1182
  console.log(
1173
1183
  warning(
1174
- ' Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-17',
1184
+ ` Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-${latestMajor}`,
1175
1185
  ),
1176
1186
  )
1177
1187
  console.log(
1178
1188
  chalk.gray(
1179
- ' This installs PostgreSQL 17 client tools: pg_restore, pg_dump, psql, and libpq',
1189
+ ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
1180
1190
  ),
1181
1191
  )
1182
1192
  await new Promise((resolve) => {
@@ -1240,24 +1250,11 @@ async function handleRestore(): Promise<void> {
1240
1250
  console.log(chalk.gray(' Connection string:'))
1241
1251
  console.log(chalk.cyan(` ${connectionString}`))
1242
1252
 
1243
- // Copy connection string to clipboard using platform-specific command
1244
- try {
1245
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
1246
- const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
1247
-
1248
- await new Promise<void>((resolve, reject) => {
1249
- const proc = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'] })
1250
- proc.stdin?.write(connectionString)
1251
- proc.stdin?.end()
1252
- proc.on('close', (code) => {
1253
- if (code === 0) resolve()
1254
- else reject(new Error(`Clipboard command exited with code ${code}`))
1255
- })
1256
- proc.on('error', reject)
1257
- })
1258
-
1253
+ // Copy connection string to clipboard using platform service
1254
+ const copied = await platformService.copyToClipboard(connectionString)
1255
+ if (copied) {
1259
1256
  console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
1260
- } catch {
1257
+ } else {
1261
1258
  console.log(chalk.gray(' (Could not copy to clipboard)'))
1262
1259
  }
1263
1260
 
@@ -1352,6 +1349,17 @@ async function handleStartContainer(containerName: string): Promise<void> {
1352
1349
  `Port ${config.port} is in use. Stop the process using it or change this container's port.`,
1353
1350
  ),
1354
1351
  )
1352
+ console.log()
1353
+ console.log(
1354
+ info(
1355
+ 'Tip: If you installed MariaDB via apt, it may have started a system service.',
1356
+ ),
1357
+ )
1358
+ console.log(
1359
+ info(
1360
+ 'Run: sudo systemctl stop mariadb && sudo systemctl disable mariadb',
1361
+ ),
1362
+ )
1355
1363
  return
1356
1364
  }
1357
1365
 
@@ -1360,15 +1368,31 @@ async function handleStartContainer(containerName: string): Promise<void> {
1360
1368
  const spinner = createSpinner(`Starting ${containerName}...`)
1361
1369
  spinner.start()
1362
1370
 
1363
- await engine.start(config)
1364
- await containerManager.updateConfig(containerName, { status: 'running' })
1371
+ try {
1372
+ await engine.start(config)
1373
+ await containerManager.updateConfig(containerName, { status: 'running' })
1365
1374
 
1366
- spinner.succeed(`Container "${containerName}" started`)
1375
+ spinner.succeed(`Container "${containerName}" started`)
1367
1376
 
1368
- const connectionString = engine.getConnectionString(config)
1369
- console.log()
1370
- console.log(chalk.gray(' Connection string:'))
1371
- console.log(chalk.cyan(` ${connectionString}`))
1377
+ const connectionString = engine.getConnectionString(config)
1378
+ console.log()
1379
+ console.log(chalk.gray(' Connection string:'))
1380
+ console.log(chalk.cyan(` ${connectionString}`))
1381
+ } catch (err) {
1382
+ spinner.fail(`Failed to start "${containerName}"`)
1383
+ const e = err as Error
1384
+ console.log()
1385
+ console.log(error(e.message))
1386
+
1387
+ // Check if there's a log file with more details
1388
+ const logPath = paths.getContainerLogPath(containerName, {
1389
+ engine: config.engine,
1390
+ })
1391
+ if (existsSync(logPath)) {
1392
+ console.log()
1393
+ console.log(info(`Check the log file for details: ${logPath}`))
1394
+ }
1395
+ }
1372
1396
  }
1373
1397
 
1374
1398
  async function handleStopContainer(containerName: string): Promise<void> {
@@ -1566,7 +1590,9 @@ async function handleDelete(containerName: string): Promise<void> {
1566
1590
  return
1567
1591
  }
1568
1592
 
1569
- const isRunning = await processManager.isRunning(containerName)
1593
+ const isRunning = await processManager.isRunning(containerName, {
1594
+ engine: config.engine,
1595
+ })
1570
1596
 
1571
1597
  if (isRunning) {
1572
1598
  const stopSpinner = createSpinner(`Stopping ${containerName}...`)
@@ -1586,72 +1612,135 @@ async function handleDelete(containerName: string): Promise<void> {
1586
1612
  deleteSpinner.succeed(`Container "${containerName}" deleted`)
1587
1613
  }
1588
1614
 
1589
- type InstalledEngine = {
1590
- engine: string
1615
+ type InstalledPostgresEngine = {
1616
+ engine: 'postgresql'
1591
1617
  version: string
1592
1618
  platform: string
1593
1619
  arch: string
1594
1620
  path: string
1595
1621
  sizeBytes: number
1622
+ source: 'downloaded'
1596
1623
  }
1597
1624
 
1598
- async function getInstalledEngines(): Promise<InstalledEngine[]> {
1599
- const binDir = paths.bin
1625
+ type InstalledMysqlEngine = {
1626
+ engine: 'mysql'
1627
+ version: string
1628
+ path: string
1629
+ source: 'system'
1630
+ isMariaDB: boolean
1631
+ }
1632
+
1633
+ type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
1600
1634
 
1601
- if (!existsSync(binDir)) {
1602
- return []
1635
+ const execAsync = promisify(exec)
1636
+
1637
+ /**
1638
+ * Get the actual PostgreSQL version from the binary
1639
+ */
1640
+ async function getPostgresVersionFromBinary(
1641
+ binPath: string,
1642
+ ): Promise<string | null> {
1643
+ const postgresPath = join(binPath, 'bin', 'postgres')
1644
+ if (!existsSync(postgresPath)) {
1645
+ return null
1603
1646
  }
1604
1647
 
1605
- const entries = await readdir(binDir, { withFileTypes: true })
1606
- const engines: InstalledEngine[] = []
1648
+ try {
1649
+ const { stdout } = await execAsync(`"${postgresPath}" --version`)
1650
+ // Output: postgres (PostgreSQL) 17.7
1651
+ const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
1652
+ return match ? match[1] : null
1653
+ } catch {
1654
+ return null
1655
+ }
1656
+ }
1607
1657
 
1608
- for (const entry of entries) {
1609
- if (entry.isDirectory()) {
1610
- // Parse directory name: postgresql-17-darwin-arm64
1611
- const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
1612
- if (match) {
1613
- const [, engine, version, platform, arch] = match
1614
- const dirPath = join(binDir, entry.name)
1658
+ async function getInstalledEngines(): Promise<InstalledEngine[]> {
1659
+ const engines: InstalledEngine[] = []
1615
1660
 
1616
- // Get directory size (using lstat to avoid following symlinks)
1617
- let sizeBytes = 0
1618
- try {
1619
- const files = await readdir(dirPath, { recursive: true })
1620
- for (const file of files) {
1621
- try {
1622
- const filePath = join(dirPath, file.toString())
1623
- const fileStat = await lstat(filePath)
1624
- // Only count regular files (not symlinks or directories)
1625
- if (fileStat.isFile()) {
1626
- sizeBytes += fileStat.size
1661
+ // Get PostgreSQL engines from ~/.spindb/bin/
1662
+ const binDir = paths.bin
1663
+ if (existsSync(binDir)) {
1664
+ const entries = await readdir(binDir, { withFileTypes: true })
1665
+
1666
+ for (const entry of entries) {
1667
+ if (entry.isDirectory()) {
1668
+ // Parse directory name: postgresql-17-darwin-arm64
1669
+ const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
1670
+ if (match && match[1] === 'postgresql') {
1671
+ const [, , majorVersion, platform, arch] = match
1672
+ const dirPath = join(binDir, entry.name)
1673
+
1674
+ // Get actual version from the binary
1675
+ const actualVersion =
1676
+ (await getPostgresVersionFromBinary(dirPath)) || majorVersion
1677
+
1678
+ // Get directory size (using lstat to avoid following symlinks)
1679
+ let sizeBytes = 0
1680
+ try {
1681
+ const files = await readdir(dirPath, { recursive: true })
1682
+ for (const file of files) {
1683
+ try {
1684
+ const filePath = join(dirPath, file.toString())
1685
+ const fileStat = await lstat(filePath)
1686
+ // Only count regular files (not symlinks or directories)
1687
+ if (fileStat.isFile()) {
1688
+ sizeBytes += fileStat.size
1689
+ }
1690
+ } catch {
1691
+ // Skip files we can't stat
1627
1692
  }
1628
- } catch {
1629
- // Skip files we can't stat
1630
1693
  }
1694
+ } catch {
1695
+ // Skip directories we can't read
1631
1696
  }
1632
- } catch {
1633
- // Skip directories we can't read
1634
- }
1635
1697
 
1636
- engines.push({
1637
- engine,
1638
- version,
1639
- platform,
1640
- arch,
1641
- path: dirPath,
1642
- sizeBytes,
1643
- })
1698
+ engines.push({
1699
+ engine: 'postgresql',
1700
+ version: actualVersion,
1701
+ platform,
1702
+ arch,
1703
+ path: dirPath,
1704
+ sizeBytes,
1705
+ source: 'downloaded',
1706
+ })
1707
+ }
1644
1708
  }
1645
1709
  }
1646
1710
  }
1647
1711
 
1648
- // Sort by engine name, then by version (descending)
1649
- engines.sort((a, b) => {
1650
- if (a.engine !== b.engine) return a.engine.localeCompare(b.engine)
1651
- return compareVersions(b.version, a.version)
1652
- })
1712
+ // Detect system-installed MySQL
1713
+ const mysqldPath = await getMysqldPath()
1714
+ if (mysqldPath) {
1715
+ const version = await getMysqlVersion(mysqldPath)
1716
+ if (version) {
1717
+ const mariadb = await isMariaDB()
1718
+ engines.push({
1719
+ engine: 'mysql',
1720
+ version,
1721
+ path: mysqldPath,
1722
+ source: 'system',
1723
+ isMariaDB: mariadb,
1724
+ })
1725
+ }
1726
+ }
1727
+
1728
+ // Sort PostgreSQL by version (descending), MySQL stays at end
1729
+ const pgEngines = engines.filter(
1730
+ (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
1731
+ )
1732
+ const mysqlEngine = engines.find(
1733
+ (e): e is InstalledMysqlEngine => e.engine === 'mysql',
1734
+ )
1735
+
1736
+ pgEngines.sort((a, b) => compareVersions(b.version, a.version))
1653
1737
 
1654
- return engines
1738
+ const result: InstalledEngine[] = [...pgEngines]
1739
+ if (mysqlEngine) {
1740
+ result.push(mysqlEngine)
1741
+ }
1742
+
1743
+ return result
1655
1744
  }
1656
1745
 
1657
1746
  function compareVersions(a: string, b: string): number {
@@ -1685,54 +1774,104 @@ async function handleEngines(): Promise<void> {
1685
1774
  console.log(info('No engines installed yet.'))
1686
1775
  console.log(
1687
1776
  chalk.gray(
1688
- ' Engines are downloaded automatically when you create a container.',
1777
+ ' PostgreSQL engines are downloaded automatically when you create a container.',
1778
+ ),
1779
+ )
1780
+ console.log(
1781
+ chalk.gray(
1782
+ ' MySQL requires system installation (brew install mysql or apt install mysql-server).',
1689
1783
  ),
1690
1784
  )
1691
1785
  return
1692
1786
  }
1693
1787
 
1694
- // Calculate total size
1695
- const totalSize = engines.reduce((acc, e) => acc + e.sizeBytes, 0)
1788
+ // Separate PostgreSQL and MySQL
1789
+ const pgEngines = engines.filter(
1790
+ (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
1791
+ )
1792
+ const mysqlEngine = engines.find(
1793
+ (e): e is InstalledMysqlEngine => e.engine === 'mysql',
1794
+ )
1795
+
1796
+ // Calculate total size for PostgreSQL
1797
+ const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
1696
1798
 
1697
1799
  // Table header
1698
1800
  console.log()
1699
1801
  console.log(
1700
1802
  chalk.gray(' ') +
1701
- chalk.bold.white('ENGINE'.padEnd(12)) +
1803
+ chalk.bold.white('ENGINE'.padEnd(14)) +
1702
1804
  chalk.bold.white('VERSION'.padEnd(12)) +
1703
- chalk.bold.white('PLATFORM'.padEnd(20)) +
1805
+ chalk.bold.white('SOURCE'.padEnd(18)) +
1704
1806
  chalk.bold.white('SIZE'),
1705
1807
  )
1706
1808
  console.log(chalk.gray(' ' + '─'.repeat(55)))
1707
1809
 
1708
- // Table rows
1709
- for (const engine of engines) {
1810
+ // PostgreSQL rows
1811
+ for (const engine of pgEngines) {
1812
+ const icon = engineIcons[engine.engine] || '🗄️'
1813
+ const platformInfo = `${engine.platform}-${engine.arch}`
1814
+
1710
1815
  console.log(
1711
1816
  chalk.gray(' ') +
1712
- chalk.cyan(engine.engine.padEnd(12)) +
1817
+ chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
1713
1818
  chalk.yellow(engine.version.padEnd(12)) +
1714
- chalk.gray(`${engine.platform}-${engine.arch}`.padEnd(20)) +
1819
+ chalk.gray(platformInfo.padEnd(18)) +
1715
1820
  chalk.white(formatBytes(engine.sizeBytes)),
1716
1821
  )
1717
1822
  }
1718
1823
 
1824
+ // MySQL row
1825
+ if (mysqlEngine) {
1826
+ const icon = engineIcons.mysql
1827
+ const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
1828
+
1829
+ console.log(
1830
+ chalk.gray(' ') +
1831
+ chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
1832
+ chalk.yellow(mysqlEngine.version.padEnd(12)) +
1833
+ chalk.gray('system'.padEnd(18)) +
1834
+ chalk.gray('(system-installed)'),
1835
+ )
1836
+ }
1837
+
1719
1838
  console.log(chalk.gray(' ' + '─'.repeat(55)))
1720
- console.log(
1721
- chalk.gray(' ') +
1722
- chalk.bold.white(`${engines.length} version(s)`.padEnd(44)) +
1723
- chalk.bold.white(formatBytes(totalSize)),
1724
- )
1839
+
1840
+ // Summary
1841
+ console.log()
1842
+ if (pgEngines.length > 0) {
1843
+ console.log(
1844
+ chalk.gray(
1845
+ ` PostgreSQL: ${pgEngines.length} version(s), ${formatBytes(totalPgSize)}`,
1846
+ ),
1847
+ )
1848
+ }
1849
+ if (mysqlEngine) {
1850
+ console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
1851
+ }
1725
1852
  console.log()
1726
1853
 
1727
- // Menu options
1728
- const choices: MenuChoice[] = [
1729
- ...engines.map((e) => ({
1854
+ // Menu options - only allow deletion of PostgreSQL engines
1855
+ const choices: MenuChoice[] = []
1856
+
1857
+ for (const e of pgEngines) {
1858
+ choices.push({
1730
1859
  name: `${chalk.red('✕')} Delete ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
1731
1860
  value: `delete:${e.path}:${e.engine}:${e.version}`,
1732
- })),
1733
- new inquirer.Separator(),
1734
- { name: `${chalk.blue('←')} Back to main menu`, value: 'back' },
1735
- ]
1861
+ })
1862
+ }
1863
+
1864
+ // MySQL info option (not disabled, shows info icon)
1865
+ if (mysqlEngine) {
1866
+ const displayName = mysqlEngine.isMariaDB ? 'MariaDB' : 'MySQL'
1867
+ choices.push({
1868
+ name: `${chalk.blue('ℹ')} ${displayName} ${mysqlEngine.version} ${chalk.gray('(system-installed)')}`,
1869
+ value: `mysql-info:${mysqlEngine.path}`,
1870
+ })
1871
+ }
1872
+
1873
+ choices.push(new inquirer.Separator())
1874
+ choices.push({ name: `${chalk.blue('←')} Back to main menu`, value: 'back' })
1736
1875
 
1737
1876
  const { action } = await inquirer.prompt<{ action: string }>([
1738
1877
  {
@@ -1754,6 +1893,13 @@ async function handleEngines(): Promise<void> {
1754
1893
  // Return to engines menu
1755
1894
  await handleEngines()
1756
1895
  }
1896
+
1897
+ if (action.startsWith('mysql-info:')) {
1898
+ const mysqldPath = action.replace('mysql-info:', '')
1899
+ await handleMysqlInfo(mysqldPath)
1900
+ // Return to engines menu
1901
+ await handleEngines()
1902
+ }
1757
1903
  }
1758
1904
 
1759
1905
  async function handleDeleteEngine(
@@ -1812,6 +1958,174 @@ async function handleDeleteEngine(
1812
1958
  }
1813
1959
  }
1814
1960
 
1961
+ async function handleMysqlInfo(mysqldPath: string): Promise<void> {
1962
+ console.clear()
1963
+
1964
+ // Get install info
1965
+ const installInfo = await getMysqlInstallInfo(mysqldPath)
1966
+ const displayName = installInfo.isMariaDB ? 'MariaDB' : 'MySQL'
1967
+
1968
+ // Get version
1969
+ const version = await getMysqlVersion(mysqldPath)
1970
+
1971
+ console.log(header(`${displayName} Information`))
1972
+ console.log()
1973
+
1974
+ // Check for containers using MySQL
1975
+ const containers = await containerManager.list()
1976
+ const mysqlContainers = containers.filter((c) => c.engine === 'mysql')
1977
+
1978
+ // Track running containers for uninstall instructions
1979
+ const runningContainers: string[] = []
1980
+
1981
+ if (mysqlContainers.length > 0) {
1982
+ console.log(
1983
+ warning(
1984
+ `${mysqlContainers.length} container(s) are using ${displayName}:`,
1985
+ ),
1986
+ )
1987
+ console.log()
1988
+ for (const c of mysqlContainers) {
1989
+ const isRunning = await processManager.isRunning(c.name, {
1990
+ engine: c.engine,
1991
+ })
1992
+ if (isRunning) {
1993
+ runningContainers.push(c.name)
1994
+ }
1995
+ const status = isRunning
1996
+ ? chalk.green('● running')
1997
+ : chalk.gray('○ stopped')
1998
+ console.log(chalk.gray(` • ${c.name} ${status}`))
1999
+ }
2000
+ console.log()
2001
+ console.log(
2002
+ chalk.yellow(
2003
+ ' Uninstalling will break these containers. Delete them first.',
2004
+ ),
2005
+ )
2006
+ console.log()
2007
+ }
2008
+
2009
+ // Show installation details
2010
+ console.log(chalk.white(' Installation Details:'))
2011
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
2012
+ console.log(
2013
+ chalk.gray(' ') +
2014
+ chalk.white('Version:'.padEnd(18)) +
2015
+ chalk.yellow(version || 'unknown'),
2016
+ )
2017
+ console.log(
2018
+ chalk.gray(' ') +
2019
+ chalk.white('Binary Path:'.padEnd(18)) +
2020
+ chalk.gray(mysqldPath),
2021
+ )
2022
+ console.log(
2023
+ chalk.gray(' ') +
2024
+ chalk.white('Package Manager:'.padEnd(18)) +
2025
+ chalk.cyan(installInfo.packageManager),
2026
+ )
2027
+ console.log(
2028
+ chalk.gray(' ') +
2029
+ chalk.white('Package Name:'.padEnd(18)) +
2030
+ chalk.cyan(installInfo.packageName),
2031
+ )
2032
+ console.log()
2033
+
2034
+ // Uninstall instructions
2035
+ console.log(chalk.white(' To uninstall:'))
2036
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
2037
+
2038
+ let stepNum = 1
2039
+
2040
+ // Step: Stop running containers first
2041
+ if (runningContainers.length > 0) {
2042
+ console.log(chalk.gray(` # ${stepNum}. Stop running SpinDB containers`))
2043
+ console.log(chalk.cyan(' spindb stop <container-name>'))
2044
+ console.log()
2045
+ stepNum++
2046
+ }
2047
+
2048
+ // Step: Delete SpinDB containers
2049
+ if (mysqlContainers.length > 0) {
2050
+ console.log(chalk.gray(` # ${stepNum}. Delete SpinDB containers`))
2051
+ console.log(chalk.cyan(' spindb delete <container-name>'))
2052
+ console.log()
2053
+ stepNum++
2054
+ }
2055
+
2056
+ if (installInfo.packageManager === 'homebrew') {
2057
+ console.log(
2058
+ chalk.gray(
2059
+ ` # ${stepNum}. Stop Homebrew service (if running separately)`,
2060
+ ),
2061
+ )
2062
+ console.log(chalk.cyan(` brew services stop ${installInfo.packageName}`))
2063
+ console.log()
2064
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2065
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2066
+ } else if (installInfo.packageManager === 'apt') {
2067
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2068
+ console.log(
2069
+ chalk.cyan(
2070
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
2071
+ ),
2072
+ )
2073
+ console.log()
2074
+ console.log(chalk.gray(` # ${stepNum + 1}. Disable auto-start on boot`))
2075
+ console.log(
2076
+ chalk.cyan(
2077
+ ` sudo systemctl disable ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
2078
+ ),
2079
+ )
2080
+ console.log()
2081
+ console.log(chalk.gray(` # ${stepNum + 2}. Uninstall the package`))
2082
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2083
+ console.log()
2084
+ console.log(chalk.gray(` # ${stepNum + 3}. Remove data files (optional)`))
2085
+ console.log(
2086
+ chalk.cyan(' sudo apt purge mysql-server mysql-client mysql-common'),
2087
+ )
2088
+ console.log(chalk.cyan(' sudo rm -rf /var/lib/mysql /etc/mysql'))
2089
+ } else if (
2090
+ installInfo.packageManager === 'yum' ||
2091
+ installInfo.packageManager === 'dnf'
2092
+ ) {
2093
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2094
+ console.log(
2095
+ chalk.cyan(
2096
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
2097
+ ),
2098
+ )
2099
+ console.log()
2100
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2101
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2102
+ } else if (installInfo.packageManager === 'pacman') {
2103
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2104
+ console.log(
2105
+ chalk.cyan(
2106
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
2107
+ ),
2108
+ )
2109
+ console.log()
2110
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2111
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2112
+ } else {
2113
+ console.log(chalk.gray(' Use your system package manager to uninstall.'))
2114
+ console.log(chalk.gray(` The binary is located at: ${mysqldPath}`))
2115
+ }
2116
+
2117
+ console.log()
2118
+
2119
+ // Wait for user
2120
+ await inquirer.prompt([
2121
+ {
2122
+ type: 'input',
2123
+ name: 'continue',
2124
+ message: chalk.gray('Press Enter to go back...'),
2125
+ },
2126
+ ])
2127
+ }
2128
+
1815
2129
  export const menuCommand = new Command('menu')
1816
2130
  .description('Interactive menu for managing containers')
1817
2131
  .action(async () => {
@@ -1833,9 +2147,7 @@ export const menuCommand = new Command('menu')
1833
2147
  : 'psql'
1834
2148
  const installed = await promptInstallDependencies(missingTool)
1835
2149
  if (installed) {
1836
- console.log(
1837
- chalk.yellow(' Please re-run spindb to continue.'),
1838
- )
2150
+ console.log(chalk.yellow(' Please re-run spindb to continue.'))
1839
2151
  }
1840
2152
  process.exit(1)
1841
2153
  }