spindb 0.4.0 → 0.5.2

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.
@@ -15,6 +15,7 @@ import { success, error, warning } from '../ui/theme'
15
15
  import { platform, tmpdir } from 'os'
16
16
  import { spawn } from 'child_process'
17
17
  import { join } from 'path'
18
+ import { getMissingDependencies } from '../../core/dependency-manager'
18
19
 
19
20
  export const restoreCommand = new Command('restore')
20
21
  .description('Restore a backup to a container')
@@ -75,8 +76,12 @@ export const restoreCommand = new Command('restore')
75
76
  process.exit(1)
76
77
  }
77
78
 
79
+ const { engine: engineName } = config
80
+
78
81
  // Check if running
79
- const running = await processManager.isRunning(containerName)
82
+ const running = await processManager.isRunning(containerName, {
83
+ engine: engineName,
84
+ })
80
85
  if (!running) {
81
86
  console.error(
82
87
  error(
@@ -87,18 +92,75 @@ export const restoreCommand = new Command('restore')
87
92
  }
88
93
 
89
94
  // Get engine
90
- const engine = getEngine(config.engine)
95
+ const engine = getEngine(engineName)
96
+
97
+ // Check for required client tools BEFORE doing anything
98
+ const depsSpinner = createSpinner('Checking required tools...')
99
+ depsSpinner.start()
100
+
101
+ let missingDeps = await getMissingDependencies(config.engine)
102
+ if (missingDeps.length > 0) {
103
+ depsSpinner.warn(
104
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
105
+ )
106
+
107
+ // Offer to install
108
+ const installed = await promptInstallDependencies(
109
+ missingDeps[0].binary,
110
+ config.engine,
111
+ )
112
+
113
+ if (!installed) {
114
+ process.exit(1)
115
+ }
116
+
117
+ // Verify installation worked
118
+ missingDeps = await getMissingDependencies(config.engine)
119
+ if (missingDeps.length > 0) {
120
+ console.error(
121
+ error(
122
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
123
+ ),
124
+ )
125
+ process.exit(1)
126
+ }
127
+
128
+ console.log(chalk.green(' ✓ All required tools are now available'))
129
+ console.log()
130
+ } else {
131
+ depsSpinner.succeed('Required tools available')
132
+ }
91
133
 
92
134
  // Handle --from-url option
93
135
  if (options.fromUrl) {
94
- // Validate connection string
95
- if (
96
- !options.fromUrl.startsWith('postgresql://') &&
97
- !options.fromUrl.startsWith('postgres://')
98
- ) {
136
+ // Validate connection string matches container's engine
137
+ const isPgUrl =
138
+ options.fromUrl.startsWith('postgresql://') ||
139
+ options.fromUrl.startsWith('postgres://')
140
+ const isMysqlUrl = options.fromUrl.startsWith('mysql://')
141
+
142
+ if (engineName === 'postgresql' && !isPgUrl) {
143
+ console.error(
144
+ error(
145
+ 'Connection string must start with postgresql:// or postgres:// for PostgreSQL containers',
146
+ ),
147
+ )
148
+ process.exit(1)
149
+ }
150
+
151
+ if (engineName === 'mysql' && !isMysqlUrl) {
99
152
  console.error(
100
153
  error(
101
- 'Connection string must start with postgresql:// or postgres://',
154
+ 'Connection string must start with mysql:// for MySQL containers',
155
+ ),
156
+ )
157
+ process.exit(1)
158
+ }
159
+
160
+ if (!isPgUrl && !isMysqlUrl) {
161
+ console.error(
162
+ error(
163
+ 'Connection string must start with postgresql://, postgres://, or mysql://',
102
164
  ),
103
165
  )
104
166
  process.exit(1)
@@ -108,31 +170,53 @@ export const restoreCommand = new Command('restore')
108
170
  const timestamp = Date.now()
109
171
  tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
110
172
 
111
- const dumpSpinner = createSpinner(
112
- 'Creating dump from remote database...',
113
- )
114
- dumpSpinner.start()
173
+ let dumpSuccess = false
174
+ let attempts = 0
175
+ const maxAttempts = 2 // Allow one retry after installing deps
115
176
 
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')
177
+ while (!dumpSuccess && attempts < maxAttempts) {
178
+ attempts++
179
+ const dumpSpinner = createSpinner(
180
+ 'Creating dump from remote database...',
181
+ )
182
+ dumpSpinner.start()
183
+
184
+ try {
185
+ await engine.dumpFromConnectionString(options.fromUrl, tempDumpPath)
186
+ dumpSpinner.succeed('Dump created from remote database')
187
+ backupPath = tempDumpPath
188
+ dumpSuccess = true
189
+ } catch (err) {
190
+ const e = err as Error
191
+ dumpSpinner.fail('Failed to create dump')
192
+
193
+ // Check if this is a missing tool error
194
+ const dumpTool = engineName === 'mysql' ? 'mysqldump' : 'pg_dump'
195
+ if (
196
+ e.message.includes(`${dumpTool} not found`) ||
197
+ e.message.includes('ENOENT')
198
+ ) {
199
+ const installed = await promptInstallDependencies(
200
+ dumpTool,
201
+ engineName,
202
+ )
203
+ if (!installed) {
204
+ process.exit(1)
205
+ }
206
+ // Loop will retry
207
+ continue
208
+ }
209
+
210
+ console.log()
211
+ console.error(error(`${dumpTool} error:`))
212
+ console.log(chalk.gray(` ${e.message}`))
130
213
  process.exit(1)
131
214
  }
215
+ }
132
216
 
133
- console.log()
134
- console.error(error('pg_dump error:'))
135
- console.log(chalk.gray(` ${e.message}`))
217
+ // Safety check - should never reach here without backupPath set
218
+ if (!dumpSuccess) {
219
+ console.error(error('Failed to create dump after retries'))
136
220
  process.exit(1)
137
221
  }
138
222
  } else {
@@ -162,6 +246,12 @@ export const restoreCommand = new Command('restore')
162
246
  databaseName = await promptDatabaseName(containerName)
163
247
  }
164
248
 
249
+ // At this point backupPath is guaranteed to be set
250
+ if (!backupPath) {
251
+ console.error(error('No backup path specified'))
252
+ process.exit(1)
253
+ }
254
+
165
255
  // Detect backup format
166
256
  const detectSpinner = createSpinner('Detecting backup format...')
167
257
  detectSpinner.start()
@@ -251,15 +341,29 @@ export const restoreCommand = new Command('restore')
251
341
  } catch (err) {
252
342
  const e = err as Error
253
343
 
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)
344
+ // Check if this is a missing tool error (PostgreSQL or MySQL)
345
+ const missingToolPatterns = [
346
+ // PostgreSQL
347
+ 'pg_restore not found',
348
+ 'psql not found',
349
+ 'pg_dump not found',
350
+ // MySQL
351
+ 'mysql not found',
352
+ 'mysqldump not found',
353
+ ]
354
+
355
+ const matchingPattern = missingToolPatterns.find((p) =>
356
+ e.message.includes(p),
357
+ )
358
+
359
+ if (matchingPattern) {
360
+ const missingTool = matchingPattern.replace(' not found', '')
361
+ const installed = await promptInstallDependencies(missingTool)
362
+ if (installed) {
363
+ console.log(
364
+ chalk.yellow(' Please re-run your command to continue.'),
365
+ )
366
+ }
263
367
  process.exit(1)
264
368
  }
265
369
 
@@ -4,6 +4,7 @@ import { containerManager } from '../../core/container-manager'
4
4
  import { portManager } from '../../core/port-manager'
5
5
  import { processManager } from '../../core/process-manager'
6
6
  import { getEngine } from '../../engines'
7
+ import { getEngineDefaults } from '../../config/defaults'
7
8
  import { promptContainerSelect } from '../ui/prompts'
8
9
  import { createSpinner } from '../ui/spinner'
9
10
  import { error, warning } from '../ui/theme'
@@ -46,18 +47,27 @@ export const startCommand = new Command('start')
46
47
  process.exit(1)
47
48
  }
48
49
 
50
+ const { engine: engineName } = config
51
+
49
52
  // Check if already running
50
- const running = await processManager.isRunning(containerName)
53
+ const running = await processManager.isRunning(containerName, {
54
+ engine: engineName,
55
+ })
51
56
  if (running) {
52
57
  console.log(warning(`Container "${containerName}" is already running`))
53
58
  return
54
59
  }
55
60
 
61
+ // Get engine defaults for port range and database name
62
+ const engineDefaults = getEngineDefaults(engineName)
63
+
56
64
  // Check port availability
57
65
  const portAvailable = await portManager.isPortAvailable(config.port)
58
66
  if (!portAvailable) {
59
- // Try to find a new port
60
- const { port: newPort } = await portManager.findAvailablePort()
67
+ // Try to find a new port (using engine-specific port range)
68
+ const { port: newPort } = await portManager.findAvailablePort({
69
+ portRange: engineDefaults.portRange,
70
+ })
61
71
  console.log(
62
72
  warning(
63
73
  `Port ${config.port} is in use, switching to port ${newPort}`,
@@ -68,7 +78,7 @@ export const startCommand = new Command('start')
68
78
  }
69
79
 
70
80
  // Get engine and start
71
- const engine = getEngine(config.engine)
81
+ const engine = getEngine(engineName)
72
82
 
73
83
  const spinner = createSpinner(`Starting ${containerName}...`)
74
84
  spinner.start()
@@ -78,6 +88,22 @@ export const startCommand = new Command('start')
78
88
 
79
89
  spinner.succeed(`Container "${containerName}" started`)
80
90
 
91
+ // Ensure the user's database exists (if different from default)
92
+ const defaultDb = engineDefaults.superuser // postgres or root
93
+ if (config.database && config.database !== defaultDb) {
94
+ const dbSpinner = createSpinner(
95
+ `Ensuring database "${config.database}" exists...`,
96
+ )
97
+ dbSpinner.start()
98
+ try {
99
+ await engine.createDatabase(config, config.database)
100
+ dbSpinner.succeed(`Database "${config.database}" ready`)
101
+ } catch {
102
+ // Database might already exist, which is fine
103
+ dbSpinner.succeed(`Database "${config.database}" ready`)
104
+ }
105
+ }
106
+
81
107
  // Show connection info
82
108
  const connectionString = engine.getConnectionString(config)
83
109
  console.log()
@@ -67,7 +67,9 @@ export const stopCommand = new Command('stop')
67
67
  }
68
68
 
69
69
  // Check if running
70
- const running = await processManager.isRunning(containerName)
70
+ const running = await processManager.isRunning(containerName, {
71
+ engine: config.engine,
72
+ })
71
73
  if (!running) {
72
74
  console.log(warning(`Container "${containerName}" is not running`))
73
75
  return
package/cli/ui/prompts.ts CHANGED
@@ -2,12 +2,13 @@ import inquirer from 'inquirer'
2
2
  import chalk from 'chalk'
3
3
  import ora from 'ora'
4
4
  import { listEngines, getEngine } from '../../engines'
5
- import { defaults } from '../../config/defaults'
5
+ import { defaults, getEngineDefaults } from '../../config/defaults'
6
6
  import { installPostgresBinaries } from '../../core/postgres-binary-manager'
7
7
  import {
8
8
  detectPackageManager,
9
9
  getManualInstallInstructions,
10
10
  getCurrentPlatform,
11
+ installEngineDependencies,
11
12
  } from '../../core/dependency-manager'
12
13
  import { getEngineDependencies } from '../../config/os-dependencies'
13
14
  import type { ContainerConfig } from '../../types'
@@ -36,25 +37,26 @@ export async function promptContainerName(
36
37
  return name
37
38
  }
38
39
 
40
+ /**
41
+ * Engine icons for display
42
+ */
43
+ const engineIcons: Record<string, string> = {
44
+ postgresql: '🐘',
45
+ mysql: '🐬',
46
+ }
47
+
39
48
  /**
40
49
  * Prompt for database engine selection
41
50
  */
42
51
  export async function promptEngine(): Promise<string> {
43
52
  const engines = listEngines()
44
53
 
45
- // Build choices from available engines plus coming soon engines
46
- const choices = [
47
- ...engines.map((e) => ({
48
- name: `🐘 ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
49
- value: e.name,
50
- short: e.displayName,
51
- })),
52
- {
53
- name: chalk.gray('🐬 MySQL (coming soon)'),
54
- value: 'mysql',
55
- disabled: 'Coming soon',
56
- },
57
- ]
54
+ // Build choices from available engines
55
+ const choices = engines.map((e) => ({
56
+ name: `${engineIcons[e.name] || '🗄️'} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
57
+ value: e.name,
58
+ short: e.displayName,
59
+ }))
58
60
 
59
61
  const { engine } = await inquirer.prompt<{ engine: string }>([
60
62
  {
@@ -69,8 +71,8 @@ export async function promptEngine(): Promise<string> {
69
71
  }
70
72
 
71
73
  /**
72
- * Prompt for PostgreSQL version
73
- * Two-step selection: first major version, then specific minor version
74
+ * Prompt for database version
75
+ * Two-step selection: first major version, then specific minor version (if available)
74
76
  */
75
77
  export async function promptVersion(engineName: string): Promise<string> {
76
78
  const engine = getEngine(engineName)
@@ -112,13 +114,13 @@ export async function promptVersion(engineName: string): Promise<string> {
112
114
  const countLabel =
113
115
  versionCount > 0 ? chalk.gray(`(${versionCount} versions)`) : ''
114
116
  const label = isLatestMajor
115
- ? `PostgreSQL ${major} ${countLabel} ${chalk.green('← latest')}`
116
- : `PostgreSQL ${major} ${countLabel}`
117
+ ? `${engine.displayName} ${major} ${countLabel} ${chalk.green('← latest')}`
118
+ : `${engine.displayName} ${major} ${countLabel}`
117
119
 
118
120
  majorChoices.push({
119
121
  name: label,
120
122
  value: major,
121
- short: `PostgreSQL ${major}`,
123
+ short: `${engine.displayName} ${major}`,
122
124
  })
123
125
  }
124
126
 
@@ -150,7 +152,7 @@ export async function promptVersion(engineName: string): Promise<string> {
150
152
  {
151
153
  type: 'list',
152
154
  name: 'version',
153
- message: `Select PostgreSQL ${majorVersion} version:`,
155
+ message: `Select ${engine.displayName} ${majorVersion} version:`,
154
156
  choices: minorChoices,
155
157
  default: minorVersions[0], // Default to latest
156
158
  },
@@ -225,7 +227,7 @@ export async function promptContainerSelect(
225
227
  name: 'container',
226
228
  message,
227
229
  choices: containers.map((c) => ({
228
- name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${
230
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${
229
231
  c.status === 'running'
230
232
  ? chalk.green('● running')
231
233
  : chalk.gray('○ stopped')
@@ -279,16 +281,17 @@ export type CreateOptions = {
279
281
  /**
280
282
  * Full interactive create flow
281
283
  */
282
- export async function promptCreateOptions(
283
- defaultPort: number = defaults.port,
284
- ): Promise<CreateOptions> {
284
+ export async function promptCreateOptions(): Promise<CreateOptions> {
285
285
  console.log(chalk.cyan('\n 🗄️ Create New Database Container\n'))
286
286
 
287
287
  const engine = await promptEngine()
288
288
  const version = await promptVersion(engine)
289
289
  const name = await promptContainerName()
290
290
  const database = await promptDatabaseName(name) // Default to container name
291
- const port = await promptPort(defaultPort)
291
+
292
+ // Get engine-specific default port
293
+ const engineDefaults = getEngineDefaults(engine)
294
+ const port = await promptPort(engineDefaults.defaultPort)
292
295
 
293
296
  return { name, engine, version, port, database }
294
297
  }
@@ -393,8 +396,7 @@ export async function promptInstallDependencies(
393
396
 
394
397
  console.log()
395
398
 
396
- // For now, only PostgreSQL has full install support
397
- // Future engines will need their own install functions
399
+ // PostgreSQL has its own install function with extra logic
398
400
  if (engine === 'postgresql') {
399
401
  const success = await installPostgresBinaries()
400
402
 
@@ -403,18 +405,79 @@ export async function promptInstallDependencies(
403
405
  console.log(
404
406
  chalk.green(` ${engineName} client tools installed successfully!`),
405
407
  )
406
- console.log(chalk.gray(' Please try your operation again.'))
408
+ console.log(chalk.gray(' Continuing with your operation...'))
407
409
  console.log()
408
410
  }
409
411
 
410
412
  return success
411
413
  }
412
414
 
413
- // For other engines, show manual instructions
415
+ // For other engines (MySQL, etc.), use the generic installer
414
416
  console.log(
415
- chalk.yellow(` Automatic installation for ${engineName} is not yet supported.`),
417
+ chalk.cyan(` Installing ${engineName} with ${packageManager.name}...`),
416
418
  )
417
- console.log(chalk.gray(' Please install manually.'))
419
+ console.log(chalk.gray(' You may be prompted for your password.'))
418
420
  console.log()
419
- return false
421
+
422
+ try {
423
+ const results = await installEngineDependencies(engine, packageManager)
424
+ const allSuccess = results.every((r) => r.success)
425
+
426
+ if (allSuccess) {
427
+ console.log()
428
+ console.log(
429
+ chalk.green(` ${engineName} tools installed successfully!`),
430
+ )
431
+ console.log(chalk.gray(' Continuing with your operation...'))
432
+ console.log()
433
+ return true
434
+ } else {
435
+ const failed = results.filter((r) => !r.success)
436
+ console.log()
437
+ console.log(chalk.red(' Some installations failed:'))
438
+ for (const f of failed) {
439
+ console.log(chalk.red(` ${f.dependency.name}: ${f.error}`))
440
+ }
441
+ console.log()
442
+
443
+ // Show manual install instructions
444
+ if (engineDeps) {
445
+ const instructions = getManualInstallInstructions(
446
+ engineDeps.dependencies[0],
447
+ platform,
448
+ )
449
+ if (instructions.length > 0) {
450
+ console.log(chalk.gray(' To install manually:'))
451
+ for (const instruction of instructions) {
452
+ console.log(chalk.gray(` ${instruction}`))
453
+ }
454
+ console.log()
455
+ }
456
+ }
457
+
458
+ return false
459
+ }
460
+ } catch (err) {
461
+ const e = err as Error
462
+ console.log()
463
+ console.log(chalk.red(` Installation failed: ${e.message}`))
464
+ console.log()
465
+
466
+ // Show manual install instructions on error
467
+ if (engineDeps) {
468
+ const instructions = getManualInstallInstructions(
469
+ engineDeps.dependencies[0],
470
+ platform,
471
+ )
472
+ if (instructions.length > 0) {
473
+ console.log(chalk.gray(' To install manually:'))
474
+ for (const instruction of instructions) {
475
+ console.log(chalk.gray(` ${instruction}`))
476
+ }
477
+ console.log()
478
+ }
479
+ }
480
+
481
+ return false
482
+ }
420
483
  }
@@ -1,3 +1,20 @@
1
+ import {
2
+ engineDefaults,
3
+ getEngineDefaults,
4
+ isEngineSupported,
5
+ getSupportedEngines,
6
+ type EngineDefaults,
7
+ } from './engine-defaults'
8
+
9
+ // Re-export engine-related functions and types
10
+ export {
11
+ engineDefaults,
12
+ getEngineDefaults,
13
+ isEngineSupported,
14
+ getSupportedEngines,
15
+ type EngineDefaults,
16
+ }
17
+
1
18
  export type PlatformMappings = {
2
19
  [key: string]: string
3
20
  }
@@ -7,42 +24,50 @@ export type PortRange = {
7
24
  end: number
8
25
  }
9
26
 
27
+ /**
28
+ * Legacy Defaults type - kept for backward compatibility
29
+ * New code should use getEngineDefaults(engine) instead
30
+ */
10
31
  export type Defaults = {
32
+ /** @deprecated Use getEngineDefaults(engine).defaultVersion instead */
11
33
  postgresVersion: string
12
34
  port: number
13
35
  portRange: PortRange
14
36
  engine: string
37
+ /** @deprecated Use getEngineDefaults(engine).supportedVersions instead */
15
38
  supportedPostgresVersions: string[]
16
39
  superuser: string
17
40
  platformMappings: PlatformMappings
18
41
  }
19
42
 
20
- // TODO - make defaults configurable via env vars or config file
21
- // TODO - make defaults generic so it supports multiple engines
22
- // TODO - consider using a configuration file or environment variables for overrides
43
+ // Get PostgreSQL defaults from engine-defaults
44
+ const pgDefaults = engineDefaults.postgresql
45
+
46
+ /**
47
+ * Default configuration values
48
+ * For backward compatibility, this defaults to PostgreSQL settings.
49
+ * New code should use getEngineDefaults(engine) for engine-specific defaults.
50
+ */
23
51
  export const defaults: Defaults = {
24
- // Default PostgreSQL version
25
- postgresVersion: '16',
52
+ // Default PostgreSQL version (from engine defaults)
53
+ postgresVersion: pgDefaults.defaultVersion,
26
54
 
27
55
  // Default port (standard PostgreSQL port)
28
- port: 5432,
56
+ port: pgDefaults.defaultPort,
29
57
 
30
58
  // Port range to scan if default is busy
31
- portRange: {
32
- start: 5432,
33
- end: 5500,
34
- },
59
+ portRange: pgDefaults.portRange,
35
60
 
36
61
  // Default engine
37
62
  engine: 'postgresql',
38
63
 
39
- // Supported PostgreSQL versions
40
- supportedPostgresVersions: ['14', '15', '16', '17'],
64
+ // Supported PostgreSQL versions (from engine defaults)
65
+ supportedPostgresVersions: pgDefaults.supportedVersions,
41
66
 
42
- // Default superuser
43
- superuser: 'postgres',
67
+ // Default superuser (from engine defaults)
68
+ superuser: pgDefaults.superuser,
44
69
 
45
- // Platform mappings for zonky.io binaries
70
+ // Platform mappings for zonky.io binaries (PostgreSQL specific)
46
71
  platformMappings: {
47
72
  'darwin-arm64': 'darwin-arm64v8',
48
73
  'darwin-x64': 'darwin-amd64',