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.
@@ -5,10 +5,12 @@ import chalk from 'chalk'
5
5
  import { containerManager } from '../../core/container-manager'
6
6
  import { portManager } from '../../core/port-manager'
7
7
  import { getEngine } from '../../engines'
8
- import { defaults } from '../../config/defaults'
8
+ import { getEngineDefaults } from '../../config/defaults'
9
9
  import {
10
10
  promptCreateOptions,
11
11
  promptInstallDependencies,
12
+ promptContainerName,
13
+ promptConfirm,
12
14
  } from '../ui/prompts'
13
15
  import { createSpinner } from '../ui/spinner'
14
16
  import { header, error, connectionBox } from '../ui/theme'
@@ -16,38 +18,43 @@ import { tmpdir } from 'os'
16
18
  import { join } from 'path'
17
19
  import { spawn } from 'child_process'
18
20
  import { platform } from 'os'
21
+ import { getMissingDependencies } from '../../core/dependency-manager'
22
+ import type { EngineName } from '../../types'
19
23
 
20
24
  /**
21
25
  * Detect if a location string is a connection string or a file path
26
+ * Also infers engine from connection string scheme
22
27
  */
23
- function detectLocationType(
24
- location: string,
25
- ): 'connection' | 'file' | 'not_found' {
26
- // Check if it's a connection string
28
+ function detectLocationType(location: string): {
29
+ type: 'connection' | 'file' | 'not_found'
30
+ inferredEngine?: EngineName
31
+ } {
32
+ // Check for PostgreSQL connection string
27
33
  if (
28
34
  location.startsWith('postgresql://') ||
29
35
  location.startsWith('postgres://')
30
36
  ) {
31
- return 'connection'
37
+ return { type: 'connection', inferredEngine: 'postgresql' }
38
+ }
39
+
40
+ // Check for MySQL connection string
41
+ if (location.startsWith('mysql://')) {
42
+ return { type: 'connection', inferredEngine: 'mysql' }
32
43
  }
33
44
 
34
45
  // Check if file exists
35
46
  if (existsSync(location)) {
36
- return 'file'
47
+ return { type: 'file' }
37
48
  }
38
49
 
39
- return 'not_found'
50
+ return { type: 'not_found' }
40
51
  }
41
52
 
42
53
  export const createCommand = new Command('create')
43
54
  .description('Create a new database container')
44
55
  .argument('[name]', 'Container name')
45
- .option('-e, --engine <engine>', 'Database engine', defaults.engine)
46
- .option(
47
- '--pg-version <version>',
48
- 'PostgreSQL version',
49
- defaults.postgresVersion,
50
- )
56
+ .option('-e, --engine <engine>', 'Database engine (postgresql, mysql)')
57
+ .option('-v, --version <version>', 'Database version')
51
58
  .option('-d, --database <database>', 'Database name')
52
59
  .option('-p, --port <port>', 'Port number')
53
60
  .option('--no-start', 'Do not start the container after creation')
@@ -59,8 +66,8 @@ export const createCommand = new Command('create')
59
66
  async (
60
67
  name: string | undefined,
61
68
  options: {
62
- engine: string
63
- pgVersion: string
69
+ engine?: string
70
+ version?: string
64
71
  database?: string
65
72
  port?: string
66
73
  start: boolean
@@ -71,41 +78,39 @@ export const createCommand = new Command('create')
71
78
 
72
79
  try {
73
80
  let containerName = name
74
- let engine = options.engine
75
- let version = options.pgVersion
81
+ let engine: EngineName = (options.engine as EngineName) || 'postgresql'
82
+ let version = options.version
76
83
  let database = options.database
77
84
 
78
- // Interactive mode if no name provided
79
- if (!containerName) {
80
- const answers = await promptCreateOptions()
81
- containerName = answers.name
82
- engine = answers.engine
83
- version = answers.version
84
- database = answers.database
85
- }
86
-
87
- // Default database name to container name if not specified
88
- database = database ?? containerName
89
-
90
- // Validate --from location if provided
85
+ // Validate --from location if provided (before prompts so we can infer engine)
91
86
  let restoreLocation: string | null = null
92
87
  let restoreType: 'connection' | 'file' | null = null
93
88
 
94
89
  if (options.from) {
95
- const locationType = detectLocationType(options.from)
90
+ const locationInfo = detectLocationType(options.from)
96
91
 
97
- if (locationType === 'not_found') {
92
+ if (locationInfo.type === 'not_found') {
98
93
  console.error(error(`Location not found: ${options.from}`))
99
94
  console.log(
100
95
  chalk.gray(
101
- ' Provide a valid file path or connection string (postgresql://...)',
96
+ ' Provide a valid file path or connection string (postgresql://, mysql://)',
102
97
  ),
103
98
  )
104
99
  process.exit(1)
105
100
  }
106
101
 
107
102
  restoreLocation = options.from
108
- restoreType = locationType
103
+ restoreType = locationInfo.type
104
+
105
+ // Infer engine from connection string if not explicitly set
106
+ if (!options.engine && locationInfo.inferredEngine) {
107
+ engine = locationInfo.inferredEngine
108
+ console.log(
109
+ chalk.gray(
110
+ ` Inferred engine "${engine}" from connection string`,
111
+ ),
112
+ )
113
+ }
109
114
 
110
115
  // If using --from, we must start the container
111
116
  if (options.start === false) {
@@ -118,12 +123,69 @@ export const createCommand = new Command('create')
118
123
  }
119
124
  }
120
125
 
126
+ // Get engine defaults for port range and default version
127
+ const engineDefaults = getEngineDefaults(engine)
128
+
129
+ // Set version to engine default if not specified
130
+ if (!version) {
131
+ version = engineDefaults.defaultVersion
132
+ }
133
+
134
+ // Interactive mode if no name provided
135
+ if (!containerName) {
136
+ const answers = await promptCreateOptions()
137
+ containerName = answers.name
138
+ engine = answers.engine as EngineName
139
+ version = answers.version
140
+ database = answers.database
141
+ }
142
+
143
+ // Default database name to container name if not specified
144
+ database = database ?? containerName
145
+
121
146
  console.log(header('Creating Database Container'))
122
147
  console.log()
123
148
 
124
149
  // Get the engine
125
150
  const dbEngine = getEngine(engine)
126
151
 
152
+ // Check for required client tools BEFORE creating anything
153
+ const depsSpinner = createSpinner('Checking required tools...')
154
+ depsSpinner.start()
155
+
156
+ let missingDeps = await getMissingDependencies(engine)
157
+ if (missingDeps.length > 0) {
158
+ depsSpinner.warn(
159
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
160
+ )
161
+
162
+ // Offer to install
163
+ const installed = await promptInstallDependencies(
164
+ missingDeps[0].binary,
165
+ engine,
166
+ )
167
+
168
+ if (!installed) {
169
+ process.exit(1)
170
+ }
171
+
172
+ // Verify installation worked
173
+ missingDeps = await getMissingDependencies(engine)
174
+ if (missingDeps.length > 0) {
175
+ console.error(
176
+ error(
177
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
178
+ ),
179
+ )
180
+ process.exit(1)
181
+ }
182
+
183
+ console.log(chalk.green(' ✓ All required tools are now available'))
184
+ console.log()
185
+ } else {
186
+ depsSpinner.succeed('Required tools available')
187
+ }
188
+
127
189
  // Find available port
128
190
  const portSpinner = createSpinner('Finding available port...')
129
191
  portSpinner.start()
@@ -139,30 +201,47 @@ export const createCommand = new Command('create')
139
201
  portSpinner.succeed(`Using port ${port}`)
140
202
  } else {
141
203
  const { port: foundPort, isDefault } =
142
- await portManager.findAvailablePort()
204
+ await portManager.findAvailablePort({
205
+ preferredPort: engineDefaults.defaultPort,
206
+ portRange: engineDefaults.portRange,
207
+ })
143
208
  port = foundPort
144
209
  if (isDefault) {
145
210
  portSpinner.succeed(`Using default port ${port}`)
146
211
  } else {
147
- portSpinner.warn(`Default port 5432 is in use, using port ${port}`)
212
+ portSpinner.warn(
213
+ `Default port ${engineDefaults.defaultPort} is in use, using port ${port}`,
214
+ )
148
215
  }
149
216
  }
150
217
 
151
218
  // Ensure binaries are available
152
219
  const binarySpinner = createSpinner(
153
- `Checking PostgreSQL ${version} binaries...`,
220
+ `Checking ${dbEngine.displayName} ${version} binaries...`,
154
221
  )
155
222
  binarySpinner.start()
156
223
 
157
224
  const isInstalled = await dbEngine.isBinaryInstalled(version)
158
225
  if (isInstalled) {
159
- binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
226
+ binarySpinner.succeed(
227
+ `${dbEngine.displayName} ${version} binaries ready (cached)`,
228
+ )
160
229
  } else {
161
- binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
230
+ binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
162
231
  await dbEngine.ensureBinaries(version, ({ message }) => {
163
232
  binarySpinner.text = message
164
233
  })
165
- binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
234
+ binarySpinner.succeed(
235
+ `${dbEngine.displayName} ${version} binaries downloaded`,
236
+ )
237
+ }
238
+
239
+ // Check if container name already exists and prompt for new name if needed
240
+ while (await containerManager.exists(containerName)) {
241
+ console.log(
242
+ chalk.yellow(` Container "${containerName}" already exists.`),
243
+ )
244
+ containerName = await promptContainerName()
166
245
  }
167
246
 
168
247
  // Create container
@@ -170,7 +249,7 @@ export const createCommand = new Command('create')
170
249
  createSpinnerInstance.start()
171
250
 
172
251
  await containerManager.create(containerName, {
173
- engine: dbEngine.name,
252
+ engine: dbEngine.name as EngineName,
174
253
  version,
175
254
  port,
176
255
  database,
@@ -183,28 +262,68 @@ export const createCommand = new Command('create')
183
262
  initSpinner.start()
184
263
 
185
264
  await dbEngine.initDataDir(containerName, version, {
186
- superuser: defaults.superuser,
265
+ superuser: engineDefaults.superuser,
187
266
  })
188
267
 
189
268
  initSpinner.succeed('Database cluster initialized')
190
269
 
191
- // Start container if requested
192
- if (options.start !== false) {
193
- const startSpinner = createSpinner('Starting PostgreSQL...')
194
- startSpinner.start()
270
+ // Determine if we should start the container
271
+ // If --from is specified, we must start to restore
272
+ // If --no-start is specified, don't start
273
+ // Otherwise, ask the user
274
+ let shouldStart = false
275
+ if (restoreLocation) {
276
+ // Must start to restore data
277
+ shouldStart = true
278
+ } else if (options.start === false) {
279
+ // User explicitly requested no start
280
+ shouldStart = false
281
+ } else {
282
+ // Ask the user
283
+ console.log()
284
+ shouldStart = await promptConfirm(
285
+ `Start ${containerName} now?`,
286
+ true,
287
+ )
288
+ }
195
289
 
196
- const config = await containerManager.getConfig(containerName)
197
- if (config) {
198
- await dbEngine.start(config)
199
- await containerManager.updateConfig(containerName, {
200
- status: 'running',
290
+ // Get container config for starting and restoration
291
+ const config = await containerManager.getConfig(containerName)
292
+
293
+ // Start container if requested
294
+ if (shouldStart && config) {
295
+ // Check port availability before starting
296
+ const portAvailable = await portManager.isPortAvailable(config.port)
297
+ if (!portAvailable) {
298
+ // Find a new available port
299
+ const { port: newPort } = await portManager.findAvailablePort({
300
+ portRange: engineDefaults.portRange,
201
301
  })
302
+ console.log(
303
+ chalk.yellow(
304
+ ` ⚠ Port ${config.port} is in use, switching to port ${newPort}`,
305
+ ),
306
+ )
307
+ config.port = newPort
308
+ port = newPort
309
+ await containerManager.updateConfig(containerName, { port: newPort })
202
310
  }
203
311
 
204
- startSpinner.succeed('PostgreSQL started')
312
+ const startSpinner = createSpinner(
313
+ `Starting ${dbEngine.displayName}...`,
314
+ )
315
+ startSpinner.start()
316
+
317
+ await dbEngine.start(config)
318
+ await containerManager.updateConfig(containerName, {
319
+ status: 'running',
320
+ })
321
+
322
+ startSpinner.succeed(`${dbEngine.displayName} started`)
205
323
 
206
- // Create the user's database (if different from 'postgres')
207
- if (config && database !== 'postgres') {
324
+ // Create the user's database (if different from default)
325
+ const defaultDb = engineDefaults.superuser // postgres or root
326
+ if (database !== defaultDb) {
208
327
  const dbSpinner = createSpinner(
209
328
  `Creating database "${database}"...`,
210
329
  )
@@ -214,16 +333,23 @@ export const createCommand = new Command('create')
214
333
 
215
334
  dbSpinner.succeed(`Database "${database}" created`)
216
335
  }
336
+ }
217
337
 
218
- // Handle --from restore if specified
219
- if (restoreLocation && restoreType && config) {
220
- let backupPath: string
338
+ // Handle --from restore if specified (only if started)
339
+ if (restoreLocation && restoreType && config && shouldStart) {
340
+ let backupPath = ''
221
341
 
222
- if (restoreType === 'connection') {
223
- // Create dump from remote database
224
- const timestamp = Date.now()
225
- tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
342
+ if (restoreType === 'connection') {
343
+ // Create dump from remote database
344
+ const timestamp = Date.now()
345
+ tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
226
346
 
347
+ let dumpSuccess = false
348
+ let attempts = 0
349
+ const maxAttempts = 2 // Allow one retry after installing deps
350
+
351
+ while (!dumpSuccess && attempts < maxAttempts) {
352
+ attempts++
227
353
  const dumpSpinner = createSpinner(
228
354
  'Creating dump from remote database...',
229
355
  )
@@ -236,6 +362,7 @@ export const createCommand = new Command('create')
236
362
  )
237
363
  dumpSpinner.succeed('Dump created from remote database')
238
364
  backupPath = tempDumpPath
365
+ dumpSuccess = true
239
366
  } catch (err) {
240
367
  const e = err as Error
241
368
  dumpSpinner.fail('Failed to create dump')
@@ -245,8 +372,12 @@ export const createCommand = new Command('create')
245
372
  e.message.includes('pg_dump not found') ||
246
373
  e.message.includes('ENOENT')
247
374
  ) {
248
- await promptInstallDependencies('pg_dump')
249
- process.exit(1)
375
+ const installed = await promptInstallDependencies('pg_dump')
376
+ if (!installed) {
377
+ process.exit(1)
378
+ }
379
+ // Loop will retry
380
+ continue
250
381
  }
251
382
 
252
383
  console.log()
@@ -254,59 +385,66 @@ export const createCommand = new Command('create')
254
385
  console.log(chalk.gray(` ${e.message}`))
255
386
  process.exit(1)
256
387
  }
257
- } else {
258
- backupPath = restoreLocation
259
388
  }
260
389
 
261
- // Detect backup format
262
- const detectSpinner = createSpinner('Detecting backup format...')
263
- detectSpinner.start()
390
+ // Safety check - should never reach here without backupPath set
391
+ if (!dumpSuccess) {
392
+ console.error(error('Failed to create dump after retries'))
393
+ process.exit(1)
394
+ }
395
+ } else {
396
+ backupPath = restoreLocation
397
+ }
264
398
 
265
- const format = await dbEngine.detectBackupFormat(backupPath)
266
- detectSpinner.succeed(`Detected: ${format.description}`)
399
+ // Detect backup format
400
+ const detectSpinner = createSpinner('Detecting backup format...')
401
+ detectSpinner.start()
267
402
 
268
- // Restore backup
269
- const restoreSpinner = createSpinner('Restoring backup...')
270
- restoreSpinner.start()
403
+ const format = await dbEngine.detectBackupFormat(backupPath)
404
+ detectSpinner.succeed(`Detected: ${format.description}`)
271
405
 
272
- const result = await dbEngine.restore(config, backupPath, {
273
- database,
274
- createDatabase: false, // Already created above
275
- })
406
+ // Restore backup
407
+ const restoreSpinner = createSpinner('Restoring backup...')
408
+ restoreSpinner.start()
276
409
 
277
- if (result.code === 0 || !result.stderr) {
278
- restoreSpinner.succeed('Backup restored successfully')
279
- } else {
280
- restoreSpinner.warn('Restore completed with warnings')
281
- if (result.stderr) {
282
- console.log(chalk.yellow('\n Warnings:'))
283
- const lines = result.stderr.split('\n').slice(0, 5)
284
- lines.forEach((line) => {
285
- if (line.trim()) {
286
- console.log(chalk.gray(` ${line}`))
287
- }
288
- })
289
- if (result.stderr.split('\n').length > 5) {
290
- console.log(chalk.gray(' ...'))
410
+ const result = await dbEngine.restore(config, backupPath, {
411
+ database,
412
+ createDatabase: false, // Already created above
413
+ })
414
+
415
+ if (result.code === 0 || !result.stderr) {
416
+ restoreSpinner.succeed('Backup restored successfully')
417
+ } else {
418
+ restoreSpinner.warn('Restore completed with warnings')
419
+ if (result.stderr) {
420
+ console.log(chalk.yellow('\n Warnings:'))
421
+ const lines = result.stderr.split('\n').slice(0, 5)
422
+ lines.forEach((line) => {
423
+ if (line.trim()) {
424
+ console.log(chalk.gray(` ${line}`))
291
425
  }
426
+ })
427
+ if (result.stderr.split('\n').length > 5) {
428
+ console.log(chalk.gray(' ...'))
292
429
  }
293
430
  }
294
431
  }
295
432
  }
296
433
 
297
434
  // Show success message
298
- const config = await containerManager.getConfig(containerName)
299
- if (config) {
300
- const connectionString = dbEngine.getConnectionString(config)
435
+ const finalConfig = await containerManager.getConfig(containerName)
436
+ if (finalConfig) {
437
+ const connectionString = dbEngine.getConnectionString(finalConfig)
301
438
 
302
439
  console.log()
303
- console.log(connectionBox(containerName, connectionString, port))
440
+ console.log(connectionBox(containerName, connectionString, finalConfig.port))
304
441
  console.log()
305
- console.log(chalk.gray(' Connect with:'))
306
- console.log(chalk.cyan(` spindb connect ${containerName}`))
307
442
 
308
- // Copy connection string to clipboard
309
- if (options.start !== false) {
443
+ if (shouldStart) {
444
+ console.log(chalk.gray(' Connect with:'))
445
+ console.log(chalk.cyan(` spindb connect ${containerName}`))
446
+
447
+ // Copy connection string to clipboard
310
448
  try {
311
449
  const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
312
450
  const args =
@@ -332,6 +470,9 @@ export const createCommand = new Command('create')
332
470
  } catch {
333
471
  // Ignore clipboard errors
334
472
  }
473
+ } else {
474
+ console.log(chalk.gray(' Start the container:'))
475
+ console.log(chalk.cyan(` spindb start ${containerName}`))
335
476
  }
336
477
 
337
478
  console.log()
@@ -339,18 +480,30 @@ export const createCommand = new Command('create')
339
480
  } catch (err) {
340
481
  const e = err as Error
341
482
 
342
- // Check if this is a missing tool error
343
- if (
344
- e.message.includes('pg_restore not found') ||
345
- e.message.includes('psql not found') ||
346
- e.message.includes('pg_dump not found')
347
- ) {
348
- const missingTool = e.message.includes('pg_restore')
349
- ? 'pg_restore'
350
- : e.message.includes('pg_dump')
351
- ? 'pg_dump'
352
- : 'psql'
353
- await promptInstallDependencies(missingTool)
483
+ // Check if this is a missing tool error (PostgreSQL or MySQL)
484
+ const missingToolPatterns = [
485
+ // PostgreSQL
486
+ 'pg_restore not found',
487
+ 'psql not found',
488
+ 'pg_dump not found',
489
+ // MySQL
490
+ 'mysql not found',
491
+ 'mysqldump not found',
492
+ 'mysqld not found',
493
+ ]
494
+
495
+ const matchingPattern = missingToolPatterns.find((p) =>
496
+ e.message.includes(p),
497
+ )
498
+
499
+ if (matchingPattern) {
500
+ const missingTool = matchingPattern.replace(' not found', '')
501
+ const installed = await promptInstallDependencies(missingTool)
502
+ if (installed) {
503
+ console.log(
504
+ chalk.yellow(' Please re-run your command to continue.'),
505
+ )
506
+ }
354
507
  process.exit(1)
355
508
  }
356
509
 
@@ -57,7 +57,9 @@ export const deleteCommand = new Command('delete')
57
57
  }
58
58
 
59
59
  // Check if running
60
- const running = await processManager.isRunning(containerName)
60
+ const running = await processManager.isRunning(containerName, {
61
+ engine: config.engine,
62
+ })
61
63
  if (running) {
62
64
  if (options.force) {
63
65
  // Stop the container first
@@ -3,6 +3,14 @@ import chalk from 'chalk'
3
3
  import { containerManager } from '../../core/container-manager'
4
4
  import { info, error } from '../ui/theme'
5
5
 
6
+ /**
7
+ * Engine icons for display
8
+ */
9
+ const engineIcons: Record<string, string> = {
10
+ postgresql: '🐘',
11
+ mysql: '🐬',
12
+ }
13
+
6
14
  export const listCommand = new Command('list')
7
15
  .alias('ls')
8
16
  .description('List all containers')
@@ -26,12 +34,12 @@ export const listCommand = new Command('list')
26
34
  console.log(
27
35
  chalk.gray(' ') +
28
36
  chalk.bold.white('NAME'.padEnd(20)) +
29
- chalk.bold.white('ENGINE'.padEnd(12)) +
37
+ chalk.bold.white('ENGINE'.padEnd(15)) +
30
38
  chalk.bold.white('VERSION'.padEnd(10)) +
31
39
  chalk.bold.white('PORT'.padEnd(8)) +
32
40
  chalk.bold.white('STATUS'),
33
41
  )
34
- console.log(chalk.gray(' ' + '─'.repeat(60)))
42
+ console.log(chalk.gray(' ' + '─'.repeat(63)))
35
43
 
36
44
  // Table rows
37
45
  for (const container of containers) {
@@ -40,10 +48,13 @@ export const listCommand = new Command('list')
40
48
  ? chalk.green('● running')
41
49
  : chalk.gray('○ stopped')
42
50
 
51
+ const engineIcon = engineIcons[container.engine] || '🗄️'
52
+ const engineDisplay = `${engineIcon} ${container.engine}`
53
+
43
54
  console.log(
44
55
  chalk.gray(' ') +
45
56
  chalk.cyan(container.name.padEnd(20)) +
46
- chalk.white(container.engine.padEnd(12)) +
57
+ chalk.white(engineDisplay.padEnd(14)) +
47
58
  chalk.yellow(container.version.padEnd(10)) +
48
59
  chalk.green(String(container.port).padEnd(8)) +
49
60
  statusDisplay,