spindb 0.4.1 → 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,11 +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
12
  promptContainerName,
13
+ promptConfirm,
13
14
  } from '../ui/prompts'
14
15
  import { createSpinner } from '../ui/spinner'
15
16
  import { header, error, connectionBox } from '../ui/theme'
@@ -18,38 +19,42 @@ import { join } from 'path'
18
19
  import { spawn } from 'child_process'
19
20
  import { platform } from 'os'
20
21
  import { getMissingDependencies } from '../../core/dependency-manager'
22
+ import type { EngineName } from '../../types'
21
23
 
22
24
  /**
23
25
  * Detect if a location string is a connection string or a file path
26
+ * Also infers engine from connection string scheme
24
27
  */
25
- function detectLocationType(
26
- location: string,
27
- ): 'connection' | 'file' | 'not_found' {
28
- // 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
29
33
  if (
30
34
  location.startsWith('postgresql://') ||
31
35
  location.startsWith('postgres://')
32
36
  ) {
33
- 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' }
34
43
  }
35
44
 
36
45
  // Check if file exists
37
46
  if (existsSync(location)) {
38
- return 'file'
47
+ return { type: 'file' }
39
48
  }
40
49
 
41
- return 'not_found'
50
+ return { type: 'not_found' }
42
51
  }
43
52
 
44
53
  export const createCommand = new Command('create')
45
54
  .description('Create a new database container')
46
55
  .argument('[name]', 'Container name')
47
- .option('-e, --engine <engine>', 'Database engine', defaults.engine)
48
- .option(
49
- '--pg-version <version>',
50
- 'PostgreSQL version',
51
- defaults.postgresVersion,
52
- )
56
+ .option('-e, --engine <engine>', 'Database engine (postgresql, mysql)')
57
+ .option('-v, --version <version>', 'Database version')
53
58
  .option('-d, --database <database>', 'Database name')
54
59
  .option('-p, --port <port>', 'Port number')
55
60
  .option('--no-start', 'Do not start the container after creation')
@@ -61,8 +66,8 @@ export const createCommand = new Command('create')
61
66
  async (
62
67
  name: string | undefined,
63
68
  options: {
64
- engine: string
65
- pgVersion: string
69
+ engine?: string
70
+ version?: string
66
71
  database?: string
67
72
  port?: string
68
73
  start: boolean
@@ -73,41 +78,39 @@ export const createCommand = new Command('create')
73
78
 
74
79
  try {
75
80
  let containerName = name
76
- let engine = options.engine
77
- let version = options.pgVersion
81
+ let engine: EngineName = (options.engine as EngineName) || 'postgresql'
82
+ let version = options.version
78
83
  let database = options.database
79
84
 
80
- // Interactive mode if no name provided
81
- if (!containerName) {
82
- const answers = await promptCreateOptions()
83
- containerName = answers.name
84
- engine = answers.engine
85
- version = answers.version
86
- database = answers.database
87
- }
88
-
89
- // Default database name to container name if not specified
90
- database = database ?? containerName
91
-
92
- // Validate --from location if provided
85
+ // Validate --from location if provided (before prompts so we can infer engine)
93
86
  let restoreLocation: string | null = null
94
87
  let restoreType: 'connection' | 'file' | null = null
95
88
 
96
89
  if (options.from) {
97
- const locationType = detectLocationType(options.from)
90
+ const locationInfo = detectLocationType(options.from)
98
91
 
99
- if (locationType === 'not_found') {
92
+ if (locationInfo.type === 'not_found') {
100
93
  console.error(error(`Location not found: ${options.from}`))
101
94
  console.log(
102
95
  chalk.gray(
103
- ' Provide a valid file path or connection string (postgresql://...)',
96
+ ' Provide a valid file path or connection string (postgresql://, mysql://)',
104
97
  ),
105
98
  )
106
99
  process.exit(1)
107
100
  }
108
101
 
109
102
  restoreLocation = options.from
110
- 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
+ }
111
114
 
112
115
  // If using --from, we must start the container
113
116
  if (options.start === false) {
@@ -120,6 +123,26 @@ export const createCommand = new Command('create')
120
123
  }
121
124
  }
122
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
+
123
146
  console.log(header('Creating Database Container'))
124
147
  console.log()
125
148
 
@@ -178,30 +201,39 @@ export const createCommand = new Command('create')
178
201
  portSpinner.succeed(`Using port ${port}`)
179
202
  } else {
180
203
  const { port: foundPort, isDefault } =
181
- await portManager.findAvailablePort()
204
+ await portManager.findAvailablePort({
205
+ preferredPort: engineDefaults.defaultPort,
206
+ portRange: engineDefaults.portRange,
207
+ })
182
208
  port = foundPort
183
209
  if (isDefault) {
184
210
  portSpinner.succeed(`Using default port ${port}`)
185
211
  } else {
186
- 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
+ )
187
215
  }
188
216
  }
189
217
 
190
218
  // Ensure binaries are available
191
219
  const binarySpinner = createSpinner(
192
- `Checking PostgreSQL ${version} binaries...`,
220
+ `Checking ${dbEngine.displayName} ${version} binaries...`,
193
221
  )
194
222
  binarySpinner.start()
195
223
 
196
224
  const isInstalled = await dbEngine.isBinaryInstalled(version)
197
225
  if (isInstalled) {
198
- binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
226
+ binarySpinner.succeed(
227
+ `${dbEngine.displayName} ${version} binaries ready (cached)`,
228
+ )
199
229
  } else {
200
- binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
230
+ binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
201
231
  await dbEngine.ensureBinaries(version, ({ message }) => {
202
232
  binarySpinner.text = message
203
233
  })
204
- binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
234
+ binarySpinner.succeed(
235
+ `${dbEngine.displayName} ${version} binaries downloaded`,
236
+ )
205
237
  }
206
238
 
207
239
  // Check if container name already exists and prompt for new name if needed
@@ -217,7 +249,7 @@ export const createCommand = new Command('create')
217
249
  createSpinnerInstance.start()
218
250
 
219
251
  await containerManager.create(containerName, {
220
- engine: dbEngine.name,
252
+ engine: dbEngine.name as EngineName,
221
253
  version,
222
254
  port,
223
255
  database,
@@ -230,28 +262,68 @@ export const createCommand = new Command('create')
230
262
  initSpinner.start()
231
263
 
232
264
  await dbEngine.initDataDir(containerName, version, {
233
- superuser: defaults.superuser,
265
+ superuser: engineDefaults.superuser,
234
266
  })
235
267
 
236
268
  initSpinner.succeed('Database cluster initialized')
237
269
 
238
- // Start container if requested
239
- if (options.start !== false) {
240
- const startSpinner = createSpinner('Starting PostgreSQL...')
241
- 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
+ }
242
289
 
243
- const config = await containerManager.getConfig(containerName)
244
- if (config) {
245
- await dbEngine.start(config)
246
- await containerManager.updateConfig(containerName, {
247
- 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,
248
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 })
249
310
  }
250
311
 
251
- 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`)
252
323
 
253
- // Create the user's database (if different from 'postgres')
254
- 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) {
255
327
  const dbSpinner = createSpinner(
256
328
  `Creating database "${database}"...`,
257
329
  )
@@ -261,117 +333,118 @@ export const createCommand = new Command('create')
261
333
 
262
334
  dbSpinner.succeed(`Database "${database}" created`)
263
335
  }
336
+ }
264
337
 
265
- // Handle --from restore if specified
266
- if (restoreLocation && restoreType && config) {
267
- let backupPath = ''
268
-
269
- if (restoreType === 'connection') {
270
- // Create dump from remote database
271
- const timestamp = Date.now()
272
- tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
273
-
274
- let dumpSuccess = false
275
- let attempts = 0
276
- const maxAttempts = 2 // Allow one retry after installing deps
277
-
278
- while (!dumpSuccess && attempts < maxAttempts) {
279
- attempts++
280
- const dumpSpinner = createSpinner(
281
- 'Creating dump from remote database...',
338
+ // Handle --from restore if specified (only if started)
339
+ if (restoreLocation && restoreType && config && shouldStart) {
340
+ let backupPath = ''
341
+
342
+ if (restoreType === 'connection') {
343
+ // Create dump from remote database
344
+ const timestamp = Date.now()
345
+ tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
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++
353
+ const dumpSpinner = createSpinner(
354
+ 'Creating dump from remote database...',
355
+ )
356
+ dumpSpinner.start()
357
+
358
+ try {
359
+ await dbEngine.dumpFromConnectionString(
360
+ restoreLocation,
361
+ tempDumpPath,
282
362
  )
283
- dumpSpinner.start()
284
-
285
- try {
286
- await dbEngine.dumpFromConnectionString(
287
- restoreLocation,
288
- tempDumpPath,
289
- )
290
- dumpSpinner.succeed('Dump created from remote database')
291
- backupPath = tempDumpPath
292
- dumpSuccess = true
293
- } catch (err) {
294
- const e = err as Error
295
- dumpSpinner.fail('Failed to create dump')
296
-
297
- // Check if this is a missing tool error
298
- if (
299
- e.message.includes('pg_dump not found') ||
300
- e.message.includes('ENOENT')
301
- ) {
302
- const installed = await promptInstallDependencies('pg_dump')
303
- if (!installed) {
304
- process.exit(1)
305
- }
306
- // Loop will retry
307
- continue
363
+ dumpSpinner.succeed('Dump created from remote database')
364
+ backupPath = tempDumpPath
365
+ dumpSuccess = true
366
+ } catch (err) {
367
+ const e = err as Error
368
+ dumpSpinner.fail('Failed to create dump')
369
+
370
+ // Check if this is a missing tool error
371
+ if (
372
+ e.message.includes('pg_dump not found') ||
373
+ e.message.includes('ENOENT')
374
+ ) {
375
+ const installed = await promptInstallDependencies('pg_dump')
376
+ if (!installed) {
377
+ process.exit(1)
308
378
  }
309
-
310
- console.log()
311
- console.error(error('pg_dump error:'))
312
- console.log(chalk.gray(` ${e.message}`))
313
- process.exit(1)
379
+ // Loop will retry
380
+ continue
314
381
  }
315
- }
316
382
 
317
- // Safety check - should never reach here without backupPath set
318
- if (!dumpSuccess) {
319
- console.error(error('Failed to create dump after retries'))
383
+ console.log()
384
+ console.error(error('pg_dump error:'))
385
+ console.log(chalk.gray(` ${e.message}`))
320
386
  process.exit(1)
321
387
  }
322
- } else {
323
- backupPath = restoreLocation
324
388
  }
325
389
 
326
- // Detect backup format
327
- const detectSpinner = createSpinner('Detecting backup format...')
328
- 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
+ }
329
398
 
330
- const format = await dbEngine.detectBackupFormat(backupPath)
331
- detectSpinner.succeed(`Detected: ${format.description}`)
399
+ // Detect backup format
400
+ const detectSpinner = createSpinner('Detecting backup format...')
401
+ detectSpinner.start()
332
402
 
333
- // Restore backup
334
- const restoreSpinner = createSpinner('Restoring backup...')
335
- restoreSpinner.start()
403
+ const format = await dbEngine.detectBackupFormat(backupPath)
404
+ detectSpinner.succeed(`Detected: ${format.description}`)
336
405
 
337
- const result = await dbEngine.restore(config, backupPath, {
338
- database,
339
- createDatabase: false, // Already created above
340
- })
406
+ // Restore backup
407
+ const restoreSpinner = createSpinner('Restoring backup...')
408
+ restoreSpinner.start()
341
409
 
342
- if (result.code === 0 || !result.stderr) {
343
- restoreSpinner.succeed('Backup restored successfully')
344
- } else {
345
- restoreSpinner.warn('Restore completed with warnings')
346
- if (result.stderr) {
347
- console.log(chalk.yellow('\n Warnings:'))
348
- const lines = result.stderr.split('\n').slice(0, 5)
349
- lines.forEach((line) => {
350
- if (line.trim()) {
351
- console.log(chalk.gray(` ${line}`))
352
- }
353
- })
354
- if (result.stderr.split('\n').length > 5) {
355
- 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}`))
356
425
  }
426
+ })
427
+ if (result.stderr.split('\n').length > 5) {
428
+ console.log(chalk.gray(' ...'))
357
429
  }
358
430
  }
359
431
  }
360
432
  }
361
433
 
362
434
  // Show success message
363
- const config = await containerManager.getConfig(containerName)
364
- if (config) {
365
- const connectionString = dbEngine.getConnectionString(config)
435
+ const finalConfig = await containerManager.getConfig(containerName)
436
+ if (finalConfig) {
437
+ const connectionString = dbEngine.getConnectionString(finalConfig)
366
438
 
367
439
  console.log()
368
- console.log(connectionBox(containerName, connectionString, port))
440
+ console.log(connectionBox(containerName, connectionString, finalConfig.port))
369
441
  console.log()
370
- console.log(chalk.gray(' Connect with:'))
371
- console.log(chalk.cyan(` spindb connect ${containerName}`))
372
442
 
373
- // Copy connection string to clipboard
374
- 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
375
448
  try {
376
449
  const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
377
450
  const args =
@@ -397,6 +470,9 @@ export const createCommand = new Command('create')
397
470
  } catch {
398
471
  // Ignore clipboard errors
399
472
  }
473
+ } else {
474
+ console.log(chalk.gray(' Start the container:'))
475
+ console.log(chalk.cyan(` spindb start ${containerName}`))
400
476
  }
401
477
 
402
478
  console.log()
@@ -404,23 +480,28 @@ export const createCommand = new Command('create')
404
480
  } catch (err) {
405
481
  const e = err as Error
406
482
 
407
- // Check if this is a missing tool error
408
- if (
409
- e.message.includes('pg_restore not found') ||
410
- e.message.includes('psql not found') ||
411
- e.message.includes('pg_dump not found')
412
- ) {
413
- const missingTool = e.message.includes('pg_restore')
414
- ? 'pg_restore'
415
- : e.message.includes('pg_dump')
416
- ? 'pg_dump'
417
- : 'psql'
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', '')
418
501
  const installed = await promptInstallDependencies(missingTool)
419
502
  if (installed) {
420
503
  console.log(
421
- chalk.yellow(
422
- ' Please re-run your command to continue.',
423
- ),
504
+ chalk.yellow(' Please re-run your command to continue.'),
424
505
  )
425
506
  }
426
507
  process.exit(1)
@@ -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,