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,832 @@
1
+ import chalk from 'chalk'
2
+ import inquirer from 'inquirer'
3
+ import { existsSync } from 'fs'
4
+ import { containerManager } from '../../../core/container-manager'
5
+ import { getMissingDependencies } from '../../../core/dependency-manager'
6
+ import { platformService } from '../../../core/platform-service'
7
+ import { portManager } from '../../../core/port-manager'
8
+ import { processManager } from '../../../core/process-manager'
9
+ import { getEngine } from '../../../engines'
10
+ import { defaults } from '../../../config/defaults'
11
+ import { paths } from '../../../config/paths'
12
+ import { type Engine } from '../../../types'
13
+ import {
14
+ promptCreateOptions,
15
+ promptContainerName,
16
+ promptContainerSelect,
17
+ promptInstallDependencies,
18
+ promptConfirm,
19
+ } from '../../ui/prompts'
20
+ import { createSpinner } from '../../ui/spinner'
21
+ import {
22
+ header,
23
+ success,
24
+ error,
25
+ warning,
26
+ info,
27
+ connectionBox,
28
+ formatBytes,
29
+ } from '../../ui/theme'
30
+ import { getEngineIcon } from '../../constants'
31
+ import { type MenuChoice } from './shared'
32
+ import { handleOpenShell, handleCopyConnectionString } from './shell-handlers'
33
+ import { handleRunSql, handleViewLogs } from './sql-handlers'
34
+
35
+ export async function handleCreate(): Promise<void> {
36
+ console.log()
37
+ const answers = await promptCreateOptions()
38
+ let { name: containerName } = answers
39
+ const { engine, version, port, database } = answers
40
+
41
+ console.log()
42
+ console.log(header('Creating Database Container'))
43
+ console.log()
44
+
45
+ const dbEngine = getEngine(engine)
46
+
47
+ // Check for required client tools BEFORE creating anything
48
+ const depsSpinner = createSpinner('Checking required tools...')
49
+ depsSpinner.start()
50
+
51
+ let missingDeps = await getMissingDependencies(engine)
52
+ if (missingDeps.length > 0) {
53
+ depsSpinner.warn(
54
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
55
+ )
56
+
57
+ // Offer to install
58
+ const installed = await promptInstallDependencies(
59
+ missingDeps[0].binary,
60
+ engine,
61
+ )
62
+
63
+ if (!installed) {
64
+ return
65
+ }
66
+
67
+ // Verify installation worked
68
+ missingDeps = await getMissingDependencies(engine)
69
+ if (missingDeps.length > 0) {
70
+ console.log(
71
+ error(
72
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
73
+ ),
74
+ )
75
+ return
76
+ }
77
+
78
+ console.log(chalk.green(' ✓ All required tools are now available'))
79
+ console.log()
80
+ } else {
81
+ depsSpinner.succeed('Required tools available')
82
+ }
83
+
84
+ // Check if port is currently in use
85
+ const portAvailable = await portManager.isPortAvailable(port)
86
+
87
+ // Ensure binaries
88
+ const binarySpinner = createSpinner(
89
+ `Checking PostgreSQL ${version} binaries...`,
90
+ )
91
+ binarySpinner.start()
92
+
93
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
94
+ if (isInstalled) {
95
+ binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
96
+ } else {
97
+ binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
98
+ await dbEngine.ensureBinaries(version, ({ message }) => {
99
+ binarySpinner.text = message
100
+ })
101
+ binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
102
+ }
103
+
104
+ // Check if container name already exists and prompt for new name if needed
105
+ while (await containerManager.exists(containerName)) {
106
+ console.log(chalk.yellow(` Container "${containerName}" already exists.`))
107
+ containerName = await promptContainerName()
108
+ }
109
+
110
+ // Create container
111
+ const createSpinnerInstance = createSpinner('Creating container...')
112
+ createSpinnerInstance.start()
113
+
114
+ await containerManager.create(containerName, {
115
+ engine: dbEngine.name as Engine,
116
+ version,
117
+ port,
118
+ database,
119
+ })
120
+
121
+ createSpinnerInstance.succeed('Container created')
122
+
123
+ // Initialize database cluster
124
+ const initSpinner = createSpinner('Initializing database cluster...')
125
+ initSpinner.start()
126
+
127
+ await dbEngine.initDataDir(containerName, version, {
128
+ superuser: defaults.superuser,
129
+ })
130
+
131
+ initSpinner.succeed('Database cluster initialized')
132
+
133
+ // Start container (only if port is available)
134
+ if (portAvailable) {
135
+ const startSpinner = createSpinner('Starting PostgreSQL...')
136
+ startSpinner.start()
137
+
138
+ const config = await containerManager.getConfig(containerName)
139
+ if (config) {
140
+ await dbEngine.start(config)
141
+ await containerManager.updateConfig(containerName, { status: 'running' })
142
+ }
143
+
144
+ startSpinner.succeed('PostgreSQL started')
145
+
146
+ // Create the user's database (if different from 'postgres')
147
+ if (config && database !== 'postgres') {
148
+ const dbSpinner = createSpinner(`Creating database "${database}"...`)
149
+ dbSpinner.start()
150
+
151
+ await dbEngine.createDatabase(config, database)
152
+
153
+ dbSpinner.succeed(`Database "${database}" created`)
154
+ }
155
+
156
+ // Show success
157
+ if (config) {
158
+ const connectionString = dbEngine.getConnectionString(config)
159
+ console.log()
160
+ console.log(success('Database Created'))
161
+ console.log()
162
+ console.log(chalk.gray(` Container: ${containerName}`))
163
+ console.log(chalk.gray(` Engine: ${dbEngine.name} ${version}`))
164
+ console.log(chalk.gray(` Database: ${database}`))
165
+ console.log(chalk.gray(` Port: ${port}`))
166
+ console.log()
167
+ console.log(success(`Started Running on port ${port}`))
168
+ console.log()
169
+ console.log(chalk.gray(' Connection string:'))
170
+ console.log(chalk.cyan(` ${connectionString}`))
171
+
172
+ // Copy connection string to clipboard using platform service
173
+ try {
174
+ const copied = await platformService.copyToClipboard(connectionString)
175
+ if (copied) {
176
+ console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
177
+ } else {
178
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
179
+ }
180
+ } catch {
181
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
182
+ }
183
+
184
+ console.log()
185
+
186
+ // Wait for user to see the result before returning to menu
187
+ await inquirer.prompt([
188
+ {
189
+ type: 'input',
190
+ name: 'continue',
191
+ message: chalk.gray('Press Enter to return to the main menu...'),
192
+ },
193
+ ])
194
+ }
195
+ } else {
196
+ console.log()
197
+ console.log(
198
+ warning(
199
+ `Port ${port} is currently in use. Container created but not started.`,
200
+ ),
201
+ )
202
+ console.log(
203
+ info(
204
+ `Start it later with: ${chalk.cyan(`spindb start ${containerName}`)}`,
205
+ ),
206
+ )
207
+ }
208
+ }
209
+
210
+ export async function handleList(
211
+ showMainMenu: () => Promise<void>,
212
+ ): Promise<void> {
213
+ console.clear()
214
+ console.log(header('Containers'))
215
+ console.log()
216
+ const containers = await containerManager.list()
217
+
218
+ if (containers.length === 0) {
219
+ console.log(
220
+ info('No containers found. Create one with the "Create" option.'),
221
+ )
222
+ console.log()
223
+
224
+ await inquirer.prompt([
225
+ {
226
+ type: 'input',
227
+ name: 'continue',
228
+ message: chalk.gray('Press Enter to return to the main menu...'),
229
+ },
230
+ ])
231
+ return
232
+ }
233
+
234
+ // Fetch sizes for running containers in parallel
235
+ const sizes = await Promise.all(
236
+ containers.map(async (container) => {
237
+ if (container.status !== 'running') return null
238
+ try {
239
+ const engine = getEngine(container.engine)
240
+ return await engine.getDatabaseSize(container)
241
+ } catch {
242
+ return null
243
+ }
244
+ }),
245
+ )
246
+
247
+ // Table header
248
+ console.log()
249
+ console.log(
250
+ chalk.gray(' ') +
251
+ chalk.bold.white('NAME'.padEnd(20)) +
252
+ chalk.bold.white('ENGINE'.padEnd(12)) +
253
+ chalk.bold.white('VERSION'.padEnd(10)) +
254
+ chalk.bold.white('PORT'.padEnd(8)) +
255
+ chalk.bold.white('SIZE'.padEnd(10)) +
256
+ chalk.bold.white('STATUS'),
257
+ )
258
+ console.log(chalk.gray(' ' + '─'.repeat(70)))
259
+
260
+ // Table rows
261
+ for (let i = 0; i < containers.length; i++) {
262
+ const container = containers[i]
263
+ const size = sizes[i]
264
+
265
+ const statusDisplay =
266
+ container.status === 'running'
267
+ ? chalk.green('● running')
268
+ : chalk.gray('○ stopped')
269
+
270
+ const sizeDisplay = size !== null ? formatBytes(size) : chalk.gray('—')
271
+
272
+ console.log(
273
+ chalk.gray(' ') +
274
+ chalk.cyan(container.name.padEnd(20)) +
275
+ chalk.white(container.engine.padEnd(12)) +
276
+ chalk.yellow(container.version.padEnd(10)) +
277
+ chalk.green(String(container.port).padEnd(8)) +
278
+ chalk.magenta(sizeDisplay.padEnd(10)) +
279
+ statusDisplay,
280
+ )
281
+ }
282
+
283
+ console.log()
284
+
285
+ const running = containers.filter((c) => c.status === 'running').length
286
+ const stopped = containers.filter((c) => c.status !== 'running').length
287
+ console.log(
288
+ chalk.gray(
289
+ ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
290
+ ),
291
+ )
292
+
293
+ // Container selection with submenu
294
+ console.log()
295
+ const containerChoices = [
296
+ ...containers.map((c, i) => {
297
+ const size = sizes[i]
298
+ const sizeLabel = size !== null ? `, ${formatBytes(size)}` : ''
299
+ return {
300
+ name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine} ${c.version}, port ${c.port}${sizeLabel})`)} ${
301
+ c.status === 'running'
302
+ ? chalk.green('● running')
303
+ : chalk.gray('○ stopped')
304
+ }`,
305
+ value: c.name,
306
+ short: c.name,
307
+ }
308
+ }),
309
+ new inquirer.Separator(),
310
+ { name: `${chalk.blue('←')} Back to main menu`, value: 'back' },
311
+ ]
312
+
313
+ const { selectedContainer } = await inquirer.prompt<{
314
+ selectedContainer: string
315
+ }>([
316
+ {
317
+ type: 'list',
318
+ name: 'selectedContainer',
319
+ message: 'Select a container for more options:',
320
+ choices: containerChoices,
321
+ pageSize: 15,
322
+ },
323
+ ])
324
+
325
+ if (selectedContainer === 'back') {
326
+ await showMainMenu()
327
+ return
328
+ }
329
+
330
+ await showContainerSubmenu(selectedContainer, showMainMenu)
331
+ }
332
+
333
+ export async function showContainerSubmenu(
334
+ containerName: string,
335
+ showMainMenu: () => Promise<void>,
336
+ ): Promise<void> {
337
+ const config = await containerManager.getConfig(containerName)
338
+ if (!config) {
339
+ console.error(error(`Container "${containerName}" not found`))
340
+ return
341
+ }
342
+
343
+ // Check actual running state
344
+ const isRunning = await processManager.isRunning(containerName, {
345
+ engine: config.engine,
346
+ })
347
+ const status = isRunning ? 'running' : 'stopped'
348
+
349
+ console.clear()
350
+ console.log(header(containerName))
351
+ console.log()
352
+ console.log(
353
+ chalk.gray(
354
+ ` ${config.engine} ${config.version} on port ${config.port} - ${status}`,
355
+ ),
356
+ )
357
+ console.log()
358
+
359
+ const actionChoices: MenuChoice[] = [
360
+ // Start or Stop depending on current state
361
+ !isRunning
362
+ ? { name: `${chalk.green('▶')} Start container`, value: 'start' }
363
+ : { name: `${chalk.red('■')} Stop container`, value: 'stop' },
364
+ {
365
+ name: isRunning
366
+ ? `${chalk.blue('⌘')} Open shell`
367
+ : chalk.gray('⌘ Open shell'),
368
+ value: 'shell',
369
+ disabled: isRunning ? false : 'Start container first',
370
+ },
371
+ {
372
+ name: isRunning
373
+ ? `${chalk.yellow('▷')} Run SQL file`
374
+ : chalk.gray('▷ Run SQL file'),
375
+ value: 'run-sql',
376
+ disabled: isRunning ? false : 'Start container first',
377
+ },
378
+ {
379
+ name: `${chalk.gray('📋')} View logs`,
380
+ value: 'logs',
381
+ },
382
+ {
383
+ name: !isRunning
384
+ ? `${chalk.white('⚙')} Edit container`
385
+ : chalk.gray('⚙ Edit container'),
386
+ value: 'edit',
387
+ disabled: !isRunning ? false : 'Stop container first',
388
+ },
389
+ {
390
+ name: !isRunning
391
+ ? `${chalk.cyan('⧉')} Clone container`
392
+ : chalk.gray('⧉ Clone container'),
393
+ value: 'clone',
394
+ disabled: !isRunning ? false : 'Stop container first',
395
+ },
396
+ { name: `${chalk.magenta('⎘')} Copy connection string`, value: 'copy' },
397
+ {
398
+ name: !isRunning
399
+ ? `${chalk.red('✕')} Delete container`
400
+ : chalk.gray('✕ Delete container'),
401
+ value: 'delete',
402
+ disabled: !isRunning ? false : 'Stop container first',
403
+ },
404
+ new inquirer.Separator(),
405
+ { name: `${chalk.blue('←')} Back to containers`, value: 'back' },
406
+ { name: `${chalk.blue('⌂')} Back to main menu`, value: 'main' },
407
+ ]
408
+
409
+ const { action } = await inquirer.prompt<{ action: string }>([
410
+ {
411
+ type: 'list',
412
+ name: 'action',
413
+ message: 'What would you like to do?',
414
+ choices: actionChoices,
415
+ pageSize: 15,
416
+ },
417
+ ])
418
+
419
+ switch (action) {
420
+ case 'start':
421
+ await handleStartContainer(containerName)
422
+ await showContainerSubmenu(containerName, showMainMenu)
423
+ return
424
+ case 'stop':
425
+ await handleStopContainer(containerName)
426
+ await showContainerSubmenu(containerName, showMainMenu)
427
+ return
428
+ case 'shell':
429
+ await handleOpenShell(containerName)
430
+ await showContainerSubmenu(containerName, showMainMenu)
431
+ return
432
+ case 'run-sql':
433
+ await handleRunSql(containerName)
434
+ await showContainerSubmenu(containerName, showMainMenu)
435
+ return
436
+ case 'logs':
437
+ await handleViewLogs(containerName)
438
+ await showContainerSubmenu(containerName, showMainMenu)
439
+ return
440
+ case 'edit': {
441
+ const newName = await handleEditContainer(containerName)
442
+ if (newName === null) {
443
+ // User chose to go back to main menu
444
+ return
445
+ }
446
+ if (newName !== containerName) {
447
+ // Container was renamed, show submenu with new name
448
+ await showContainerSubmenu(newName, showMainMenu)
449
+ } else {
450
+ await showContainerSubmenu(containerName, showMainMenu)
451
+ }
452
+ return
453
+ }
454
+ case 'clone':
455
+ await handleCloneFromSubmenu(containerName, showMainMenu)
456
+ return
457
+ case 'copy':
458
+ await handleCopyConnectionString(containerName)
459
+ await showContainerSubmenu(containerName, showMainMenu)
460
+ return
461
+ case 'delete':
462
+ await handleDelete(containerName)
463
+ return // Don't show submenu again after delete
464
+ case 'back':
465
+ await handleList(showMainMenu)
466
+ return
467
+ case 'main':
468
+ return // Return to main menu
469
+ }
470
+ }
471
+
472
+ export async function handleStart(): Promise<void> {
473
+ const containers = await containerManager.list()
474
+ const stopped = containers.filter((c) => c.status !== 'running')
475
+
476
+ if (stopped.length === 0) {
477
+ console.log(warning('All containers are already running'))
478
+ return
479
+ }
480
+
481
+ const containerName = await promptContainerSelect(
482
+ stopped,
483
+ 'Select container to start:',
484
+ )
485
+ if (!containerName) return
486
+
487
+ const config = await containerManager.getConfig(containerName)
488
+ if (!config) {
489
+ console.error(error(`Container "${containerName}" not found`))
490
+ return
491
+ }
492
+
493
+ // Check port availability
494
+ const portAvailable = await portManager.isPortAvailable(config.port)
495
+ if (!portAvailable) {
496
+ const { port: newPort } = await portManager.findAvailablePort()
497
+ console.log(
498
+ warning(`Port ${config.port} is in use, switching to port ${newPort}`),
499
+ )
500
+ config.port = newPort
501
+ await containerManager.updateConfig(containerName, { port: newPort })
502
+ }
503
+
504
+ const engine = getEngine(config.engine)
505
+
506
+ const spinner = createSpinner(`Starting ${containerName}...`)
507
+ spinner.start()
508
+
509
+ await engine.start(config)
510
+ await containerManager.updateConfig(containerName, { status: 'running' })
511
+
512
+ spinner.succeed(`Container "${containerName}" started`)
513
+
514
+ const connectionString = engine.getConnectionString(config)
515
+ console.log()
516
+ console.log(chalk.gray(' Connection string:'))
517
+ console.log(chalk.cyan(` ${connectionString}`))
518
+ }
519
+
520
+ export async function handleStop(): Promise<void> {
521
+ const containers = await containerManager.list()
522
+ const running = containers.filter((c) => c.status === 'running')
523
+
524
+ if (running.length === 0) {
525
+ console.log(warning('No running containers'))
526
+ return
527
+ }
528
+
529
+ const containerName = await promptContainerSelect(
530
+ running,
531
+ 'Select container to stop:',
532
+ )
533
+ if (!containerName) return
534
+
535
+ const config = await containerManager.getConfig(containerName)
536
+ if (!config) {
537
+ console.error(error(`Container "${containerName}" not found`))
538
+ return
539
+ }
540
+
541
+ const engine = getEngine(config.engine)
542
+
543
+ const spinner = createSpinner(`Stopping ${containerName}...`)
544
+ spinner.start()
545
+
546
+ await engine.stop(config)
547
+ await containerManager.updateConfig(containerName, { status: 'stopped' })
548
+
549
+ spinner.succeed(`Container "${containerName}" stopped`)
550
+ }
551
+
552
+ async function handleStartContainer(containerName: string): Promise<void> {
553
+ const config = await containerManager.getConfig(containerName)
554
+ if (!config) {
555
+ console.error(error(`Container "${containerName}" not found`))
556
+ return
557
+ }
558
+
559
+ // Check port availability
560
+ const portAvailable = await portManager.isPortAvailable(config.port)
561
+ if (!portAvailable) {
562
+ console.log(
563
+ warning(
564
+ `Port ${config.port} is in use. Stop the process using it or change this container's port.`,
565
+ ),
566
+ )
567
+ console.log()
568
+ console.log(
569
+ info(
570
+ 'Tip: If you installed MariaDB via apt, it may have started a system service.',
571
+ ),
572
+ )
573
+ console.log(
574
+ info(
575
+ 'Run: sudo systemctl stop mariadb && sudo systemctl disable mariadb',
576
+ ),
577
+ )
578
+ return
579
+ }
580
+
581
+ const engine = getEngine(config.engine)
582
+
583
+ const spinner = createSpinner(`Starting ${containerName}...`)
584
+ spinner.start()
585
+
586
+ try {
587
+ await engine.start(config)
588
+ await containerManager.updateConfig(containerName, { status: 'running' })
589
+
590
+ spinner.succeed(`Container "${containerName}" started`)
591
+
592
+ const connectionString = engine.getConnectionString(config)
593
+ console.log()
594
+ console.log(chalk.gray(' Connection string:'))
595
+ console.log(chalk.cyan(` ${connectionString}`))
596
+ } catch (err) {
597
+ spinner.fail(`Failed to start "${containerName}"`)
598
+ const e = err as Error
599
+ console.log()
600
+ console.log(error(e.message))
601
+
602
+ // Check if there's a log file with more details
603
+ const logPath = paths.getContainerLogPath(containerName, {
604
+ engine: config.engine,
605
+ })
606
+ if (existsSync(logPath)) {
607
+ console.log()
608
+ console.log(info(`Check the log file for details: ${logPath}`))
609
+ }
610
+ }
611
+ }
612
+
613
+ async function handleStopContainer(containerName: string): Promise<void> {
614
+ const config = await containerManager.getConfig(containerName)
615
+ if (!config) {
616
+ console.error(error(`Container "${containerName}" not found`))
617
+ return
618
+ }
619
+
620
+ const engine = getEngine(config.engine)
621
+
622
+ const spinner = createSpinner(`Stopping ${containerName}...`)
623
+ spinner.start()
624
+
625
+ await engine.stop(config)
626
+ await containerManager.updateConfig(containerName, { status: 'stopped' })
627
+
628
+ spinner.succeed(`Container "${containerName}" stopped`)
629
+ }
630
+
631
+ async function handleEditContainer(
632
+ containerName: string,
633
+ ): Promise<string | null> {
634
+ const config = await containerManager.getConfig(containerName)
635
+ if (!config) {
636
+ console.error(error(`Container "${containerName}" not found`))
637
+ return null
638
+ }
639
+
640
+ console.clear()
641
+ console.log(header(`Edit: ${containerName}`))
642
+ console.log()
643
+
644
+ const editChoices = [
645
+ {
646
+ name: `Name: ${chalk.white(containerName)}`,
647
+ value: 'name',
648
+ },
649
+ {
650
+ name: `Port: ${chalk.white(String(config.port))}`,
651
+ value: 'port',
652
+ },
653
+ new inquirer.Separator(),
654
+ { name: `${chalk.blue('←')} Back to container`, value: 'back' },
655
+ { name: `${chalk.blue('⌂')} Back to main menu`, value: 'main' },
656
+ ]
657
+
658
+ const { field } = await inquirer.prompt<{ field: string }>([
659
+ {
660
+ type: 'list',
661
+ name: 'field',
662
+ message: 'Select field to edit:',
663
+ choices: editChoices,
664
+ pageSize: 10,
665
+ },
666
+ ])
667
+
668
+ if (field === 'back') {
669
+ return containerName
670
+ }
671
+
672
+ if (field === 'main') {
673
+ return null // Signal to go back to main menu
674
+ }
675
+
676
+ if (field === 'name') {
677
+ const { newName } = await inquirer.prompt<{ newName: string }>([
678
+ {
679
+ type: 'input',
680
+ name: 'newName',
681
+ message: 'New name:',
682
+ default: containerName,
683
+ validate: (input: string) => {
684
+ if (!input) return 'Name is required'
685
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
686
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
687
+ }
688
+ return true
689
+ },
690
+ },
691
+ ])
692
+
693
+ if (newName === containerName) {
694
+ console.log(info('Name unchanged'))
695
+ return await handleEditContainer(containerName)
696
+ }
697
+
698
+ // Check if new name already exists
699
+ if (await containerManager.exists(newName)) {
700
+ console.log(error(`Container "${newName}" already exists`))
701
+ return await handleEditContainer(containerName)
702
+ }
703
+
704
+ const spinner = createSpinner('Renaming container...')
705
+ spinner.start()
706
+
707
+ await containerManager.rename(containerName, newName)
708
+
709
+ spinner.succeed(`Renamed "${containerName}" to "${newName}"`)
710
+
711
+ // Continue editing with new name
712
+ return await handleEditContainer(newName)
713
+ }
714
+
715
+ if (field === 'port') {
716
+ const { newPort } = await inquirer.prompt<{ newPort: number }>([
717
+ {
718
+ type: 'input',
719
+ name: 'newPort',
720
+ message: 'New port:',
721
+ default: String(config.port),
722
+ validate: (input: string) => {
723
+ const num = parseInt(input, 10)
724
+ if (isNaN(num) || num < 1 || num > 65535) {
725
+ return 'Port must be a number between 1 and 65535'
726
+ }
727
+ return true
728
+ },
729
+ filter: (input: string) => parseInt(input, 10),
730
+ },
731
+ ])
732
+
733
+ if (newPort === config.port) {
734
+ console.log(info('Port unchanged'))
735
+ return await handleEditContainer(containerName)
736
+ }
737
+
738
+ // Check if port is in use
739
+ const portAvailable = await portManager.isPortAvailable(newPort)
740
+ if (!portAvailable) {
741
+ console.log(
742
+ warning(
743
+ `Port ${newPort} is currently in use. You'll need to stop the process using it before starting this container.`,
744
+ ),
745
+ )
746
+ }
747
+
748
+ await containerManager.updateConfig(containerName, { port: newPort })
749
+ console.log(success(`Changed port from ${config.port} to ${newPort}`))
750
+
751
+ // Continue editing
752
+ return await handleEditContainer(containerName)
753
+ }
754
+
755
+ return containerName
756
+ }
757
+
758
+ async function handleCloneFromSubmenu(
759
+ sourceName: string,
760
+ showMainMenu: () => Promise<void>,
761
+ ): Promise<void> {
762
+ const { targetName } = await inquirer.prompt<{ targetName: string }>([
763
+ {
764
+ type: 'input',
765
+ name: 'targetName',
766
+ message: 'Name for the cloned container:',
767
+ default: `${sourceName}-copy`,
768
+ validate: (input: string) => {
769
+ if (!input) return 'Name is required'
770
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
771
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
772
+ }
773
+ return true
774
+ },
775
+ },
776
+ ])
777
+
778
+ const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
779
+ spinner.start()
780
+
781
+ const newConfig = await containerManager.clone(sourceName, targetName)
782
+
783
+ spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
784
+
785
+ const engine = getEngine(newConfig.engine)
786
+ const connectionString = engine.getConnectionString(newConfig)
787
+
788
+ console.log()
789
+ console.log(connectionBox(targetName, connectionString, newConfig.port))
790
+
791
+ // Go to the new container's submenu
792
+ await showContainerSubmenu(targetName, showMainMenu)
793
+ }
794
+
795
+ async function handleDelete(containerName: string): Promise<void> {
796
+ const config = await containerManager.getConfig(containerName)
797
+ if (!config) {
798
+ console.error(error(`Container "${containerName}" not found`))
799
+ return
800
+ }
801
+
802
+ const confirmed = await promptConfirm(
803
+ `Are you sure you want to delete "${containerName}"? This cannot be undone.`,
804
+ false,
805
+ )
806
+
807
+ if (!confirmed) {
808
+ console.log(warning('Deletion cancelled'))
809
+ return
810
+ }
811
+
812
+ const isRunning = await processManager.isRunning(containerName, {
813
+ engine: config.engine,
814
+ })
815
+
816
+ if (isRunning) {
817
+ const stopSpinner = createSpinner(`Stopping ${containerName}...`)
818
+ stopSpinner.start()
819
+
820
+ const engine = getEngine(config.engine)
821
+ await engine.stop(config)
822
+
823
+ stopSpinner.succeed(`Stopped "${containerName}"`)
824
+ }
825
+
826
+ const deleteSpinner = createSpinner(`Deleting ${containerName}...`)
827
+ deleteSpinner.start()
828
+
829
+ await containerManager.delete(containerName, { force: true })
830
+
831
+ deleteSpinner.succeed(`Container "${containerName}" deleted`)
832
+ }