spindb 0.30.1 → 0.30.7

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.
package/README.md CHANGED
@@ -386,6 +386,36 @@ Generated files:
386
386
  - `entrypoint.sh` - Startup script
387
387
  - `README.md` - Instructions
388
388
 
389
+ ### Deploying Your Container
390
+
391
+ **SpinDB doesn't require Docker for local development**, but it can repackage your database as a Docker image for deployment to cloud servers, EC2 instances, Kubernetes clusters, or any Docker-compatible environment.
392
+
393
+ ```bash
394
+ # Export your local database to Docker
395
+ spindb export docker mydb -o ./mydb-deploy
396
+
397
+ # Build and run
398
+ cd ./mydb-deploy
399
+ docker compose build --no-cache
400
+ docker compose up -d
401
+
402
+ # Connect from host (credentials in .env)
403
+ source .env
404
+ psql "postgresql://$SPINDB_USER:$SPINDB_PASSWORD@localhost:$PORT/$DATABASE"
405
+ ```
406
+
407
+ **Schema-only vs Full Data:**
408
+ ```bash
409
+ spindb export docker mydb # Include all data (default)
410
+ spindb export docker mydb --no-data # Schema only (empty tables)
411
+ ```
412
+
413
+ > **Development Tool Notice:** SpinDB is currently a development tool. While Docker exports include TLS encryption and authentication, they are intended for staging and testing—not production workloads. For production databases, consider managed services.
414
+
415
+ **Future Export Options:** Additional export targets are planned for future releases, including direct deployment to managed database services like Neon, Supabase, and PlanetScale.
416
+
417
+ See [DEPLOY.md](DEPLOY.md) for comprehensive deployment documentation.
418
+
389
419
  ### Container Management
390
420
 
391
421
  ```bash
@@ -433,7 +463,8 @@ spindb deps install # Install missing tools
433
463
  # Configuration
434
464
  spindb config show # Show current config
435
465
  spindb config detect # Re-detect tool paths
436
- spindb config update-check on # Enable update notifications
466
+ spindb config update-check on # Enable update checks
467
+ spindb config update-check off # Disable update checks
437
468
 
438
469
  # Doctor
439
470
  spindb doctor # Interactive health check
@@ -66,7 +66,7 @@ import { Engine, isFileBasedEngine } from '../../../types'
66
66
  import { type MenuChoice, pressEnterToContinue } from './shared'
67
67
  import { getEngineIcon } from '../../constants'
68
68
 
69
- export async function handleCreate(): Promise<'main' | void> {
69
+ export async function handleCreate(): Promise<'main' | string | void> {
70
70
  console.log()
71
71
  console.log(header('Create New Database Container'))
72
72
  console.log()
@@ -342,11 +342,11 @@ export async function handleCreate(): Promise<'main' | void> {
342
342
  {
343
343
  type: 'input',
344
344
  name: 'continue',
345
- message: chalk.gray('Press Enter to return to the main menu...'),
345
+ message: chalk.gray('Press Enter to continue...'),
346
346
  },
347
347
  ])
348
348
  }
349
- return
349
+ return containerNameFinal
350
350
  }
351
351
 
352
352
  // Server databases: start and create database
@@ -410,7 +410,7 @@ export async function handleCreate(): Promise<'main' | void> {
410
410
  {
411
411
  type: 'input',
412
412
  name: 'continue',
413
- message: chalk.gray('Press Enter to return to the main menu...'),
413
+ message: chalk.gray('Press Enter to continue...'),
414
414
  },
415
415
  ])
416
416
  }
@@ -426,7 +426,18 @@ export async function handleCreate(): Promise<'main' | void> {
426
426
  `Start it later with: ${chalk.cyan(`spindb start ${containerNameFinal}`)}`,
427
427
  ),
428
428
  )
429
+ console.log()
430
+
431
+ await escapeablePrompt([
432
+ {
433
+ type: 'input',
434
+ name: 'continue',
435
+ message: chalk.gray('Press Enter to continue...'),
436
+ },
437
+ ])
429
438
  }
439
+
440
+ return containerNameFinal
430
441
  }
431
442
 
432
443
  export async function handleList(
@@ -557,14 +568,13 @@ export async function handleList(
557
568
  (c) => !isFileBasedEngine(c.engine),
558
569
  )
559
570
 
560
- // Build hints string (bottom of list)
561
- const hints = hasServerContainers ? chalk.gray('[Shift]+[Tab] - toggle') : ''
562
-
563
- // Build the full choice list with footer items
564
- const summary = hints
565
- ? `${containers.length} container(s): ${parts.join('; ')} — ${hints}`
566
- : `${containers.length} container(s): ${parts.join('; ')}`
571
+ // Build the full choice list with header hint and footer items
572
+ const summary = `${containers.length} container(s): ${parts.join('; ')}`
567
573
  const allChoices: (FilterableChoice | inquirer.Separator)[] = [
574
+ // Show toggle hint at top when server-based containers exist
575
+ ...(hasServerContainers
576
+ ? [new inquirer.Separator(chalk.cyan('── [Shift+Tab] toggle start/stop ──'))]
577
+ : []),
568
578
  ...containerChoices,
569
579
  new inquirer.Separator(),
570
580
  new inquirer.Separator(summary),
@@ -694,12 +704,14 @@ export async function showContainerSubmenu(
694
704
  // Build action choices based on engine type
695
705
  const actionChoices: MenuChoice[] = []
696
706
 
697
- // Helper for disabled menu items - passes hint to inquirer's disabled property
698
- const disabledItem = (icon: string, label: string, hint: string) => ({
699
- name: chalk.gray(`${icon} ${label}`),
700
- value: '_disabled_',
701
- disabled: chalk.gray(hint), // String value shows as the reason instead of "(Disabled)"
702
- })
707
+ // Helper for disabled menu items (hint shown in separator, not on each item)
708
+ function disabledItem(icon: string, label: string) {
709
+ return {
710
+ name: chalk.gray(`${icon} ${label}`),
711
+ value: '_disabled_',
712
+ disabled: '', // Empty string hides the "(Disabled)" text
713
+ }
714
+ }
703
715
 
704
716
  // Determine if database-specific actions can be performed
705
717
  // Requires: database selected + (running for server DBs OR file exists for file-based DBs)
@@ -707,13 +719,25 @@ export async function showContainerSubmenu(
707
719
  const hasMultipleDatabases = databases.length > 1
708
720
  const canDoDbAction = !!activeDatabase && containerReady
709
721
 
710
- // Hint for disabled database actions
711
- const getDbActionHint = () => {
712
- if (!activeDatabase && hasMultipleDatabases) return 'Select database first'
722
+ // Label for data section separator - shows state or required action
723
+ function getDataSectionLabel(): string {
713
724
  if (!containerReady) {
714
725
  return isFileBasedDB ? 'Database file missing' : 'Start container first'
715
726
  }
716
- return 'Select database first'
727
+ if (!activeDatabase && hasMultipleDatabases) {
728
+ return 'Select database first'
729
+ }
730
+ // Show positive state when actions are available
731
+ return isFileBasedDB ? 'Available' : 'Running'
732
+ }
733
+
734
+ // Label for management section separator - shows state or required action
735
+ function getManageSectionLabel(): string {
736
+ if (!isFileBasedDB && isRunning) {
737
+ return 'Stop container first'
738
+ }
739
+ // Show positive state when actions are available
740
+ return isFileBasedDB ? 'Available' : 'Stopped'
717
741
  }
718
742
 
719
743
  // ─────────────────────────────────────────────────────────────────────────────
@@ -733,6 +757,12 @@ export async function showContainerSubmenu(
733
757
  value: 'stop',
734
758
  })
735
759
  }
760
+
761
+ // View logs - available anytime for server-based DBs
762
+ actionChoices.push({
763
+ name: `${chalk.gray('☰')} View logs`,
764
+ value: 'logs',
765
+ })
736
766
  }
737
767
 
738
768
  // Database selection - show current selection or prompt to select
@@ -748,18 +778,20 @@ export async function showContainerSubmenu(
748
778
  })
749
779
  }
750
780
 
751
- actionChoices.push(new inquirer.Separator())
752
-
753
781
  // ─────────────────────────────────────────────────────────────────────────────
754
782
  // SECTION 2: Data Operations
783
+ // Separator shows current state or required action
755
784
  // ─────────────────────────────────────────────────────────────────────────────
785
+ const dataSectionLabel = getDataSectionLabel()
786
+ actionChoices.push(
787
+ new inquirer.Separator(chalk.gray(`── ${dataSectionLabel} ──`)),
788
+ )
756
789
 
757
790
  // Open shell - requires database selection for multi-db containers
758
- const shellHint = getDbActionHint()
759
791
  actionChoices.push(
760
792
  canDoDbAction
761
793
  ? { name: `${chalk.blue('>')} Open shell`, value: 'shell' }
762
- : disabledItem('>', 'Open shell', shellHint),
794
+ : disabledItem('>', 'Open shell'),
763
795
  )
764
796
 
765
797
  // Run SQL/script - requires database selection for multi-db containers
@@ -778,66 +810,64 @@ export async function showContainerSubmenu(
778
810
  : config.engine === Engine.SurrealDB
779
811
  ? 'Run SurrealQL file'
780
812
  : 'Run SQL file'
781
- const runSqlHint = getDbActionHint()
782
813
  actionChoices.push(
783
814
  canDoDbAction
784
815
  ? { name: `${chalk.yellow('▷')} ${runScriptLabel}`, value: 'run-sql' }
785
- : disabledItem('▷', runScriptLabel, runSqlHint),
816
+ : disabledItem('▷', runScriptLabel),
786
817
  )
787
818
  }
788
819
 
789
820
  // Copy connection string - requires database selection for multi-db containers
790
- const copyHint = getDbActionHint()
791
821
  actionChoices.push(
792
822
  canDoDbAction
793
823
  ? { name: `${chalk.magenta('⊕')} Copy connection string`, value: 'copy' }
794
- : disabledItem('⊕', 'Copy connection string', copyHint),
824
+ : disabledItem('⊕', 'Copy connection string'),
795
825
  )
796
826
 
797
827
  // Backup - requires database selection for multi-db containers
798
- const backupHint = getDbActionHint()
799
828
  actionChoices.push(
800
829
  canDoDbAction
801
830
  ? { name: `${chalk.magenta('↓')} Backup database`, value: 'backup' }
802
- : disabledItem('↓', 'Backup database', backupHint),
831
+ : disabledItem('↓', 'Backup database'),
803
832
  )
804
833
 
805
834
  // Restore - requires database selection for multi-db containers
806
- const restoreHint = getDbActionHint()
807
835
  actionChoices.push(
808
836
  canDoDbAction
809
837
  ? { name: `${chalk.magenta('↑')} Restore from backup`, value: 'restore' }
810
- : disabledItem('↑', 'Restore from backup', restoreHint),
838
+ : disabledItem('↑', 'Restore from backup'),
811
839
  )
812
840
 
813
- // View logs - not available for file-based DBs (no log file)
814
- if (!isFileBasedDB) {
815
- actionChoices.push({
816
- name: `${chalk.gray('')} View logs`,
817
- value: 'logs',
818
- })
819
- }
820
-
821
- actionChoices.push(new inquirer.Separator())
841
+ // Export - server-based DBs must be running, file-based must have the file
842
+ actionChoices.push(
843
+ containerReady
844
+ ? { name: `${chalk.cyan('')} Export`, value: 'export' }
845
+ : disabledItem('', 'Export'),
846
+ )
822
847
 
823
848
  // ─────────────────────────────────────────────────────────────────────────────
824
- // SECTION 3: Container Management (requires stopped)
849
+ // SECTION 3: Container Management (requires stopped for server-based)
850
+ // Separator shows current state or required action
825
851
  // ─────────────────────────────────────────────────────────────────────────────
852
+ const manageSectionLabel = getManageSectionLabel()
853
+ actionChoices.push(
854
+ new inquirer.Separator(chalk.gray(`── ${manageSectionLabel} ──`)),
855
+ )
826
856
 
827
857
  // Edit container - file-based DBs can always edit (no running state), server databases must be stopped
828
- const canEdit = isFileBasedDB ? true : !isRunning
858
+ const canEdit = isFileBasedDB || !isRunning
829
859
  actionChoices.push(
830
860
  canEdit
831
861
  ? { name: `${chalk.yellow('⚙')} Edit container`, value: 'edit' }
832
- : disabledItem('⚙', 'Edit container', 'Stop container first'),
862
+ : disabledItem('⚙', 'Edit container'),
833
863
  )
834
864
 
835
865
  // Clone container - file-based DBs can always clone, server databases must be stopped
836
- const canClone = isFileBasedDB ? true : !isRunning
866
+ const canClone = isFileBasedDB || !isRunning
837
867
  actionChoices.push(
838
868
  canClone
839
869
  ? { name: `${chalk.cyan('◇')} Clone container`, value: 'clone' }
840
- : disabledItem('◇', 'Clone container', 'Stop container first'),
870
+ : disabledItem('◇', 'Clone container'),
841
871
  )
842
872
 
843
873
  // Detach - only for file-based DBs (unregisters without deleting file)
@@ -849,22 +879,11 @@ export async function showContainerSubmenu(
849
879
  }
850
880
 
851
881
  // Delete container - file-based DBs can always delete, server databases must be stopped
852
- const canDelete = isFileBasedDB ? true : !isRunning
882
+ const canDelete = isFileBasedDB || !isRunning
853
883
  actionChoices.push(
854
884
  canDelete
855
885
  ? { name: `${chalk.red('✕')} Delete container`, value: 'delete' }
856
- : disabledItem('✕', 'Delete container', 'Stop container first'),
857
- )
858
-
859
- // Export - server-based DBs must be running, file-based must have the file
860
- const canExport = containerReady
861
- const exportHint = isFileBasedDB
862
- ? 'Database file missing'
863
- : 'Start container first'
864
- actionChoices.push(
865
- canExport
866
- ? { name: `${chalk.cyan('↑')} Export`, value: 'export' }
867
- : disabledItem('⬆', 'Export', exportHint),
886
+ : disabledItem('✕', 'Delete container'),
868
887
  )
869
888
 
870
889
  actionChoices.push(new inquirer.Separator())
@@ -2,6 +2,7 @@ import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import inquirer from 'inquirer'
4
4
  import { containerManager } from '../../../core/container-manager'
5
+ import { updateManager, type UpdateCheckResult } from '../../../core/update-manager'
5
6
  import {
6
7
  promptInstallDependencies,
7
8
  enableGlobalEscape,
@@ -9,35 +10,48 @@ import {
9
10
  escapeablePrompt,
10
11
  EscapeError,
11
12
  } from '../../ui/prompts'
12
- import { header, uiError } from '../../ui/theme'
13
+ import { header, uiError, uiSuccess, uiWarning } from '../../ui/theme'
13
14
  import { MissingToolError } from '../../../core/error-handler'
14
- import { hasAnyInstalledEngines } from '../../helpers'
15
15
  import {
16
16
  handleCreate,
17
17
  handleList,
18
- handleStart,
19
- handleStop,
18
+ showContainerSubmenu,
20
19
  } from './container-handlers'
21
- import { handleBackup, handleRestore, handleClone } from './backup-handlers'
22
- import { handleEngines } from './engine-handlers'
23
- import { handleCheckUpdate, handleDoctor } from './update-handlers'
24
20
  import { handleSettings } from './settings-handlers'
25
21
  import { configManager } from '../../../core/config-manager'
26
- import { type MenuChoice } from './shared'
22
+ import { createSpinner } from '../../ui/spinner'
23
+ import { type MenuChoice, pressEnterToContinue } from './shared'
27
24
  import { getPageSize } from '../../constants'
28
25
 
26
+ // Track update check state for this session (only check once on first menu load)
27
+ let updateCheckPromise: Promise<UpdateCheckResult | null> | null = null
28
+ let cachedUpdateResult: UpdateCheckResult | null = null
29
+
29
30
  async function showMainMenu(): Promise<void> {
30
31
  console.clear()
31
32
  console.log(header('SpinDB - Local Database Manager'))
32
33
  console.log()
33
34
 
34
- // Parallelize container list, engine checks, and config loading for faster startup
35
- const [containers, hasEngines, config] = await Promise.all([
35
+ // Parallelize container list and config loading for faster startup
36
+ const [containers, config] = await Promise.all([
36
37
  containerManager.list(),
37
- hasAnyInstalledEngines(),
38
38
  configManager.getConfig(),
39
39
  ])
40
40
 
41
+ // Check for updates on first menu load only (if auto-check is enabled)
42
+ // The check runs in background and updates cachedUpdateResult when complete
43
+ const autoCheckEnabled = config.update?.autoCheckEnabled !== false
44
+ if (autoCheckEnabled && !updateCheckPromise) {
45
+ // Start update check in background - it will populate cachedUpdateResult when done
46
+ updateCheckPromise = updateManager
47
+ .checkForUpdate()
48
+ .then((result) => {
49
+ cachedUpdateResult = result
50
+ return result
51
+ })
52
+ .catch(() => null)
53
+ }
54
+
41
55
  // Check if icon mode preference is set
42
56
  const iconModeSet = config.preferences?.iconMode !== undefined
43
57
 
@@ -51,12 +65,7 @@ async function showMainMenu(): Promise<void> {
51
65
  )
52
66
  console.log()
53
67
 
54
- const canStart = stopped > 0
55
- const canStop = running > 0
56
- const canRestore = running > 0
57
- const canClone = containers.length > 0
58
-
59
- // If containers exist, show List first; otherwise show Create first
68
+ // If containers exist, show Containers first; otherwise show Create first
60
69
  const hasContainers = containers.length > 0
61
70
 
62
71
  const choices: MenuChoice[] = [
@@ -69,53 +78,18 @@ async function showMainMenu(): Promise<void> {
69
78
  { name: `${chalk.green('+')} Create container`, value: 'create' },
70
79
  { name: `${chalk.cyan('◉')} Containers`, value: 'list' },
71
80
  ]),
72
- {
73
- name: canStart
74
- ? `${chalk.green('▶')} Start container`
75
- : chalk.gray('▶ Start container'),
76
- value: 'start',
77
- disabled: canStart ? false : 'No stopped containers',
78
- },
79
- {
80
- name: canStop
81
- ? `${chalk.red('■')} Stop container`
82
- : chalk.gray('■ Stop container'),
83
- value: 'stop',
84
- disabled: canStop ? false : 'No running containers',
85
- },
86
- {
87
- name: canRestore
88
- ? `${chalk.magenta('↓')} Backup database`
89
- : chalk.gray('↓ Backup database'),
90
- value: 'backup',
91
- disabled: canRestore ? false : 'No running containers',
92
- },
93
- {
94
- name: canRestore
95
- ? `${chalk.magenta('↑')} Restore backup`
96
- : chalk.gray('↑ Restore backup'),
97
- value: 'restore',
98
- disabled: canRestore ? false : 'No running containers',
99
- },
100
- {
101
- name: canClone
102
- ? `${chalk.cyan('◇')} Clone container`
103
- : chalk.gray('◇ Clone container'),
104
- value: 'clone',
105
- disabled: canClone ? false : 'No containers',
106
- },
107
81
  new inquirer.Separator(),
108
- {
109
- name: hasEngines
110
- ? `${chalk.magenta('⬢')} Manage engines`
111
- : chalk.gray('⬢ Manage engines'),
112
- value: 'engines',
113
- disabled: hasEngines ? false : 'No engines installed',
114
- },
115
- { name: `${chalk.red.bold('+')} Health check`, value: 'doctor' },
116
- { name: `${chalk.cyan('↑')} Check for updates`, value: 'check-update' },
117
82
  { name: `${chalk.yellow('⚙')} Settings`, value: 'settings' },
118
- { name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
83
+ // Show update option if a new version is available (only when auto-check enabled)
84
+ ...(cachedUpdateResult?.updateAvailable
85
+ ? [
86
+ {
87
+ name: `${chalk.green('↑')} Update to v${cachedUpdateResult.latestVersion}`,
88
+ value: 'update',
89
+ },
90
+ ]
91
+ : []),
92
+ { name: `${chalk.gray('⎋')} Exit`, value: 'exit' },
119
93
  new inquirer.Separator(),
120
94
  ]
121
95
 
@@ -150,45 +124,75 @@ async function showMainMenu(): Promise<void> {
150
124
  }
151
125
 
152
126
  switch (action) {
153
- case 'create':
154
- await handleCreate()
127
+ case 'create': {
128
+ const result = await handleCreate()
129
+ // If a container name is returned, navigate to its submenu
130
+ if (result && result !== 'main') {
131
+ await showContainerSubmenu(result, showMainMenu)
132
+ }
155
133
  break
134
+ }
156
135
  case 'list':
157
136
  await handleList(showMainMenu)
158
137
  break
159
- case 'start':
160
- await handleStart()
161
- break
162
- case 'stop':
163
- await handleStop()
164
- break
165
- case 'restore':
166
- await handleRestore()
167
- break
168
- case 'backup':
169
- await handleBackup()
170
- break
171
- case 'clone':
172
- await handleClone()
173
- break
174
- case 'engines':
175
- await handleEngines()
176
- break
177
- case 'doctor':
178
- await handleDoctor()
179
- break
180
- case 'check-update':
181
- await handleCheckUpdate()
182
- break
183
138
  case 'settings':
184
139
  await handleSettings()
185
140
  break
141
+ case 'update':
142
+ await handleUpdate()
143
+ break
186
144
  case 'exit':
187
145
  console.log(chalk.gray('\n Goodbye!\n'))
188
146
  process.exit(0)
189
147
  }
148
+ }
149
+
150
+ async function handleUpdate(): Promise<void> {
151
+ console.clear()
152
+ console.log(header('Update SpinDB'))
153
+ console.log()
154
+
155
+ if (!cachedUpdateResult) {
156
+ console.log(uiError('No update information available'))
157
+ await pressEnterToContinue()
158
+ return
159
+ }
160
+
161
+ console.log(chalk.gray(` Current version: ${cachedUpdateResult.currentVersion}`))
162
+ console.log(
163
+ chalk.gray(` Latest version: ${chalk.green(cachedUpdateResult.latestVersion)}`),
164
+ )
165
+ console.log()
166
+
167
+ const spinner = createSpinner('Updating spindb...')
168
+ spinner.start()
169
+
170
+ const result = await updateManager.performUpdate()
171
+
172
+ if (result.success) {
173
+ spinner.succeed('Update complete')
174
+ console.log()
175
+ console.log(
176
+ uiSuccess(`Updated from ${result.previousVersion} to ${result.newVersion}`),
177
+ )
178
+ console.log()
179
+ if (result.previousVersion !== result.newVersion) {
180
+ console.log(uiWarning('Please restart spindb to use the new version.'))
181
+ console.log()
182
+ }
183
+ // Clear cached result so the update option disappears
184
+ cachedUpdateResult = null
185
+ updateCheckPromise = null
186
+ } else {
187
+ spinner.fail('Update failed')
188
+ console.log()
189
+ console.log(uiError(result.error || 'Unknown error'))
190
+ console.log()
191
+ const pm = await updateManager.detectPackageManager()
192
+ console.log(chalk.gray(` Manual update: ${updateManager.getInstallCommand(pm)}`))
193
+ }
190
194
 
191
- await showMainMenu()
195
+ await pressEnterToContinue()
192
196
  }
193
197
 
194
198
  export const menuCommand = new Command('menu')
@@ -4,9 +4,12 @@ import { configManager } from '../../../core/config-manager'
4
4
  import { updateManager } from '../../../core/update-manager'
5
5
  import { escapeablePrompt } from '../../ui/prompts'
6
6
  import { header, uiSuccess, uiInfo } from '../../ui/theme'
7
- import { setCachedIconMode, ENGINE_BRAND_COLORS } from '../../constants'
7
+ import { setCachedIconMode, ENGINE_BRAND_COLORS, getPageSize } from '../../constants'
8
+ import { hasAnyInstalledEngines } from '../../helpers'
8
9
  import { Engine, type IconMode } from '../../../types'
9
10
  import { type MenuChoice, pressEnterToContinue } from './shared'
11
+ import { handleEngines } from './engine-handlers'
12
+ import { handleCheckUpdate, handleDoctor } from './update-handlers'
10
13
 
11
14
  // Sample engines for icon preview
12
15
  const PREVIEW_ENGINES = [
@@ -178,6 +181,7 @@ async function handleIconModeSettings(): Promise<void> {
178
181
  name: 'iconMode',
179
182
  message: 'Select icon mode:',
180
183
  choices,
184
+ pageSize: getPageSize(),
181
185
  },
182
186
  ])
183
187
 
@@ -244,6 +248,7 @@ async function handleUpdateCheckSettings(): Promise<void> {
244
248
  name: 'action',
245
249
  message: 'Update check setting:',
246
250
  choices,
251
+ pageSize: getPageSize(),
247
252
  },
248
253
  ])
249
254
 
@@ -273,9 +278,12 @@ async function handleUpdateCheckSettings(): Promise<void> {
273
278
  */
274
279
  export async function handleSettings(): Promise<void> {
275
280
  while (true) {
276
- const config = await configManager.getConfig()
281
+ const [config, hasEngines, cached] = await Promise.all([
282
+ configManager.getConfig(),
283
+ hasAnyInstalledEngines(),
284
+ updateManager.getCachedUpdateInfo(),
285
+ ])
277
286
  const currentIconMode = config.preferences?.iconMode || 'ascii'
278
- const cached = await updateManager.getCachedUpdateInfo()
279
287
  const updateCheckEnabled = cached.autoCheckEnabled !== false
280
288
 
281
289
  console.clear()
@@ -283,6 +291,16 @@ export async function handleSettings(): Promise<void> {
283
291
  console.log()
284
292
 
285
293
  const choices: MenuChoice[] = [
294
+ {
295
+ name: hasEngines
296
+ ? `${chalk.magenta('⬢')} Manage engines`
297
+ : chalk.gray('⬢ Manage engines'),
298
+ value: 'engines',
299
+ disabled: hasEngines ? false : 'No engines installed',
300
+ },
301
+ { name: `${chalk.red.bold('+')} Health check`, value: 'doctor' },
302
+ { name: `${chalk.cyan('↑')} Check for updates`, value: 'check-update' },
303
+ new inquirer.Separator(),
286
304
  {
287
305
  name: `Icon mode: ${chalk.cyan(currentIconMode)}`,
288
306
  value: 'icon-mode',
@@ -293,7 +311,7 @@ export async function handleSettings(): Promise<void> {
293
311
  },
294
312
  new inquirer.Separator(),
295
313
  {
296
- name: `${chalk.blue('\u2190')} Back`,
314
+ name: `${chalk.blue('')} Back`,
297
315
  value: 'back',
298
316
  },
299
317
  ]
@@ -304,10 +322,20 @@ export async function handleSettings(): Promise<void> {
304
322
  name: 'action',
305
323
  message: 'What would you like to configure?',
306
324
  choices,
325
+ pageSize: getPageSize(),
307
326
  },
308
327
  ])
309
328
 
310
329
  switch (action) {
330
+ case 'engines':
331
+ await handleEngines()
332
+ break
333
+ case 'doctor':
334
+ await handleDoctor()
335
+ break
336
+ case 'check-update':
337
+ await handleCheckUpdate()
338
+ break
311
339
  case 'icon-mode':
312
340
  await handleIconModeSettings()
313
341
  break
@@ -380,7 +380,7 @@ export const restoreCommand = new Command('restore')
380
380
  }
381
381
  }
382
382
 
383
- // Drop existing database
383
+ // Drop existing database (tracking entry stays - we're recreating same name)
384
384
  const dropSpinner = createSpinner(
385
385
  `Dropping existing database "${databaseName}"...`,
386
386
  )
@@ -388,7 +388,8 @@ export const restoreCommand = new Command('restore')
388
388
 
389
389
  try {
390
390
  await engine.dropDatabase(config, databaseName)
391
- await containerManager.removeDatabase(containerName, databaseName)
391
+ // Don't remove from tracking - the database name stays the same
392
+ // and addDatabase() is idempotent, so tracking remains valid
392
393
  dropSpinner.succeed(`Dropped database "${databaseName}"`)
393
394
  } catch (dropErr) {
394
395
  dropSpinner.fail('Failed to drop database')
@@ -159,9 +159,9 @@ function generateDockerfile(
159
159
  // Server-based engines check for running status
160
160
  const healthcheck = isFileBased
161
161
  ? `HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
162
- CMD gosu spindb spindb list --json | grep -q '"engine":"${engine}"'`
162
+ CMD gosu spindb spindb list --json | grep -q '"engine":.*"${engine}"'`
163
163
  : `HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \\
164
- CMD gosu spindb spindb list --json | grep -q '"status":"running"'`
164
+ CMD gosu spindb spindb list --json | grep -q '"status":.*"running"'`
165
165
 
166
166
  // Only copy TLS certificates if they were generated
167
167
  const copyCerts = useTLS
@@ -248,6 +248,37 @@ function generateEntrypoint(
248
248
  ): string {
249
249
  const isFileBased = isFileBasedEngine(engine)
250
250
 
251
+ // Engine-specific network configuration (run after create, before start)
252
+ // This configures databases to accept connections from Docker network
253
+ let networkConfig = ''
254
+
255
+ switch (engine) {
256
+ case Engine.PostgreSQL:
257
+ case Engine.CockroachDB:
258
+ // PostgreSQL/CockroachDB need to listen on all interfaces and allow network connections
259
+ networkConfig = `
260
+ # Configure PostgreSQL to accept connections from Docker network
261
+ echo "Configuring network access..."
262
+ PG_CONF="/home/spindb/.spindb/containers/${engine}/\${CONTAINER_NAME}/data/postgresql.conf"
263
+ PG_HBA="/home/spindb/.spindb/containers/${engine}/\${CONTAINER_NAME}/data/pg_hba.conf"
264
+
265
+ # Set listen_addresses to allow connections from any interface
266
+ if [ -f "$PG_CONF" ]; then
267
+ sed -i "s/^#*listen_addresses.*/listen_addresses = '*'/" "$PG_CONF"
268
+ fi
269
+
270
+ # Add rule to allow password-authenticated connections from any IP
271
+ if [ -f "$PG_HBA" ] && ! grep -q "0.0.0.0/0" "$PG_HBA"; then
272
+ echo "host all all 0.0.0.0/0 scram-sha-256" >> "$PG_HBA"
273
+ fi
274
+ `
275
+ break
276
+
277
+ default:
278
+ // Other engines don't need special network config (they listen on all interfaces by default)
279
+ break
280
+ }
281
+
251
282
  // Engine-specific user creation commands
252
283
  let userCreationCommands = ''
253
284
 
@@ -256,7 +287,7 @@ function generateEntrypoint(
256
287
  userCreationCommands = `
257
288
  # Create user with password
258
289
  echo "Creating database user..."
259
- run_as_spindb spindb run "$CONTAINER_NAME" --database postgres <<EOF
290
+ cat > /tmp/create-user.sql <<EOSQL
260
291
  DO \\$\\$
261
292
  BEGIN
262
293
  IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '$SPINDB_USER') THEN
@@ -267,7 +298,9 @@ BEGIN
267
298
  END
268
299
  \\$\\$;
269
300
  GRANT ALL PRIVILEGES ON DATABASE "$DATABASE" TO "$SPINDB_USER";
270
- EOF
301
+ EOSQL
302
+ run_as_spindb spindb run "$CONTAINER_NAME" /tmp/create-user.sql --database postgres
303
+ rm -f /tmp/create-user.sql
271
304
  `
272
305
  break
273
306
 
@@ -276,11 +309,13 @@ EOF
276
309
  userCreationCommands = `
277
310
  # Create user with password
278
311
  echo "Creating database user..."
279
- run_as_spindb spindb run "$CONTAINER_NAME" --database mysql <<EOF
312
+ cat > /tmp/create-user.sql <<EOSQL
280
313
  CREATE USER IF NOT EXISTS '$SPINDB_USER'@'%' IDENTIFIED BY '$SPINDB_PASSWORD';
281
314
  GRANT ALL PRIVILEGES ON \\\`$DATABASE\\\`.* TO '$SPINDB_USER'@'%';
282
315
  FLUSH PRIVILEGES;
283
- EOF
316
+ EOSQL
317
+ run_as_spindb spindb run "$CONTAINER_NAME" /tmp/create-user.sql --database mysql
318
+ rm -f /tmp/create-user.sql
284
319
  `
285
320
  break
286
321
 
@@ -289,13 +324,15 @@ EOF
289
324
  userCreationCommands = `
290
325
  # Create user with password
291
326
  echo "Creating database user..."
292
- run_as_spindb spindb run "$CONTAINER_NAME" --database admin <<EOF
327
+ cat > /tmp/create-user.js <<EOJS
293
328
  db.createUser({
294
329
  user: "$SPINDB_USER",
295
330
  pwd: "$SPINDB_PASSWORD",
296
331
  roles: [{ role: "readWrite", db: "$DATABASE" }]
297
332
  });
298
- EOF
333
+ EOJS
334
+ run_as_spindb spindb run "$CONTAINER_NAME" /tmp/create-user.js --database admin
335
+ rm -f /tmp/create-user.js
299
336
  `
300
337
  break
301
338
 
@@ -312,10 +349,12 @@ echo "Authentication configured via server settings"
312
349
  userCreationCommands = `
313
350
  # Create user with password
314
351
  echo "Creating database user..."
315
- run_as_spindb spindb run "$CONTAINER_NAME" <<EOF
352
+ cat > /tmp/create-user.sql <<EOSQL
316
353
  CREATE USER IF NOT EXISTS $SPINDB_USER IDENTIFIED BY '$SPINDB_PASSWORD';
317
354
  GRANT ALL ON $DATABASE.* TO $SPINDB_USER;
318
- EOF
355
+ EOSQL
356
+ run_as_spindb spindb run "$CONTAINER_NAME" /tmp/create-user.sql
357
+ rm -f /tmp/create-user.sql
319
358
  `
320
359
  break
321
360
 
@@ -330,10 +369,12 @@ echo "Admin credentials configured via server settings"
330
369
  userCreationCommands = `
331
370
  # Create user with password
332
371
  echo "Creating database user..."
333
- run_as_spindb spindb run "$CONTAINER_NAME" --database defaultdb <<EOF
372
+ cat > /tmp/create-user.sql <<EOSQL
334
373
  CREATE USER IF NOT EXISTS $SPINDB_USER WITH PASSWORD '$SPINDB_PASSWORD';
335
374
  GRANT ALL ON DATABASE $DATABASE TO $SPINDB_USER;
336
- EOF
375
+ EOSQL
376
+ run_as_spindb spindb run "$CONTAINER_NAME" /tmp/create-user.sql --database defaultdb
377
+ rm -f /tmp/create-user.sql
337
378
  `
338
379
  break
339
380
 
@@ -411,6 +452,40 @@ if ls ${initDir}/* 1> /dev/null 2>&1; then
411
452
  fi
412
453
  `
413
454
 
455
+ // Post-restore commands - grant table/sequence permissions to the spindb user
456
+ // Tables created during restore are owned by postgres, so spindb user needs grants
457
+ let postRestoreCommands = ''
458
+
459
+ switch (engine) {
460
+ case Engine.PostgreSQL:
461
+ case Engine.CockroachDB:
462
+ postRestoreCommands = `
463
+ # Grant table and sequence permissions to spindb user
464
+ # (Tables from restore are owned by postgres, spindb user needs access)
465
+ echo "Granting table permissions..."
466
+ cat > /tmp/grant-permissions.sql <<EOSQL
467
+ GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "$SPINDB_USER";
468
+ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO "$SPINDB_USER";
469
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "$SPINDB_USER";
470
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "$SPINDB_USER";
471
+ EOSQL
472
+ run_as_spindb spindb run "$CONTAINER_NAME" /tmp/grant-permissions.sql --database "$DATABASE" || echo "Permission grants completed with warnings"
473
+ rm -f /tmp/grant-permissions.sql
474
+ `
475
+ break
476
+
477
+ case Engine.MySQL:
478
+ case Engine.MariaDB:
479
+ // MySQL grants are already handled by GRANT ALL ON database.* in user creation
480
+ postRestoreCommands = ''
481
+ break
482
+
483
+ default:
484
+ // Other engines don't need post-restore permission grants
485
+ postRestoreCommands = ''
486
+ break
487
+ }
488
+
414
489
  return `#!/bin/bash
415
490
  set -e
416
491
 
@@ -442,6 +517,9 @@ FILE_DB_PATH="/home/spindb/.spindb/containers/${engine}/\${CONTAINER_NAME}/\${CO
442
517
  # Export environment variables for the spindb user
443
518
  export SPINDB_CONTAINER SPINDB_DATABASE SPINDB_ENGINE SPINDB_VERSION SPINDB_PORT SPINDB_USER SPINDB_PASSWORD
444
519
 
520
+ # Add ~/.local/bin to PATH for symlinked database binaries
521
+ export PATH="/home/spindb/.local/bin:$PATH"
522
+
445
523
  # Fix permissions on mounted volume (may have been created with root ownership)
446
524
  echo "Setting up directories..."
447
525
  chown -R spindb:spindb /home/spindb/.spindb 2>/dev/null || true
@@ -472,12 +550,27 @@ else
472
550
  : `run_as_spindb spindb create "$CONTAINER_NAME" --engine "$ENGINE" --db-version "$VERSION" --port "$PORT" --database "$DATABASE" --force`
473
551
  }
474
552
  fi
475
- ${
476
- isFileBased
477
- ? `
553
+
554
+ # Create symlinks for database binaries in ~/.local/bin
555
+ # This allows users to run psql, mysql, etc. directly in the container
556
+ echo "Creating binary symlinks..."
557
+ mkdir -p /home/spindb/.local/bin
558
+ BIN_DIR=$(ls -d /home/spindb/.spindb/bin/\${ENGINE}-*/bin 2>/dev/null | head -1)
559
+ if [ -d "$BIN_DIR" ]; then
560
+ for binary in "$BIN_DIR"/*; do
561
+ if [ -x "$binary" ] && [ -f "$binary" ]; then
562
+ name=$(basename "$binary")
563
+ ln -sf "$binary" "/home/spindb/.local/bin/$name"
564
+ fi
565
+ done
566
+ echo "Binaries available: $(ls /home/spindb/.local/bin | tr '\\n' ' ')"
567
+ fi
568
+ ${networkConfig}${
569
+ isFileBased
570
+ ? `
478
571
  # File-based database: no server to start, just verify file exists after restore
479
572
  `
480
- : `
573
+ : `
481
574
  # Start the database
482
575
  echo "Starting database..."
483
576
  run_as_spindb spindb start "$CONTAINER_NAME"
@@ -485,7 +578,7 @@ run_as_spindb spindb start "$CONTAINER_NAME"
485
578
  # Wait for database to be ready
486
579
  echo "Waiting for database to be ready..."
487
580
  RETRIES=30
488
- until run_as_spindb spindb list --json 2>/dev/null | grep -q '"status":"running"' || [ $RETRIES -eq 0 ]; do
581
+ until run_as_spindb spindb list --json 2>/dev/null | grep -q '"status":.*"running"' || [ $RETRIES -eq 0 ]; do
489
582
  echo "Waiting for database... ($RETRIES attempts remaining)"
490
583
  sleep 2
491
584
  RETRIES=$((RETRIES-1))
@@ -495,11 +588,12 @@ if [ $RETRIES -eq 0 ]; then
495
588
  echo "Error: Database failed to start"
496
589
  exit 1
497
590
  fi`
498
- }
591
+ }
499
592
 
500
593
  echo "Database is running!"
501
594
  ${userCreationCommands}
502
595
  ${restoreSection}
596
+ ${postRestoreCommands}
503
597
  echo "========================================"
504
598
  echo "SpinDB container ready!"
505
599
  echo ""
@@ -520,7 +614,7 @@ exec gosu spindb tail -f /dev/null &
520
614
  while true; do
521
615
  sleep 60
522
616
  # Check if database is still running
523
- if ! run_as_spindb spindb list --json 2>/dev/null | grep -q '"status":"running"'; then
617
+ if ! run_as_spindb spindb list --json 2>/dev/null | grep -q '"status":.*"running"'; then
524
618
  echo "Database stopped unexpectedly, restarting..."
525
619
  run_as_spindb spindb start "$CONTAINER_NAME" || true
526
620
  fi
@@ -543,9 +637,10 @@ function generateDockerCompose(
543
637
  // Engine-aware healthcheck matching Dockerfile behavior
544
638
  // Server-based: check for running status
545
639
  // File-based: check that container exists (no server process to check)
640
+ // Note: Double quotes must be escaped for YAML string context
546
641
  const healthcheckCommand = isFileBased
547
- ? `gosu spindb spindb list --json | grep -q '"engine":"${engine}"'`
548
- : `gosu spindb spindb list --json | grep -q '"status":"running"'`
642
+ ? `gosu spindb spindb list --json | grep -q '\\"engine\\":.*\\"${engine}\\"'`
643
+ : `gosu spindb spindb list --json | grep -q '\\"status\\":.*\\"running\\"'`
549
644
 
550
645
  const startPeriod = isFileBased ? '30s' : '60s'
551
646
 
@@ -626,6 +721,29 @@ function generateReadme(
626
721
  useTLS,
627
722
  )
628
723
 
724
+ // TLS-conditional content
725
+ const tlsSecurityNote = useTLS
726
+ ? '- TLS certificates in `certs/` are self-signed. For production, replace with valid certificates.'
727
+ : '- TLS is disabled for this export. Consider enabling TLS for production use.'
728
+ const certsFileEntry = useTLS ? '| `certs/` | TLS certificates |\n' : ''
729
+ const tlsCustomization = useTLS
730
+ ? `
731
+ ### Use Custom Certificates
732
+
733
+ Replace the files in \`certs/\`:
734
+ - \`server.crt\` - TLS certificate
735
+ - \`server.key\` - TLS private key
736
+
737
+ ### Disable TLS
738
+
739
+ Edit \`entrypoint.sh\` and remove TLS-related flags (not recommended for production).
740
+ `
741
+ : `
742
+ ### Enable TLS
743
+
744
+ To enable TLS, re-export the container without the \`--skip-tls\` flag (requires OpenSSL).
745
+ `
746
+
629
747
  return `# ${containerName} - SpinDB Docker Export
630
748
 
631
749
  This directory contains a Docker-ready package for running your SpinDB ${displayName} container.
@@ -664,7 +782,7 @@ Replace \`\${SPINDB_USER}\` and \`\${SPINDB_PASSWORD}\` with the values from \`.
664
782
  ## Security Notes
665
783
 
666
784
  - The \`.env\` file contains auto-generated credentials. **Change these in production.**
667
- - TLS certificates in \`certs/\` are self-signed. For production, replace with valid certificates.
785
+ ${tlsSecurityNote}
668
786
  - The default \`spindb\` user has full access to the database. Create restricted users for applications.
669
787
 
670
788
  ## Files
@@ -675,8 +793,7 @@ Replace \`\${SPINDB_USER}\` and \`\${SPINDB_PASSWORD}\` with the values from \`.
675
793
  | \`docker-compose.yml\` | Container orchestration |
676
794
  | \`.env\` | Environment variables and credentials |
677
795
  | \`entrypoint.sh\` | Container startup script |
678
- | \`certs/\` | TLS certificates |
679
- | \`data/\` | Database backup for initialization |
796
+ ${certsFileEntry}| \`data/\` | Database backup for initialization |
680
797
 
681
798
  ## Customization
682
799
 
@@ -686,17 +803,7 @@ Edit \`.env\`:
686
803
  \`\`\`
687
804
  PORT=5433
688
805
  \`\`\`
689
-
690
- ### Use Custom Certificates
691
-
692
- Replace the files in \`certs/\`:
693
- - \`server.crt\` - TLS certificate
694
- - \`server.key\` - TLS private key
695
-
696
- ### Disable TLS
697
-
698
- Edit \`entrypoint.sh\` and remove TLS-related flags (not recommended for production).
699
-
806
+ ${tlsCustomization}
700
807
  ---
701
808
 
702
809
  Generated by [SpinDB](https://github.com/robertjbass/spindb)
@@ -731,15 +838,16 @@ export async function exportToDocker(
731
838
  const outputDirExisted = existsSync(outputDir)
732
839
 
733
840
  if (outputDirExisted) {
734
- // If it exists, check if it's empty
841
+ // If it exists, check if it's empty or only contains 'data' (from backup step)
735
842
  const existingFiles = await readdir(outputDir)
736
- if (existingFiles.length > 0) {
843
+ const nonDataFiles = existingFiles.filter((f) => f !== 'data')
844
+ if (nonDataFiles.length > 0) {
737
845
  throw new Error(
738
846
  `Output directory "${outputDir}" already exists and is not empty. ` +
739
847
  `Please use an empty directory or remove the existing files.`,
740
848
  )
741
849
  }
742
- // Directory exists but is empty - don't register rollback for it
850
+ // Directory exists but is empty or only has data/ - don't register rollback for it
743
851
  } else {
744
852
  // Directory doesn't exist - create it and register rollback
745
853
  await mkdir(outputDir, { recursive: true })
@@ -80,6 +80,7 @@ export class UpdateManager {
80
80
  try {
81
81
  const { stdout } = await execAsync('pnpm list -g spindb --json', {
82
82
  timeout: 5000,
83
+ cwd: '/',
83
84
  })
84
85
  const data = JSON.parse(stdout) as Array<{
85
86
  dependencies?: { spindb?: unknown }
@@ -94,6 +95,7 @@ export class UpdateManager {
94
95
  try {
95
96
  const { stdout } = await execAsync('yarn global list --json', {
96
97
  timeout: 5000,
98
+ cwd: '/',
97
99
  })
98
100
  // yarn outputs newline-delimited JSON, look for spindb in any line
99
101
  if (stdout.includes('"spindb@')) {
@@ -106,6 +108,7 @@ export class UpdateManager {
106
108
  try {
107
109
  const { stdout } = await execAsync('bun pm ls -g', {
108
110
  timeout: 5000,
111
+ cwd: '/',
109
112
  })
110
113
  if (stdout.includes('spindb@')) {
111
114
  return 'bun'
@@ -181,7 +184,7 @@ export class UpdateManager {
181
184
 
182
185
  // Run install command
183
186
  try {
184
- await execAsync(installCmd, { timeout: 60000 })
187
+ await execAsync(installCmd, { timeout: 60000, cwd: '/' })
185
188
  } catch (error) {
186
189
  const message = error instanceof Error ? error.message : String(error)
187
190
 
@@ -710,9 +710,10 @@ export class QdrantEngine extends BaseEngine {
710
710
  }
711
711
 
712
712
  // Wait for process to fully terminate
713
- if (isWindows()) {
714
- await new Promise((resolve) => setTimeout(resolve, 3000))
715
- }
713
+ // Windows needs longer due to file handle release
714
+ // Linux/macOS need a brief wait after SIGKILL before checking ports
715
+ const terminationWait = isWindows() ? 3000 : 1000
716
+ await new Promise((resolve) => setTimeout(resolve, terminationWait))
716
717
 
717
718
  // Kill any processes still listening on the ports
718
719
  // This handles cases where the PID file is stale, child processes exist,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.30.1",
3
+ "version": "0.30.7",
4
4
  "author": "Bob Bass <bob@bbass.co>",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "description": "Zero-config Docker-free local database containers. Create, backup, and clone a variety of popular databases.",