spindb 0.7.0 → 0.7.3

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