spindb 0.7.0 → 0.7.5

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.
Files changed (40) hide show
  1. package/README.md +421 -294
  2. package/cli/commands/backup.ts +1 -30
  3. package/cli/commands/clone.ts +0 -6
  4. package/cli/commands/config.ts +7 -1
  5. package/cli/commands/connect.ts +1 -16
  6. package/cli/commands/create.ts +4 -55
  7. package/cli/commands/delete.ts +0 -6
  8. package/cli/commands/edit.ts +9 -25
  9. package/cli/commands/engines.ts +10 -188
  10. package/cli/commands/info.ts +7 -34
  11. package/cli/commands/list.ts +2 -18
  12. package/cli/commands/logs.ts +118 -0
  13. package/cli/commands/menu/backup-handlers.ts +749 -0
  14. package/cli/commands/menu/container-handlers.ts +825 -0
  15. package/cli/commands/menu/engine-handlers.ts +362 -0
  16. package/cli/commands/menu/index.ts +179 -0
  17. package/cli/commands/menu/shared.ts +26 -0
  18. package/cli/commands/menu/shell-handlers.ts +320 -0
  19. package/cli/commands/menu/sql-handlers.ts +194 -0
  20. package/cli/commands/menu/update-handlers.ts +94 -0
  21. package/cli/commands/restore.ts +2 -28
  22. package/cli/commands/run.ts +139 -0
  23. package/cli/commands/start.ts +2 -10
  24. package/cli/commands/stop.ts +0 -5
  25. package/cli/commands/url.ts +18 -13
  26. package/cli/constants.ts +10 -0
  27. package/cli/helpers.ts +152 -0
  28. package/cli/index.ts +5 -2
  29. package/cli/ui/prompts.ts +3 -11
  30. package/core/dependency-manager.ts +0 -163
  31. package/core/error-handler.ts +0 -26
  32. package/core/platform-service.ts +60 -40
  33. package/core/start-with-retry.ts +3 -28
  34. package/core/transaction-manager.ts +0 -8
  35. package/engines/base-engine.ts +10 -0
  36. package/engines/mysql/binary-detection.ts +1 -1
  37. package/engines/mysql/index.ts +78 -2
  38. package/engines/postgresql/index.ts +49 -0
  39. package/package.json +1 -1
  40. package/cli/commands/menu.ts +0 -2670
@@ -0,0 +1,749 @@
1
+ import chalk from 'chalk'
2
+ import inquirer from 'inquirer'
3
+ import { existsSync } from 'fs'
4
+ import { rm } from 'fs/promises'
5
+ import { join } from 'path'
6
+ import { tmpdir } from 'os'
7
+ import { containerManager } from '../../../core/container-manager'
8
+ import { getMissingDependencies } from '../../../core/dependency-manager'
9
+ import { platformService } from '../../../core/platform-service'
10
+ import { portManager } from '../../../core/port-manager'
11
+ import { getEngine } from '../../../engines'
12
+ import { defaults } from '../../../config/defaults'
13
+ import { getPostgresHomebrewPackage } from '../../../config/engine-defaults'
14
+ import { updatePostgresClientTools } from '../../../engines/postgresql/binary-manager'
15
+ import {
16
+ promptCreateOptions,
17
+ promptContainerName,
18
+ promptContainerSelect,
19
+ promptDatabaseName,
20
+ promptDatabaseSelect,
21
+ promptBackupFormat,
22
+ promptBackupFilename,
23
+ promptInstallDependencies,
24
+ } from '../../ui/prompts'
25
+ import { createSpinner } from '../../ui/spinner'
26
+ import {
27
+ header,
28
+ success,
29
+ error,
30
+ warning,
31
+ connectionBox,
32
+ formatBytes,
33
+ } from '../../ui/theme'
34
+ import { getEngineIcon } from '../../constants'
35
+ import { type Engine } from '../../../types'
36
+
37
+ function generateBackupTimestamp(): string {
38
+ const now = new Date()
39
+ return now.toISOString().replace(/:/g, '').split('.')[0]
40
+ }
41
+
42
+ function getBackupExtension(format: 'sql' | 'dump', engine: string): string {
43
+ if (format === 'sql') {
44
+ return '.sql'
45
+ }
46
+ // MySQL dump is gzipped SQL, PostgreSQL dump is custom format
47
+ return engine === 'mysql' ? '.sql.gz' : '.dump'
48
+ }
49
+
50
+ export async function handleCreateForRestore(): Promise<{
51
+ name: string
52
+ config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>
53
+ } | null> {
54
+ console.log()
55
+ const answers = await promptCreateOptions()
56
+ let { name: containerName } = answers
57
+ const { engine, version, port, database } = answers
58
+
59
+ console.log()
60
+ console.log(header('Creating Database Container'))
61
+ console.log()
62
+
63
+ const dbEngine = getEngine(engine)
64
+
65
+ const portAvailable = await portManager.isPortAvailable(port)
66
+ if (!portAvailable) {
67
+ console.log(
68
+ error(`Port ${port} is in use. Please choose a different port.`),
69
+ )
70
+ return null
71
+ }
72
+
73
+ const binarySpinner = createSpinner(
74
+ `Checking PostgreSQL ${version} binaries...`,
75
+ )
76
+ binarySpinner.start()
77
+
78
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
79
+ if (isInstalled) {
80
+ binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
81
+ } else {
82
+ binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
83
+ await dbEngine.ensureBinaries(version, ({ message }) => {
84
+ binarySpinner.text = message
85
+ })
86
+ binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
87
+ }
88
+
89
+ while (await containerManager.exists(containerName)) {
90
+ console.log(chalk.yellow(` Container "${containerName}" already exists.`))
91
+ containerName = await promptContainerName()
92
+ }
93
+
94
+ const createSpinnerInstance = createSpinner('Creating container...')
95
+ createSpinnerInstance.start()
96
+
97
+ await containerManager.create(containerName, {
98
+ engine: dbEngine.name as Engine,
99
+ version,
100
+ port,
101
+ database,
102
+ })
103
+
104
+ createSpinnerInstance.succeed('Container created')
105
+
106
+ const initSpinner = createSpinner('Initializing database cluster...')
107
+ initSpinner.start()
108
+
109
+ await dbEngine.initDataDir(containerName, version, {
110
+ superuser: defaults.superuser,
111
+ })
112
+
113
+ initSpinner.succeed('Database cluster initialized')
114
+
115
+ const startSpinner = createSpinner('Starting PostgreSQL...')
116
+ startSpinner.start()
117
+
118
+ const config = await containerManager.getConfig(containerName)
119
+ if (!config) {
120
+ startSpinner.fail('Failed to get container config')
121
+ return null
122
+ }
123
+
124
+ await dbEngine.start(config)
125
+ await containerManager.updateConfig(containerName, { status: 'running' })
126
+
127
+ startSpinner.succeed('PostgreSQL started')
128
+
129
+ if (database !== 'postgres') {
130
+ const dbSpinner = createSpinner(`Creating database "${database}"...`)
131
+ dbSpinner.start()
132
+
133
+ await dbEngine.createDatabase(config, database)
134
+
135
+ dbSpinner.succeed(`Database "${database}" created`)
136
+ }
137
+
138
+ console.log()
139
+ console.log(success('Container ready for restore'))
140
+ console.log()
141
+
142
+ return { name: containerName, config }
143
+ }
144
+
145
+ export async function handleRestore(): Promise<void> {
146
+ const containers = await containerManager.list()
147
+ const running = containers.filter((c) => c.status === 'running')
148
+
149
+ const choices = [
150
+ ...running.map((c) => ({
151
+ name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
152
+ value: c.name,
153
+ short: c.name,
154
+ })),
155
+ new inquirer.Separator(),
156
+ {
157
+ name: `${chalk.green('➕')} Create new container`,
158
+ value: '__create_new__',
159
+ short: 'Create new',
160
+ },
161
+ ]
162
+
163
+ const { selectedContainer } = await inquirer.prompt<{
164
+ selectedContainer: string
165
+ }>([
166
+ {
167
+ type: 'list',
168
+ name: 'selectedContainer',
169
+ message: 'Select container to restore to:',
170
+ choices,
171
+ pageSize: 15,
172
+ },
173
+ ])
174
+
175
+ let containerName: string
176
+ let config: Awaited<ReturnType<typeof containerManager.getConfig>>
177
+
178
+ if (selectedContainer === '__create_new__') {
179
+ const createResult = await handleCreateForRestore()
180
+ if (!createResult) return
181
+ containerName = createResult.name
182
+ config = createResult.config
183
+ } else {
184
+ containerName = selectedContainer
185
+ config = await containerManager.getConfig(containerName)
186
+ if (!config) {
187
+ console.error(error(`Container "${containerName}" not found`))
188
+ return
189
+ }
190
+ }
191
+
192
+ const depsSpinner = createSpinner('Checking required tools...')
193
+ depsSpinner.start()
194
+
195
+ let missingDeps = await getMissingDependencies(config.engine)
196
+ if (missingDeps.length > 0) {
197
+ depsSpinner.warn(
198
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
199
+ )
200
+
201
+ const installed = await promptInstallDependencies(
202
+ missingDeps[0].binary,
203
+ config.engine,
204
+ )
205
+
206
+ if (!installed) {
207
+ return
208
+ }
209
+
210
+ missingDeps = await getMissingDependencies(config.engine)
211
+ if (missingDeps.length > 0) {
212
+ console.log(
213
+ error(
214
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
215
+ ),
216
+ )
217
+ return
218
+ }
219
+
220
+ console.log(chalk.green(' ✓ All required tools are now available'))
221
+ console.log()
222
+ } else {
223
+ depsSpinner.succeed('Required tools available')
224
+ }
225
+
226
+ const { restoreSource } = await inquirer.prompt<{
227
+ restoreSource: 'file' | 'connection'
228
+ }>([
229
+ {
230
+ type: 'list',
231
+ name: 'restoreSource',
232
+ message: 'Restore from:',
233
+ choices: [
234
+ {
235
+ name: `${chalk.magenta('📁')} Dump file (drag and drop or enter path)`,
236
+ value: 'file',
237
+ },
238
+ {
239
+ name: `${chalk.cyan('🔗')} Connection string (pull from remote database)`,
240
+ value: 'connection',
241
+ },
242
+ ],
243
+ },
244
+ ])
245
+
246
+ let backupPath = ''
247
+ let isTempFile = false
248
+
249
+ if (restoreSource === 'connection') {
250
+ console.log(
251
+ chalk.gray(' Enter connection string, or press Enter to go back'),
252
+ )
253
+ const { connectionString } = await inquirer.prompt<{
254
+ connectionString: string
255
+ }>([
256
+ {
257
+ type: 'input',
258
+ name: 'connectionString',
259
+ message: 'Connection string:',
260
+ validate: (input: string) => {
261
+ if (!input) return true
262
+ if (
263
+ !input.startsWith('postgresql://') &&
264
+ !input.startsWith('postgres://')
265
+ ) {
266
+ return 'Connection string must start with postgresql:// or postgres://'
267
+ }
268
+ return true
269
+ },
270
+ },
271
+ ])
272
+
273
+ if (!connectionString.trim()) {
274
+ return
275
+ }
276
+
277
+ const engine = getEngine(config.engine)
278
+
279
+ const timestamp = Date.now()
280
+ const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
281
+
282
+ let dumpSuccess = false
283
+ let attempts = 0
284
+ const maxAttempts = 2
285
+
286
+ while (!dumpSuccess && attempts < maxAttempts) {
287
+ attempts++
288
+ const dumpSpinner = createSpinner('Creating dump from remote database...')
289
+ dumpSpinner.start()
290
+
291
+ try {
292
+ await engine.dumpFromConnectionString(connectionString, tempDumpPath)
293
+ dumpSpinner.succeed('Dump created from remote database')
294
+ backupPath = tempDumpPath
295
+ isTempFile = true
296
+ dumpSuccess = true
297
+ } catch (err) {
298
+ const e = err as Error
299
+ dumpSpinner.fail('Failed to create dump')
300
+
301
+ if (
302
+ e.message.includes('pg_dump not found') ||
303
+ e.message.includes('ENOENT')
304
+ ) {
305
+ const installed = await promptInstallDependencies('pg_dump')
306
+ if (installed) {
307
+ continue
308
+ }
309
+ } else {
310
+ console.log()
311
+ console.log(error('pg_dump error:'))
312
+ console.log(chalk.gray(` ${e.message}`))
313
+ console.log()
314
+ }
315
+
316
+ try {
317
+ await rm(tempDumpPath, { force: true })
318
+ } catch {
319
+ // Ignore cleanup errors
320
+ }
321
+
322
+ await inquirer.prompt([
323
+ {
324
+ type: 'input',
325
+ name: 'continue',
326
+ message: chalk.gray('Press Enter to continue...'),
327
+ },
328
+ ])
329
+ return
330
+ }
331
+ }
332
+
333
+ if (!dumpSuccess) {
334
+ console.log(error('Failed to create dump after retries'))
335
+ return
336
+ }
337
+ } else {
338
+ const stripQuotes = (path: string) =>
339
+ path.replace(/^['"]|['"]$/g, '').trim()
340
+
341
+ console.log(
342
+ chalk.gray(
343
+ ' Drag & drop, enter path (abs or rel), or press Enter to go back',
344
+ ),
345
+ )
346
+ const { backupPath: rawBackupPath } = await inquirer.prompt<{
347
+ backupPath: string
348
+ }>([
349
+ {
350
+ type: 'input',
351
+ name: 'backupPath',
352
+ message: 'Backup file path:',
353
+ validate: (input: string) => {
354
+ if (!input) return true
355
+ const cleanPath = stripQuotes(input)
356
+ if (!existsSync(cleanPath)) return 'File not found'
357
+ return true
358
+ },
359
+ },
360
+ ])
361
+
362
+ if (!rawBackupPath.trim()) {
363
+ return
364
+ }
365
+
366
+ backupPath = stripQuotes(rawBackupPath)
367
+ }
368
+
369
+ const databaseName = await promptDatabaseName(containerName, config.engine)
370
+
371
+ const engine = getEngine(config.engine)
372
+
373
+ const detectSpinner = createSpinner('Detecting backup format...')
374
+ detectSpinner.start()
375
+
376
+ const format = await engine.detectBackupFormat(backupPath)
377
+ detectSpinner.succeed(`Detected: ${format.description}`)
378
+
379
+ const dbSpinner = createSpinner(`Creating database "${databaseName}"...`)
380
+ dbSpinner.start()
381
+
382
+ await engine.createDatabase(config, databaseName)
383
+ dbSpinner.succeed(`Database "${databaseName}" ready`)
384
+
385
+ const restoreSpinner = createSpinner('Restoring backup...')
386
+ restoreSpinner.start()
387
+
388
+ const result = await engine.restore(config, backupPath, {
389
+ database: databaseName,
390
+ createDatabase: false,
391
+ })
392
+
393
+ if (result.code === 0 || !result.stderr) {
394
+ restoreSpinner.succeed('Backup restored successfully')
395
+ } else {
396
+ const stderr = result.stderr || ''
397
+
398
+ if (
399
+ stderr.includes('unsupported version') ||
400
+ stderr.includes('Archive version') ||
401
+ stderr.includes('too old')
402
+ ) {
403
+ restoreSpinner.fail('Version compatibility detected')
404
+ console.log()
405
+ console.log(error('PostgreSQL version incompatibility detected:'))
406
+ console.log(
407
+ warning('Your pg_restore version is too old for this backup file.'),
408
+ )
409
+
410
+ console.log(chalk.yellow('Cleaning up failed database...'))
411
+ try {
412
+ await engine.dropDatabase(config, databaseName)
413
+ console.log(chalk.gray(`✓ Removed database "${databaseName}"`))
414
+ } catch {
415
+ console.log(
416
+ chalk.yellow(`Warning: Could not remove database "${databaseName}"`),
417
+ )
418
+ }
419
+
420
+ console.log()
421
+
422
+ const versionMatch = stderr.match(/PostgreSQL (\d+)/)
423
+ const requiredVersion = versionMatch ? versionMatch[1] : '17'
424
+
425
+ console.log(
426
+ chalk.gray(
427
+ `This backup was created with PostgreSQL ${requiredVersion}`,
428
+ ),
429
+ )
430
+ console.log()
431
+
432
+ const { shouldUpgrade } = await inquirer.prompt({
433
+ type: 'list',
434
+ name: 'shouldUpgrade',
435
+ message: `Would you like to upgrade PostgreSQL client tools to support PostgreSQL ${requiredVersion}?`,
436
+ choices: [
437
+ { name: 'Yes', value: true },
438
+ { name: 'No', value: false },
439
+ ],
440
+ default: 0,
441
+ })
442
+
443
+ if (shouldUpgrade) {
444
+ console.log()
445
+ const upgradeSpinner = createSpinner(
446
+ 'Upgrading PostgreSQL client tools...',
447
+ )
448
+ upgradeSpinner.start()
449
+
450
+ try {
451
+ const updateSuccess = await updatePostgresClientTools()
452
+
453
+ if (updateSuccess) {
454
+ upgradeSpinner.succeed('PostgreSQL client tools upgraded')
455
+ console.log()
456
+ console.log(
457
+ success('Please try the restore again with the updated tools.'),
458
+ )
459
+ await new Promise((resolve) => {
460
+ console.log(chalk.gray('Press Enter to continue...'))
461
+ process.stdin.once('data', resolve)
462
+ })
463
+ return
464
+ } else {
465
+ upgradeSpinner.fail('Upgrade failed')
466
+ console.log()
467
+ console.log(
468
+ error('Automatic upgrade failed. Please upgrade manually:'),
469
+ )
470
+ const pgPackage = getPostgresHomebrewPackage()
471
+ const latestMajor = pgPackage.split('@')[1]
472
+ console.log(
473
+ warning(
474
+ ` macOS: brew install ${pgPackage} && brew link --force ${pgPackage}`,
475
+ ),
476
+ )
477
+ console.log(
478
+ chalk.gray(
479
+ ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
480
+ ),
481
+ )
482
+ console.log(
483
+ warning(
484
+ ` Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-${latestMajor}`,
485
+ ),
486
+ )
487
+ console.log(
488
+ chalk.gray(
489
+ ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
490
+ ),
491
+ )
492
+ await new Promise((resolve) => {
493
+ console.log(chalk.gray('Press Enter to continue...'))
494
+ process.stdin.once('data', resolve)
495
+ })
496
+ return
497
+ }
498
+ } catch {
499
+ upgradeSpinner.fail('Upgrade failed')
500
+ console.log(error('Failed to upgrade PostgreSQL client tools'))
501
+ console.log(
502
+ chalk.gray(
503
+ 'Manual upgrade may be required for pg_restore, pg_dump, and psql',
504
+ ),
505
+ )
506
+ await new Promise((resolve) => {
507
+ console.log(chalk.gray('Press Enter to continue...'))
508
+ process.stdin.once('data', resolve)
509
+ })
510
+ return
511
+ }
512
+ } else {
513
+ console.log()
514
+ console.log(
515
+ warning(
516
+ 'Restore cancelled. Please upgrade PostgreSQL client tools manually and try again.',
517
+ ),
518
+ )
519
+ await new Promise((resolve) => {
520
+ console.log(chalk.gray('Press Enter to continue...'))
521
+ process.stdin.once('data', resolve)
522
+ })
523
+ return
524
+ }
525
+ } else {
526
+ restoreSpinner.warn('Restore completed with warnings')
527
+ if (result.stderr) {
528
+ console.log()
529
+ console.log(chalk.yellow(' Warnings/Errors:'))
530
+ const lines = result.stderr.split('\n').filter((l) => l.trim())
531
+ const displayLines = lines.slice(0, 20)
532
+ for (const line of displayLines) {
533
+ console.log(chalk.gray(` ${line}`))
534
+ }
535
+ if (lines.length > 20) {
536
+ console.log(chalk.gray(` ... and ${lines.length - 20} more lines`))
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ if (result.code === 0 || !result.stderr) {
543
+ const connectionString = engine.getConnectionString(config, databaseName)
544
+ console.log()
545
+ console.log(success(`Database "${databaseName}" restored`))
546
+ console.log(chalk.gray(' Connection string:'))
547
+ console.log(chalk.cyan(` ${connectionString}`))
548
+
549
+ const copied = await platformService.copyToClipboard(connectionString)
550
+ if (copied) {
551
+ console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
552
+ } else {
553
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
554
+ }
555
+
556
+ console.log()
557
+ }
558
+
559
+ if (isTempFile) {
560
+ try {
561
+ await rm(backupPath, { force: true })
562
+ } catch {
563
+ // Ignore cleanup errors
564
+ }
565
+ }
566
+
567
+ await inquirer.prompt([
568
+ {
569
+ type: 'input',
570
+ name: 'continue',
571
+ message: chalk.gray('Press Enter to continue...'),
572
+ },
573
+ ])
574
+ }
575
+
576
+ export async function handleBackup(): Promise<void> {
577
+ const containers = await containerManager.list()
578
+ const running = containers.filter((c) => c.status === 'running')
579
+
580
+ if (running.length === 0) {
581
+ console.log(warning('No running containers. Start a container first.'))
582
+ await inquirer.prompt([
583
+ {
584
+ type: 'input',
585
+ name: 'continue',
586
+ message: chalk.gray('Press Enter to continue...'),
587
+ },
588
+ ])
589
+ return
590
+ }
591
+
592
+ const containerName = await promptContainerSelect(
593
+ running,
594
+ 'Select container to backup:',
595
+ )
596
+ if (!containerName) return
597
+
598
+ const config = await containerManager.getConfig(containerName)
599
+ if (!config) {
600
+ console.log(error(`Container "${containerName}" not found`))
601
+ return
602
+ }
603
+
604
+ const engine = getEngine(config.engine)
605
+
606
+ const depsSpinner = createSpinner('Checking required tools...')
607
+ depsSpinner.start()
608
+
609
+ let missingDeps = await getMissingDependencies(config.engine)
610
+ if (missingDeps.length > 0) {
611
+ depsSpinner.warn(
612
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
613
+ )
614
+
615
+ const installed = await promptInstallDependencies(
616
+ missingDeps[0].binary,
617
+ config.engine,
618
+ )
619
+
620
+ if (!installed) {
621
+ return
622
+ }
623
+
624
+ missingDeps = await getMissingDependencies(config.engine)
625
+ if (missingDeps.length > 0) {
626
+ console.log(
627
+ error(
628
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
629
+ ),
630
+ )
631
+ return
632
+ }
633
+
634
+ console.log(chalk.green(' ✓ All required tools are now available'))
635
+ console.log()
636
+ } else {
637
+ depsSpinner.succeed('Required tools available')
638
+ }
639
+
640
+ const databases = config.databases || [config.database]
641
+ let databaseName: string
642
+
643
+ if (databases.length > 1) {
644
+ databaseName = await promptDatabaseSelect(
645
+ databases,
646
+ 'Select database to backup:',
647
+ )
648
+ } else {
649
+ databaseName = databases[0]
650
+ }
651
+
652
+ const format = await promptBackupFormat(config.engine)
653
+
654
+ const defaultFilename = `${containerName}-${databaseName}-backup-${generateBackupTimestamp()}`
655
+ const filename = await promptBackupFilename(defaultFilename)
656
+
657
+ const extension = getBackupExtension(format, config.engine)
658
+ const outputPath = join(process.cwd(), `${filename}${extension}`)
659
+
660
+ const backupSpinner = createSpinner(
661
+ `Creating ${format === 'sql' ? 'SQL' : 'dump'} backup of "${databaseName}"...`,
662
+ )
663
+ backupSpinner.start()
664
+
665
+ try {
666
+ const result = await engine.backup(config, outputPath, {
667
+ database: databaseName,
668
+ format,
669
+ })
670
+
671
+ backupSpinner.succeed('Backup created successfully')
672
+
673
+ console.log()
674
+ console.log(success('Backup complete'))
675
+ console.log()
676
+ console.log(chalk.gray(' File:'), chalk.cyan(result.path))
677
+ console.log(chalk.gray(' Size:'), chalk.white(formatBytes(result.size)))
678
+ console.log(chalk.gray(' Format:'), chalk.white(result.format))
679
+ console.log()
680
+ } catch (err) {
681
+ const e = err as Error
682
+ backupSpinner.fail('Backup failed')
683
+ console.log()
684
+ console.log(error(e.message))
685
+ console.log()
686
+ }
687
+
688
+ await inquirer.prompt([
689
+ {
690
+ type: 'input',
691
+ name: 'continue',
692
+ message: chalk.gray('Press Enter to continue...'),
693
+ },
694
+ ])
695
+ }
696
+
697
+ export async function handleClone(): Promise<void> {
698
+ const containers = await containerManager.list()
699
+ const stopped = containers.filter((c) => c.status !== 'running')
700
+
701
+ if (containers.length === 0) {
702
+ console.log(warning('No containers found'))
703
+ return
704
+ }
705
+
706
+ if (stopped.length === 0) {
707
+ console.log(
708
+ warning(
709
+ 'All containers are running. Stop a container first to clone it.',
710
+ ),
711
+ )
712
+ return
713
+ }
714
+
715
+ const sourceName = await promptContainerSelect(
716
+ stopped,
717
+ 'Select container to clone:',
718
+ )
719
+ if (!sourceName) return
720
+
721
+ const { targetName } = await inquirer.prompt<{ targetName: string }>([
722
+ {
723
+ type: 'input',
724
+ name: 'targetName',
725
+ message: 'Name for the cloned container:',
726
+ default: `${sourceName}-copy`,
727
+ validate: (input: string) => {
728
+ if (!input) return 'Name is required'
729
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
730
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
731
+ }
732
+ return true
733
+ },
734
+ },
735
+ ])
736
+
737
+ const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
738
+ spinner.start()
739
+
740
+ const newConfig = await containerManager.clone(sourceName, targetName)
741
+
742
+ spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
743
+
744
+ const engine = getEngine(newConfig.engine)
745
+ const connectionString = engine.getConnectionString(newConfig)
746
+
747
+ console.log()
748
+ console.log(connectionBox(targetName, connectionString, newConfig.port))
749
+ }