spindb 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,9 +5,11 @@ import { processManager } from '../../core/process-manager'
5
5
  import { getEngine } from '../../engines'
6
6
  import {
7
7
  promptContainerSelect,
8
+ promptContainerName,
8
9
  promptDatabaseName,
9
10
  promptCreateOptions,
10
11
  promptConfirm,
12
+ promptInstallDependencies,
11
13
  } from '../ui/prompts'
12
14
  import { createSpinner } from '../ui/spinner'
13
15
  import {
@@ -21,12 +23,13 @@ import {
21
23
  import { existsSync } from 'fs'
22
24
  import { readdir, rm, lstat } from 'fs/promises'
23
25
  import { spawn } from 'child_process'
24
- import { platform } from 'os'
26
+ import { platform, tmpdir } from 'os'
25
27
  import { join } from 'path'
26
28
  import { paths } from '../../config/paths'
27
29
  import { portManager } from '../../core/port-manager'
28
30
  import { defaults } from '../../config/defaults'
29
31
  import inquirer from 'inquirer'
32
+ import { getMissingDependencies } from '../../core/dependency-manager'
30
33
 
31
34
  type MenuChoice =
32
35
  | {
@@ -168,7 +171,8 @@ async function showMainMenu(): Promise<void> {
168
171
  async function handleCreate(): Promise<void> {
169
172
  console.log()
170
173
  const answers = await promptCreateOptions()
171
- const { name: containerName, engine, version, port, database } = answers
174
+ let { name: containerName } = answers
175
+ const { engine, version, port, database } = answers
172
176
 
173
177
  console.log()
174
178
  console.log(header('Creating Database Container'))
@@ -176,6 +180,41 @@ async function handleCreate(): Promise<void> {
176
180
 
177
181
  const dbEngine = getEngine(engine)
178
182
 
183
+ // Check for required client tools BEFORE creating anything
184
+ const depsSpinner = createSpinner('Checking required tools...')
185
+ depsSpinner.start()
186
+
187
+ let missingDeps = await getMissingDependencies(engine)
188
+ if (missingDeps.length > 0) {
189
+ depsSpinner.warn(
190
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
191
+ )
192
+
193
+ // Offer to install
194
+ const installed = await promptInstallDependencies(
195
+ missingDeps[0].binary,
196
+ engine,
197
+ )
198
+
199
+ if (!installed) {
200
+ return
201
+ }
202
+
203
+ // Verify installation worked
204
+ missingDeps = await getMissingDependencies(engine)
205
+ if (missingDeps.length > 0) {
206
+ console.log(
207
+ error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
208
+ )
209
+ return
210
+ }
211
+
212
+ console.log(chalk.green(' ✓ All required tools are now available'))
213
+ console.log()
214
+ } else {
215
+ depsSpinner.succeed('Required tools available')
216
+ }
217
+
179
218
  // Check if port is currently in use
180
219
  const portAvailable = await portManager.isPortAvailable(port)
181
220
 
@@ -196,6 +235,12 @@ async function handleCreate(): Promise<void> {
196
235
  binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
197
236
  }
198
237
 
238
+ // Check if container name already exists and prompt for new name if needed
239
+ while (await containerManager.exists(containerName)) {
240
+ console.log(chalk.yellow(` Container "${containerName}" already exists.`))
241
+ containerName = await promptContainerName()
242
+ }
243
+
199
244
  // Create container
200
245
  const createSpinnerInstance = createSpinner('Creating container...')
201
246
  createSpinnerInstance.start()
@@ -678,47 +723,330 @@ async function handleConnect(): Promise<void> {
678
723
  })
679
724
  }
680
725
 
681
- async function handleRestore(): Promise<void> {
682
- const containers = await containerManager.list()
683
- const running = containers.filter((c) => c.status === 'running')
726
+ /**
727
+ * Create a new container for the restore flow
728
+ * Returns the container name and config if successful, null if cancelled/error
729
+ */
730
+ async function handleCreateForRestore(): Promise<{
731
+ name: string
732
+ config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>
733
+ } | null> {
734
+ console.log()
735
+ const answers = await promptCreateOptions()
736
+ let { name: containerName } = answers
737
+ const { engine, version, port, database } = answers
684
738
 
685
- if (running.length === 0) {
686
- console.log(warning('No running containers. Start one first.'))
687
- return
739
+ console.log()
740
+ console.log(header('Creating Database Container'))
741
+ console.log()
742
+
743
+ const dbEngine = getEngine(engine)
744
+
745
+ // Check if port is currently in use
746
+ const portAvailable = await portManager.isPortAvailable(port)
747
+ if (!portAvailable) {
748
+ console.log(
749
+ error(`Port ${port} is in use. Please choose a different port.`),
750
+ )
751
+ return null
688
752
  }
689
753
 
690
- const containerName = await promptContainerSelect(
691
- running,
692
- 'Select container to restore to:',
754
+ // Ensure binaries
755
+ const binarySpinner = createSpinner(
756
+ `Checking PostgreSQL ${version} binaries...`,
693
757
  )
694
- if (!containerName) return
758
+ binarySpinner.start()
759
+
760
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
761
+ if (isInstalled) {
762
+ binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
763
+ } else {
764
+ binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
765
+ await dbEngine.ensureBinaries(version, ({ message }) => {
766
+ binarySpinner.text = message
767
+ })
768
+ binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
769
+ }
770
+
771
+ // Check if container name already exists and prompt for new name if needed
772
+ while (await containerManager.exists(containerName)) {
773
+ console.log(chalk.yellow(` Container "${containerName}" already exists.`))
774
+ containerName = await promptContainerName()
775
+ }
776
+
777
+ // Create container
778
+ const createSpinnerInstance = createSpinner('Creating container...')
779
+ createSpinnerInstance.start()
780
+
781
+ await containerManager.create(containerName, {
782
+ engine: dbEngine.name,
783
+ version,
784
+ port,
785
+ database,
786
+ })
787
+
788
+ createSpinnerInstance.succeed('Container created')
789
+
790
+ // Initialize database cluster
791
+ const initSpinner = createSpinner('Initializing database cluster...')
792
+ initSpinner.start()
793
+
794
+ await dbEngine.initDataDir(containerName, version, {
795
+ superuser: defaults.superuser,
796
+ })
797
+
798
+ initSpinner.succeed('Database cluster initialized')
799
+
800
+ // Start container
801
+ const startSpinner = createSpinner('Starting PostgreSQL...')
802
+ startSpinner.start()
695
803
 
696
804
  const config = await containerManager.getConfig(containerName)
697
805
  if (!config) {
698
- console.error(error(`Container "${containerName}" not found`))
699
- return
806
+ startSpinner.fail('Failed to get container config')
807
+ return null
808
+ }
809
+
810
+ await dbEngine.start(config)
811
+ await containerManager.updateConfig(containerName, { status: 'running' })
812
+
813
+ startSpinner.succeed('PostgreSQL started')
814
+
815
+ // Create the user's database (if different from 'postgres')
816
+ if (database !== 'postgres') {
817
+ const dbSpinner = createSpinner(`Creating database "${database}"...`)
818
+ dbSpinner.start()
819
+
820
+ await dbEngine.createDatabase(config, database)
821
+
822
+ dbSpinner.succeed(`Database "${database}" created`)
823
+ }
824
+
825
+ console.log()
826
+ console.log(success('Container ready for restore'))
827
+ console.log()
828
+
829
+ return { name: containerName, config }
830
+ }
831
+
832
+ async function handleRestore(): Promise<void> {
833
+ const containers = await containerManager.list()
834
+ const running = containers.filter((c) => c.status === 'running')
835
+
836
+ // Build choices: running containers + create new option
837
+ const choices = [
838
+ ...running.map((c) => ({
839
+ name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
840
+ value: c.name,
841
+ short: c.name,
842
+ })),
843
+ new inquirer.Separator(),
844
+ {
845
+ name: `${chalk.green('➕')} Create new container`,
846
+ value: '__create_new__',
847
+ short: 'Create new',
848
+ },
849
+ ]
850
+
851
+ const { selectedContainer } = await inquirer.prompt<{
852
+ selectedContainer: string
853
+ }>([
854
+ {
855
+ type: 'list',
856
+ name: 'selectedContainer',
857
+ message: 'Select container to restore to:',
858
+ choices,
859
+ },
860
+ ])
861
+
862
+ let containerName: string
863
+ let config: Awaited<ReturnType<typeof containerManager.getConfig>>
864
+
865
+ if (selectedContainer === '__create_new__') {
866
+ // Run the create flow first
867
+ const createResult = await handleCreateForRestore()
868
+ if (!createResult) return // User cancelled or error
869
+ containerName = createResult.name
870
+ config = createResult.config
871
+ } else {
872
+ containerName = selectedContainer
873
+ config = await containerManager.getConfig(containerName)
874
+ if (!config) {
875
+ console.error(error(`Container "${containerName}" not found`))
876
+ return
877
+ }
700
878
  }
701
879
 
702
- // Get backup file path
703
- // Strip quotes that terminals add when drag-and-dropping files
704
- const stripQuotes = (path: string) => path.replace(/^['"]|['"]$/g, '').trim()
880
+ // Check for required client tools BEFORE doing anything
881
+ const depsSpinner = createSpinner('Checking required tools...')
882
+ depsSpinner.start()
705
883
 
706
- const { backupPath: rawBackupPath } = await inquirer.prompt<{
707
- backupPath: string
884
+ let missingDeps = await getMissingDependencies(config.engine)
885
+ if (missingDeps.length > 0) {
886
+ depsSpinner.warn(
887
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
888
+ )
889
+
890
+ // Offer to install
891
+ const installed = await promptInstallDependencies(
892
+ missingDeps[0].binary,
893
+ config.engine,
894
+ )
895
+
896
+ if (!installed) {
897
+ return
898
+ }
899
+
900
+ // Verify installation worked
901
+ missingDeps = await getMissingDependencies(config.engine)
902
+ if (missingDeps.length > 0) {
903
+ console.log(
904
+ error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
905
+ )
906
+ return
907
+ }
908
+
909
+ console.log(chalk.green(' ✓ All required tools are now available'))
910
+ console.log()
911
+ } else {
912
+ depsSpinner.succeed('Required tools available')
913
+ }
914
+
915
+ // Ask for restore source
916
+ const { restoreSource } = await inquirer.prompt<{
917
+ restoreSource: 'file' | 'connection'
708
918
  }>([
709
919
  {
710
- type: 'input',
711
- name: 'backupPath',
712
- message: 'Path to backup file (drag and drop or enter path):',
713
- validate: (input: string) => {
714
- if (!input) return 'Backup path is required'
715
- const cleanPath = stripQuotes(input)
716
- if (!existsSync(cleanPath)) return 'File not found'
717
- return true
718
- },
920
+ type: 'list',
921
+ name: 'restoreSource',
922
+ message: 'Restore from:',
923
+ choices: [
924
+ {
925
+ name: `${chalk.magenta('📁')} Dump file (drag and drop or enter path)`,
926
+ value: 'file',
927
+ },
928
+ {
929
+ name: `${chalk.cyan('🔗')} Connection string (pull from remote database)`,
930
+ value: 'connection',
931
+ },
932
+ ],
719
933
  },
720
934
  ])
721
- const backupPath = stripQuotes(rawBackupPath)
935
+
936
+ let backupPath = ''
937
+ let isTempFile = false
938
+
939
+ if (restoreSource === 'connection') {
940
+ // Get connection string and create dump
941
+ const { connectionString } = await inquirer.prompt<{
942
+ connectionString: string
943
+ }>([
944
+ {
945
+ type: 'input',
946
+ name: 'connectionString',
947
+ message: 'Connection string (postgresql://user:pass@host:port/dbname):',
948
+ validate: (input: string) => {
949
+ if (!input) return 'Connection string is required'
950
+ if (
951
+ !input.startsWith('postgresql://') &&
952
+ !input.startsWith('postgres://')
953
+ ) {
954
+ return 'Connection string must start with postgresql:// or postgres://'
955
+ }
956
+ return true
957
+ },
958
+ },
959
+ ])
960
+
961
+ const engine = getEngine(config.engine)
962
+
963
+ // Create temp file for the dump
964
+ const timestamp = Date.now()
965
+ const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
966
+
967
+ let dumpSuccess = false
968
+ let attempts = 0
969
+ const maxAttempts = 2 // Allow one retry after installing deps
970
+
971
+ while (!dumpSuccess && attempts < maxAttempts) {
972
+ attempts++
973
+ const dumpSpinner = createSpinner('Creating dump from remote database...')
974
+ dumpSpinner.start()
975
+
976
+ try {
977
+ await engine.dumpFromConnectionString(connectionString, tempDumpPath)
978
+ dumpSpinner.succeed('Dump created from remote database')
979
+ backupPath = tempDumpPath
980
+ isTempFile = true
981
+ dumpSuccess = true
982
+ } catch (err) {
983
+ const e = err as Error
984
+ dumpSpinner.fail('Failed to create dump')
985
+
986
+ // Check if this is a missing tool error
987
+ if (
988
+ e.message.includes('pg_dump not found') ||
989
+ e.message.includes('ENOENT')
990
+ ) {
991
+ const installed = await promptInstallDependencies('pg_dump')
992
+ if (installed) {
993
+ // Loop will retry
994
+ continue
995
+ }
996
+ } else {
997
+ console.log()
998
+ console.log(error('pg_dump error:'))
999
+ console.log(chalk.gray(` ${e.message}`))
1000
+ console.log()
1001
+ }
1002
+
1003
+ // Clean up temp file if it was created
1004
+ try {
1005
+ await rm(tempDumpPath, { force: true })
1006
+ } catch {
1007
+ // Ignore cleanup errors
1008
+ }
1009
+
1010
+ // Wait for user to see the error
1011
+ await inquirer.prompt([
1012
+ {
1013
+ type: 'input',
1014
+ name: 'continue',
1015
+ message: chalk.gray('Press Enter to continue...'),
1016
+ },
1017
+ ])
1018
+ return
1019
+ }
1020
+ }
1021
+
1022
+ // Safety check - should never reach here without backupPath set
1023
+ if (!dumpSuccess) {
1024
+ console.log(error('Failed to create dump after retries'))
1025
+ return
1026
+ }
1027
+ } else {
1028
+ // Get backup file path
1029
+ // Strip quotes that terminals add when drag-and-dropping files
1030
+ const stripQuotes = (path: string) =>
1031
+ path.replace(/^['"]|['"]$/g, '').trim()
1032
+
1033
+ const { backupPath: rawBackupPath } = await inquirer.prompt<{
1034
+ backupPath: string
1035
+ }>([
1036
+ {
1037
+ type: 'input',
1038
+ name: 'backupPath',
1039
+ message: 'Path to backup file (drag and drop or enter path):',
1040
+ validate: (input: string) => {
1041
+ if (!input) return 'Backup path is required'
1042
+ const cleanPath = stripQuotes(input)
1043
+ if (!existsSync(cleanPath)) return 'File not found'
1044
+ return true
1045
+ },
1046
+ },
1047
+ ])
1048
+ backupPath = stripQuotes(rawBackupPath)
1049
+ }
722
1050
 
723
1051
  const databaseName = await promptDatabaseName(containerName)
724
1052
 
@@ -936,6 +1264,15 @@ async function handleRestore(): Promise<void> {
936
1264
  console.log()
937
1265
  }
938
1266
 
1267
+ // Clean up temp file if we created one
1268
+ if (isTempFile) {
1269
+ try {
1270
+ await rm(backupPath, { force: true })
1271
+ } catch {
1272
+ // Ignore cleanup errors
1273
+ }
1274
+ }
1275
+
939
1276
  // Wait for user to see the result before returning to menu
940
1277
  await inquirer.prompt([
941
1278
  {
@@ -1482,6 +1819,27 @@ export const menuCommand = new Command('menu')
1482
1819
  await showMainMenu()
1483
1820
  } catch (err) {
1484
1821
  const e = err as Error
1822
+
1823
+ // Check if this is a missing tool error
1824
+ if (
1825
+ e.message.includes('pg_restore not found') ||
1826
+ e.message.includes('psql not found') ||
1827
+ e.message.includes('pg_dump not found')
1828
+ ) {
1829
+ const missingTool = e.message.includes('pg_restore')
1830
+ ? 'pg_restore'
1831
+ : e.message.includes('pg_dump')
1832
+ ? 'pg_dump'
1833
+ : 'psql'
1834
+ const installed = await promptInstallDependencies(missingTool)
1835
+ if (installed) {
1836
+ console.log(
1837
+ chalk.yellow(' Please re-run spindb to continue.'),
1838
+ )
1839
+ }
1840
+ process.exit(1)
1841
+ }
1842
+
1485
1843
  console.error(error(e.message))
1486
1844
  process.exit(1)
1487
1845
  }