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