spindb 0.3.6 → 0.4.0

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.
@@ -8,6 +8,7 @@ import {
8
8
  promptDatabaseName,
9
9
  promptCreateOptions,
10
10
  promptConfirm,
11
+ promptInstallDependencies,
11
12
  } from '../ui/prompts'
12
13
  import { createSpinner } from '../ui/spinner'
13
14
  import {
@@ -21,7 +22,7 @@ import {
21
22
  import { existsSync } from 'fs'
22
23
  import { readdir, rm, lstat } from 'fs/promises'
23
24
  import { spawn } from 'child_process'
24
- import { platform } from 'os'
25
+ import { platform, tmpdir } from 'os'
25
26
  import { join } from 'path'
26
27
  import { paths } from '../../config/paths'
27
28
  import { portManager } from '../../core/port-manager'
@@ -678,47 +679,270 @@ async function handleConnect(): Promise<void> {
678
679
  })
679
680
  }
680
681
 
681
- async function handleRestore(): Promise<void> {
682
- const containers = await containerManager.list()
683
- const running = containers.filter((c) => c.status === 'running')
682
+ /**
683
+ * Create a new container for the restore flow
684
+ * Returns the container name and config if successful, null if cancelled/error
685
+ */
686
+ async function handleCreateForRestore(): Promise<{
687
+ name: string
688
+ config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>
689
+ } | null> {
690
+ console.log()
691
+ const answers = await promptCreateOptions()
692
+ const { name: containerName, engine, version, port, database } = answers
684
693
 
685
- if (running.length === 0) {
686
- console.log(warning('No running containers. Start one first.'))
687
- return
694
+ console.log()
695
+ console.log(header('Creating Database Container'))
696
+ console.log()
697
+
698
+ const dbEngine = getEngine(engine)
699
+
700
+ // Check if port is currently in use
701
+ const portAvailable = await portManager.isPortAvailable(port)
702
+ if (!portAvailable) {
703
+ console.log(
704
+ error(`Port ${port} is in use. Please choose a different port.`),
705
+ )
706
+ return null
688
707
  }
689
708
 
690
- const containerName = await promptContainerSelect(
691
- running,
692
- 'Select container to restore to:',
709
+ // Ensure binaries
710
+ const binarySpinner = createSpinner(
711
+ `Checking PostgreSQL ${version} binaries...`,
693
712
  )
694
- if (!containerName) return
713
+ binarySpinner.start()
714
+
715
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
716
+ if (isInstalled) {
717
+ binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
718
+ } else {
719
+ binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
720
+ await dbEngine.ensureBinaries(version, ({ message }) => {
721
+ binarySpinner.text = message
722
+ })
723
+ binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
724
+ }
725
+
726
+ // Create container
727
+ const createSpinnerInstance = createSpinner('Creating container...')
728
+ createSpinnerInstance.start()
729
+
730
+ await containerManager.create(containerName, {
731
+ engine: dbEngine.name,
732
+ version,
733
+ port,
734
+ database,
735
+ })
736
+
737
+ createSpinnerInstance.succeed('Container created')
738
+
739
+ // Initialize database cluster
740
+ const initSpinner = createSpinner('Initializing database cluster...')
741
+ initSpinner.start()
742
+
743
+ await dbEngine.initDataDir(containerName, version, {
744
+ superuser: defaults.superuser,
745
+ })
746
+
747
+ initSpinner.succeed('Database cluster initialized')
748
+
749
+ // Start container
750
+ const startSpinner = createSpinner('Starting PostgreSQL...')
751
+ startSpinner.start()
695
752
 
696
753
  const config = await containerManager.getConfig(containerName)
697
754
  if (!config) {
698
- console.error(error(`Container "${containerName}" not found`))
699
- return
755
+ startSpinner.fail('Failed to get container config')
756
+ return null
757
+ }
758
+
759
+ await dbEngine.start(config)
760
+ await containerManager.updateConfig(containerName, { status: 'running' })
761
+
762
+ startSpinner.succeed('PostgreSQL started')
763
+
764
+ // Create the user's database (if different from 'postgres')
765
+ if (database !== 'postgres') {
766
+ const dbSpinner = createSpinner(`Creating database "${database}"...`)
767
+ dbSpinner.start()
768
+
769
+ await dbEngine.createDatabase(config, database)
770
+
771
+ dbSpinner.succeed(`Database "${database}" created`)
700
772
  }
701
773
 
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()
774
+ console.log()
775
+ console.log(success('Container ready for restore'))
776
+ console.log()
777
+
778
+ return { name: containerName, config }
779
+ }
780
+
781
+ async function handleRestore(): Promise<void> {
782
+ const containers = await containerManager.list()
783
+ const running = containers.filter((c) => c.status === 'running')
784
+
785
+ // Build choices: running containers + create new option
786
+ const choices = [
787
+ ...running.map((c) => ({
788
+ name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
789
+ value: c.name,
790
+ short: c.name,
791
+ })),
792
+ new inquirer.Separator(),
793
+ {
794
+ name: `${chalk.green('➕')} Create new container`,
795
+ value: '__create_new__',
796
+ short: 'Create new',
797
+ },
798
+ ]
799
+
800
+ const { selectedContainer } = await inquirer.prompt<{
801
+ selectedContainer: string
802
+ }>([
803
+ {
804
+ type: 'list',
805
+ name: 'selectedContainer',
806
+ message: 'Select container to restore to:',
807
+ choices,
808
+ },
809
+ ])
810
+
811
+ let containerName: string
812
+ let config: Awaited<ReturnType<typeof containerManager.getConfig>>
813
+
814
+ if (selectedContainer === '__create_new__') {
815
+ // Run the create flow first
816
+ const createResult = await handleCreateForRestore()
817
+ if (!createResult) return // User cancelled or error
818
+ containerName = createResult.name
819
+ config = createResult.config
820
+ } else {
821
+ containerName = selectedContainer
822
+ config = await containerManager.getConfig(containerName)
823
+ if (!config) {
824
+ console.error(error(`Container "${containerName}" not found`))
825
+ return
826
+ }
827
+ }
705
828
 
706
- const { backupPath: rawBackupPath } = await inquirer.prompt<{
707
- backupPath: string
829
+ // Ask for restore source
830
+ const { restoreSource } = await inquirer.prompt<{
831
+ restoreSource: 'file' | 'connection'
708
832
  }>([
709
833
  {
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
- },
834
+ type: 'list',
835
+ name: 'restoreSource',
836
+ message: 'Restore from:',
837
+ choices: [
838
+ {
839
+ name: `${chalk.magenta('📁')} Dump file (drag and drop or enter path)`,
840
+ value: 'file',
841
+ },
842
+ {
843
+ name: `${chalk.cyan('🔗')} Connection string (pull from remote database)`,
844
+ value: 'connection',
845
+ },
846
+ ],
719
847
  },
720
848
  ])
721
- const backupPath = stripQuotes(rawBackupPath)
849
+
850
+ let backupPath: string
851
+ let isTempFile = false
852
+
853
+ if (restoreSource === 'connection') {
854
+ // Get connection string and create dump
855
+ const { connectionString } = await inquirer.prompt<{
856
+ connectionString: string
857
+ }>([
858
+ {
859
+ type: 'input',
860
+ name: 'connectionString',
861
+ message: 'Connection string (postgresql://user:pass@host:port/dbname):',
862
+ validate: (input: string) => {
863
+ if (!input) return 'Connection string is required'
864
+ if (
865
+ !input.startsWith('postgresql://') &&
866
+ !input.startsWith('postgres://')
867
+ ) {
868
+ return 'Connection string must start with postgresql:// or postgres://'
869
+ }
870
+ return true
871
+ },
872
+ },
873
+ ])
874
+
875
+ const engine = getEngine(config.engine)
876
+
877
+ // Create temp file for the dump
878
+ const timestamp = Date.now()
879
+ const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
880
+
881
+ const dumpSpinner = createSpinner('Creating dump from remote database...')
882
+ dumpSpinner.start()
883
+
884
+ try {
885
+ await engine.dumpFromConnectionString(connectionString, tempDumpPath)
886
+ dumpSpinner.succeed('Dump created from remote database')
887
+ backupPath = tempDumpPath
888
+ isTempFile = true
889
+ } catch (err) {
890
+ const e = err as Error
891
+ dumpSpinner.fail('Failed to create dump')
892
+
893
+ // Clean up temp file if it was created
894
+ try {
895
+ await rm(tempDumpPath, { force: true })
896
+ } catch {
897
+ // Ignore cleanup errors
898
+ }
899
+
900
+ // Check if this is a missing tool error
901
+ if (
902
+ e.message.includes('pg_dump not found') ||
903
+ e.message.includes('ENOENT')
904
+ ) {
905
+ await promptInstallDependencies('pg_dump')
906
+ } else {
907
+ console.log()
908
+ console.log(error('pg_dump error:'))
909
+ console.log(chalk.gray(` ${e.message}`))
910
+ console.log()
911
+ }
912
+
913
+ // Wait for user to see the error
914
+ await inquirer.prompt([
915
+ {
916
+ type: 'input',
917
+ name: 'continue',
918
+ message: chalk.gray('Press Enter to continue...'),
919
+ },
920
+ ])
921
+ return
922
+ }
923
+ } else {
924
+ // Get backup file path
925
+ // Strip quotes that terminals add when drag-and-dropping files
926
+ const stripQuotes = (path: string) =>
927
+ path.replace(/^['"]|['"]$/g, '').trim()
928
+
929
+ const { backupPath: rawBackupPath } = await inquirer.prompt<{
930
+ backupPath: string
931
+ }>([
932
+ {
933
+ type: 'input',
934
+ name: 'backupPath',
935
+ message: 'Path to backup file (drag and drop or enter path):',
936
+ validate: (input: string) => {
937
+ if (!input) return 'Backup path is required'
938
+ const cleanPath = stripQuotes(input)
939
+ if (!existsSync(cleanPath)) return 'File not found'
940
+ return true
941
+ },
942
+ },
943
+ ])
944
+ backupPath = stripQuotes(rawBackupPath)
945
+ }
722
946
 
723
947
  const databaseName = await promptDatabaseName(containerName)
724
948
 
@@ -936,6 +1160,15 @@ async function handleRestore(): Promise<void> {
936
1160
  console.log()
937
1161
  }
938
1162
 
1163
+ // Clean up temp file if we created one
1164
+ if (isTempFile) {
1165
+ try {
1166
+ await rm(backupPath, { force: true })
1167
+ } catch {
1168
+ // Ignore cleanup errors
1169
+ }
1170
+ }
1171
+
939
1172
  // Wait for user to see the result before returning to menu
940
1173
  await inquirer.prompt([
941
1174
  {
@@ -1482,6 +1715,22 @@ export const menuCommand = new Command('menu')
1482
1715
  await showMainMenu()
1483
1716
  } catch (err) {
1484
1717
  const e = err as Error
1718
+
1719
+ // Check if this is a missing tool error
1720
+ if (
1721
+ e.message.includes('pg_restore not found') ||
1722
+ e.message.includes('psql not found') ||
1723
+ e.message.includes('pg_dump not found')
1724
+ ) {
1725
+ const missingTool = e.message.includes('pg_restore')
1726
+ ? 'pg_restore'
1727
+ : e.message.includes('pg_dump')
1728
+ ? 'pg_dump'
1729
+ : 'psql'
1730
+ await promptInstallDependencies(missingTool)
1731
+ process.exit(1)
1732
+ }
1733
+
1485
1734
  console.error(error(e.message))
1486
1735
  process.exit(1)
1487
1736
  }
@@ -1,29 +1,44 @@
1
1
  import { Command } from 'commander'
2
2
  import { existsSync } from 'fs'
3
+ import { rm } from 'fs/promises'
3
4
  import chalk from 'chalk'
4
5
  import { containerManager } from '../../core/container-manager'
5
6
  import { processManager } from '../../core/process-manager'
6
7
  import { getEngine } from '../../engines'
7
- import { promptContainerSelect, promptDatabaseName } from '../ui/prompts'
8
+ import {
9
+ promptContainerSelect,
10
+ promptDatabaseName,
11
+ promptInstallDependencies,
12
+ } from '../ui/prompts'
8
13
  import { createSpinner } from '../ui/spinner'
9
14
  import { success, error, warning } from '../ui/theme'
10
- import { platform } from 'os'
15
+ import { platform, tmpdir } from 'os'
11
16
  import { spawn } from 'child_process'
17
+ import { join } from 'path'
12
18
 
13
19
  export const restoreCommand = new Command('restore')
14
20
  .description('Restore a backup to a container')
15
21
  .argument('[name]', 'Container name')
16
- .argument('[backup]', 'Path to backup file')
22
+ .argument(
23
+ '[backup]',
24
+ 'Path to backup file (not required if using --from-url)',
25
+ )
17
26
  .option('-d, --database <name>', 'Target database name')
27
+ .option(
28
+ '--from-url <url>',
29
+ 'Pull data from a remote database connection string',
30
+ )
18
31
  .action(
19
32
  async (
20
33
  name: string | undefined,
21
34
  backup: string | undefined,
22
- options: { database?: string },
35
+ options: { database?: string; fromUrl?: string },
23
36
  ) => {
37
+ let tempDumpPath: string | null = null
38
+
24
39
  try {
25
40
  let containerName = name
26
- const backupPath = backup
41
+ let backupPath = backup
27
42
 
28
43
  // Interactive selection if no name provided
29
44
  if (!containerName) {
@@ -71,18 +86,74 @@ export const restoreCommand = new Command('restore')
71
86
  process.exit(1)
72
87
  }
73
88
 
74
- // Check backup file
75
- if (!backupPath) {
76
- console.error(error('Backup file path is required'))
77
- console.log(
78
- chalk.gray(' Usage: spindb restore <container> <backup-file>'),
89
+ // Get engine
90
+ const engine = getEngine(config.engine)
91
+
92
+ // Handle --from-url option
93
+ if (options.fromUrl) {
94
+ // Validate connection string
95
+ if (
96
+ !options.fromUrl.startsWith('postgresql://') &&
97
+ !options.fromUrl.startsWith('postgres://')
98
+ ) {
99
+ console.error(
100
+ error(
101
+ 'Connection string must start with postgresql:// or postgres://',
102
+ ),
103
+ )
104
+ process.exit(1)
105
+ }
106
+
107
+ // Create temp file for the dump
108
+ const timestamp = Date.now()
109
+ tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
110
+
111
+ const dumpSpinner = createSpinner(
112
+ 'Creating dump from remote database...',
79
113
  )
80
- process.exit(1)
81
- }
114
+ dumpSpinner.start()
82
115
 
83
- if (!existsSync(backupPath)) {
84
- console.error(error(`Backup file not found: ${backupPath}`))
85
- process.exit(1)
116
+ try {
117
+ await engine.dumpFromConnectionString(options.fromUrl, tempDumpPath)
118
+ dumpSpinner.succeed('Dump created from remote database')
119
+ backupPath = tempDumpPath
120
+ } catch (err) {
121
+ const e = err as Error
122
+ dumpSpinner.fail('Failed to create dump')
123
+
124
+ // Check if this is a missing tool error
125
+ if (
126
+ e.message.includes('pg_dump not found') ||
127
+ e.message.includes('ENOENT')
128
+ ) {
129
+ await promptInstallDependencies('pg_dump')
130
+ process.exit(1)
131
+ }
132
+
133
+ console.log()
134
+ console.error(error('pg_dump error:'))
135
+ console.log(chalk.gray(` ${e.message}`))
136
+ process.exit(1)
137
+ }
138
+ } else {
139
+ // Check backup file
140
+ if (!backupPath) {
141
+ console.error(error('Backup file path is required'))
142
+ console.log(
143
+ chalk.gray(' Usage: spindb restore <container> <backup-file>'),
144
+ )
145
+ console.log(
146
+ chalk.gray(
147
+ ' or: spindb restore <container> --from-url <connection-string>',
148
+ ),
149
+ )
150
+ process.exit(1)
151
+ }
152
+
153
+ if (!existsSync(backupPath)) {
154
+ console.error(error(`Backup file not found: ${backupPath}`))
155
+ process.exit(1)
156
+ }
86
157
  }
87
158
 
88
159
  // Get database name
@@ -91,9 +162,6 @@ export const restoreCommand = new Command('restore')
91
162
  databaseName = await promptDatabaseName(containerName)
92
163
  }
93
164
 
94
- // Get engine
95
- const engine = getEngine(config.engine)
96
-
97
165
  // Detect backup format
98
166
  const detectSpinner = createSpinner('Detecting backup format...')
99
167
  detectSpinner.start()
@@ -182,8 +250,30 @@ export const restoreCommand = new Command('restore')
182
250
  console.log()
183
251
  } catch (err) {
184
252
  const e = err as Error
253
+
254
+ // Check if this is a missing tool error
255
+ if (
256
+ e.message.includes('pg_restore not found') ||
257
+ e.message.includes('psql not found')
258
+ ) {
259
+ const missingTool = e.message.includes('pg_restore')
260
+ ? 'pg_restore'
261
+ : 'psql'
262
+ await promptInstallDependencies(missingTool)
263
+ process.exit(1)
264
+ }
265
+
185
266
  console.error(error(e.message))
186
267
  process.exit(1)
268
+ } finally {
269
+ // Clean up temp file if we created one
270
+ if (tempDumpPath) {
271
+ try {
272
+ await rm(tempDumpPath, { force: true })
273
+ } catch {
274
+ // Ignore cleanup errors
275
+ }
276
+ }
187
277
  }
188
278
  },
189
279
  )
package/cli/index.ts CHANGED
@@ -10,6 +10,7 @@ import { cloneCommand } from './commands/clone'
10
10
  import { menuCommand } from './commands/menu'
11
11
  import { configCommand } from './commands/config'
12
12
  import { postgresToolsCommand } from './commands/postgres-tools'
13
+ import { depsCommand } from './commands/deps'
13
14
 
14
15
  export async function run(): Promise<void> {
15
16
  program
@@ -28,6 +29,7 @@ export async function run(): Promise<void> {
28
29
  program.addCommand(menuCommand)
29
30
  program.addCommand(configCommand)
30
31
  program.addCommand(postgresToolsCommand)
32
+ program.addCommand(depsCommand)
31
33
 
32
34
  // If no arguments provided, show interactive menu
33
35
  if (process.argv.length <= 2) {
package/cli/ui/prompts.ts CHANGED
@@ -3,6 +3,13 @@ import chalk from 'chalk'
3
3
  import ora from 'ora'
4
4
  import { listEngines, getEngine } from '../../engines'
5
5
  import { defaults } from '../../config/defaults'
6
+ import { installPostgresBinaries } from '../../core/postgres-binary-manager'
7
+ import {
8
+ detectPackageManager,
9
+ getManualInstallInstructions,
10
+ getCurrentPlatform,
11
+ } from '../../core/dependency-manager'
12
+ import { getEngineDependencies } from '../../config/os-dependencies'
6
13
  import type { ContainerConfig } from '../../types'
7
14
 
8
15
  /**
@@ -285,3 +292,129 @@ export async function promptCreateOptions(
285
292
 
286
293
  return { name, engine, version, port, database }
287
294
  }
295
+
296
+ /**
297
+ * Prompt user to install missing database client tools
298
+ * Returns true if installation was successful or user declined, false if installation failed
299
+ *
300
+ * @param missingTool - The name of the missing tool (e.g., 'psql', 'pg_dump', 'mysql')
301
+ * @param engine - The database engine (defaults to 'postgresql')
302
+ */
303
+ export async function promptInstallDependencies(
304
+ missingTool: string,
305
+ engine: string = 'postgresql',
306
+ ): Promise<boolean> {
307
+ const platform = getCurrentPlatform()
308
+
309
+ console.log()
310
+ console.log(
311
+ chalk.yellow(` Database client tool "${missingTool}" is not installed.`),
312
+ )
313
+ console.log()
314
+
315
+ // Check what package manager is available
316
+ const packageManager = await detectPackageManager()
317
+
318
+ if (!packageManager) {
319
+ console.log(chalk.red(' No supported package manager found.'))
320
+ console.log()
321
+
322
+ // Get instructions from the dependency registry
323
+ const engineDeps = getEngineDependencies(engine)
324
+ if (engineDeps) {
325
+ // Find the specific dependency or use the first one for general instructions
326
+ const dep =
327
+ engineDeps.dependencies.find((d) => d.binary === missingTool) ||
328
+ engineDeps.dependencies[0]
329
+
330
+ if (dep) {
331
+ const instructions = getManualInstallInstructions(dep, platform)
332
+ console.log(
333
+ chalk.gray(` Please install ${engineDeps.displayName} client tools:`),
334
+ )
335
+ console.log()
336
+ for (const instruction of instructions) {
337
+ console.log(chalk.gray(` ${instruction}`))
338
+ }
339
+ }
340
+ }
341
+ console.log()
342
+ return false
343
+ }
344
+
345
+ console.log(
346
+ chalk.gray(` Detected package manager: ${chalk.white(packageManager.name)}`),
347
+ )
348
+ console.log()
349
+
350
+ // Get engine display name
351
+ const engineDeps = getEngineDependencies(engine)
352
+ const engineName = engineDeps?.displayName || engine
353
+
354
+ const { shouldInstall } = await inquirer.prompt<{ shouldInstall: string }>([
355
+ {
356
+ type: 'list',
357
+ name: 'shouldInstall',
358
+ message: `Would you like to install ${engineName} client tools now?`,
359
+ choices: [
360
+ { name: 'Yes, install now', value: 'yes' },
361
+ { name: 'No, I will install manually', value: 'no' },
362
+ ],
363
+ default: 'yes',
364
+ },
365
+ ])
366
+
367
+ if (shouldInstall === 'no') {
368
+ console.log()
369
+ console.log(chalk.gray(' To install manually, run:'))
370
+
371
+ // Get the specific dependency and build install command info
372
+ if (engineDeps) {
373
+ const dep = engineDeps.dependencies.find((d) => d.binary === missingTool)
374
+ if (dep) {
375
+ const pkgDef = dep.packages[packageManager.id]
376
+ if (pkgDef) {
377
+ const installCmd = packageManager.config.installTemplate.replace(
378
+ '{package}',
379
+ pkgDef.package,
380
+ )
381
+ console.log(chalk.cyan(` ${installCmd}`))
382
+ if (pkgDef.postInstall) {
383
+ for (const postCmd of pkgDef.postInstall) {
384
+ console.log(chalk.cyan(` ${postCmd}`))
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+ console.log()
391
+ return false
392
+ }
393
+
394
+ console.log()
395
+
396
+ // For now, only PostgreSQL has full install support
397
+ // Future engines will need their own install functions
398
+ if (engine === 'postgresql') {
399
+ const success = await installPostgresBinaries()
400
+
401
+ if (success) {
402
+ console.log()
403
+ console.log(
404
+ chalk.green(` ${engineName} client tools installed successfully!`),
405
+ )
406
+ console.log(chalk.gray(' Please try your operation again.'))
407
+ console.log()
408
+ }
409
+
410
+ return success
411
+ }
412
+
413
+ // For other engines, show manual instructions
414
+ console.log(
415
+ chalk.yellow(` Automatic installation for ${engineName} is not yet supported.`),
416
+ )
417
+ console.log(chalk.gray(' Please install manually.'))
418
+ console.log()
419
+ return false
420
+ }