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.
@@ -1,2670 +0,0 @@
1
- import { Command } from 'commander'
2
- import chalk from 'chalk'
3
- import { containerManager } from '../../core/container-manager'
4
- import { processManager } from '../../core/process-manager'
5
- import { getEngine } from '../../engines'
6
- import {
7
- promptContainerSelect,
8
- promptContainerName,
9
- promptDatabaseName,
10
- promptDatabaseSelect,
11
- promptBackupFormat,
12
- promptBackupFilename,
13
- promptCreateOptions,
14
- promptConfirm,
15
- promptInstallDependencies,
16
- } from '../ui/prompts'
17
- import { createSpinner } from '../ui/spinner'
18
- import {
19
- header,
20
- success,
21
- error,
22
- warning,
23
- info,
24
- connectionBox,
25
- formatBytes,
26
- } from '../ui/theme'
27
- import { existsSync } from 'fs'
28
- import { readdir, rm, lstat } from 'fs/promises'
29
- import { spawn, exec } from 'child_process'
30
- import { promisify } from 'util'
31
- import { tmpdir } from 'os'
32
- import { join } from 'path'
33
- import { paths } from '../../config/paths'
34
- import { platformService } from '../../core/platform-service'
35
- import { portManager } from '../../core/port-manager'
36
- import { defaults } from '../../config/defaults'
37
- import { getPostgresHomebrewPackage } from '../../config/engine-defaults'
38
- import { Engine } from '../../types'
39
- import inquirer from 'inquirer'
40
- import {
41
- getMissingDependencies,
42
- isUsqlInstalled,
43
- isPgcliInstalled,
44
- isMycliInstalled,
45
- detectPackageManager,
46
- installUsql,
47
- installPgcli,
48
- installMycli,
49
- getUsqlManualInstructions,
50
- getPgcliManualInstructions,
51
- getMycliManualInstructions,
52
- } from '../../core/dependency-manager'
53
- import {
54
- getMysqldPath,
55
- getMysqlVersion,
56
- isMariaDB,
57
- getMysqlInstallInfo,
58
- } from '../../engines/mysql/binary-detection'
59
- import { updateManager } from '../../core/update-manager'
60
-
61
- type MenuChoice =
62
- | {
63
- name: string
64
- value: string
65
- disabled?: boolean | string
66
- }
67
- | inquirer.Separator
68
-
69
- /**
70
- * Engine icons for display
71
- */
72
- const engineIcons: Record<string, string> = {
73
- postgresql: '🐘',
74
- mysql: '🐬',
75
- }
76
-
77
- /**
78
- * Helper to pause and wait for user to press Enter
79
- */
80
- async function pressEnterToContinue(): Promise<void> {
81
- await inquirer.prompt([
82
- {
83
- type: 'input',
84
- name: 'continue',
85
- message: chalk.gray('Press Enter to continue...'),
86
- },
87
- ])
88
- }
89
-
90
- async function showMainMenu(): Promise<void> {
91
- console.clear()
92
- console.log(header('SpinDB - Local Database Manager'))
93
- console.log()
94
-
95
- const containers = await containerManager.list()
96
- const running = containers.filter((c) => c.status === 'running').length
97
- const stopped = containers.filter((c) => c.status !== 'running').length
98
-
99
- console.log(
100
- chalk.gray(
101
- ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
102
- ),
103
- )
104
- console.log()
105
-
106
- const canStart = stopped > 0
107
- const canStop = running > 0
108
- const canRestore = running > 0
109
- const canClone = containers.length > 0
110
-
111
- // Check if any engines are installed
112
- const engines = await getInstalledEngines()
113
- const hasEngines = engines.length > 0
114
-
115
- // If containers exist, show List first; otherwise show Create first
116
- const hasContainers = containers.length > 0
117
-
118
- const choices: MenuChoice[] = [
119
- ...(hasContainers
120
- ? [
121
- { name: `${chalk.cyan('◉')} Containers`, value: 'list' },
122
- { name: `${chalk.green('+')} Create new container`, value: 'create' },
123
- ]
124
- : [
125
- { name: `${chalk.green('+')} Create new container`, value: 'create' },
126
- { name: `${chalk.cyan('◉')} Containers`, value: 'list' },
127
- ]),
128
- {
129
- name: canStart
130
- ? `${chalk.green('▶')} Start a container`
131
- : chalk.gray('▶ Start a container'),
132
- value: 'start',
133
- disabled: canStart ? false : 'No stopped containers',
134
- },
135
- {
136
- name: canStop
137
- ? `${chalk.red('■')} Stop a container`
138
- : chalk.gray('■ Stop a container'),
139
- value: 'stop',
140
- disabled: canStop ? false : 'No running containers',
141
- },
142
- {
143
- name: canRestore
144
- ? `${chalk.magenta('↓')} Restore backup`
145
- : chalk.gray('↓ Restore backup'),
146
- value: 'restore',
147
- disabled: canRestore ? false : 'No running containers',
148
- },
149
- {
150
- name: canRestore
151
- ? `${chalk.magenta('↑')} Backup database`
152
- : chalk.gray('↑ Backup database'),
153
- value: 'backup',
154
- disabled: canRestore ? false : 'No running containers',
155
- },
156
- {
157
- name: canClone
158
- ? `${chalk.cyan('⧉')} Clone a container`
159
- : chalk.gray('⧉ Clone a container'),
160
- value: 'clone',
161
- disabled: canClone ? false : 'No containers',
162
- },
163
- {
164
- name: hasEngines
165
- ? `${chalk.yellow('⚙')} List installed engines`
166
- : chalk.gray('⚙ List installed engines'),
167
- value: 'engines',
168
- disabled: hasEngines ? false : 'No engines installed',
169
- },
170
- new inquirer.Separator(),
171
- { name: `${chalk.cyan('↑')} Check for updates`, value: 'check-update' },
172
- { name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
173
- ]
174
-
175
- const { action } = await inquirer.prompt<{ action: string }>([
176
- {
177
- type: 'list',
178
- name: 'action',
179
- message: 'What would you like to do?',
180
- choices,
181
- pageSize: 12,
182
- },
183
- ])
184
-
185
- switch (action) {
186
- case 'create':
187
- await handleCreate()
188
- break
189
- case 'list':
190
- await handleList()
191
- break
192
- case 'start':
193
- await handleStart()
194
- break
195
- case 'stop':
196
- await handleStop()
197
- break
198
- case 'restore':
199
- await handleRestore()
200
- break
201
- case 'backup':
202
- await handleBackup()
203
- break
204
- case 'clone':
205
- await handleClone()
206
- break
207
- case 'engines':
208
- await handleEngines()
209
- break
210
- case 'check-update':
211
- await handleCheckUpdate()
212
- break
213
- case 'exit':
214
- console.log(chalk.gray('\n Goodbye!\n'))
215
- process.exit(0)
216
- }
217
-
218
- // Return to menu after action
219
- await showMainMenu()
220
- }
221
-
222
- async function handleCheckUpdate(): Promise<void> {
223
- console.clear()
224
- console.log(header('Check for Updates'))
225
- console.log()
226
-
227
- const spinner = createSpinner('Checking for updates...')
228
- spinner.start()
229
-
230
- const result = await updateManager.checkForUpdate(true)
231
-
232
- if (!result) {
233
- spinner.fail('Could not reach npm registry')
234
- console.log()
235
- console.log(info('Check your internet connection and try again.'))
236
- console.log(chalk.gray(' Manual update: npm install -g spindb@latest'))
237
- console.log()
238
- await pressEnterToContinue()
239
- return
240
- }
241
-
242
- if (result.updateAvailable) {
243
- spinner.succeed('Update available')
244
- console.log()
245
- console.log(chalk.gray(` Current version: ${result.currentVersion}`))
246
- console.log(
247
- chalk.gray(` Latest version: ${chalk.green(result.latestVersion)}`),
248
- )
249
- console.log()
250
-
251
- const { action } = await inquirer.prompt<{ action: string }>([
252
- {
253
- type: 'list',
254
- name: 'action',
255
- message: 'What would you like to do?',
256
- choices: [
257
- { name: 'Update now', value: 'update' },
258
- { name: 'Remind me later', value: 'later' },
259
- { name: "Don't check for updates on startup", value: 'disable' },
260
- ],
261
- },
262
- ])
263
-
264
- if (action === 'update') {
265
- console.log()
266
- const updateSpinner = createSpinner('Updating spindb...')
267
- updateSpinner.start()
268
-
269
- const updateResult = await updateManager.performUpdate()
270
-
271
- if (updateResult.success) {
272
- updateSpinner.succeed('Update complete')
273
- console.log()
274
- console.log(
275
- success(
276
- `Updated from ${updateResult.previousVersion} to ${updateResult.newVersion}`,
277
- ),
278
- )
279
- console.log()
280
- if (updateResult.previousVersion !== updateResult.newVersion) {
281
- console.log(warning('Please restart spindb to use the new version.'))
282
- console.log()
283
- }
284
- } else {
285
- updateSpinner.fail('Update failed')
286
- console.log()
287
- console.log(error(updateResult.error || 'Unknown error'))
288
- console.log()
289
- console.log(info('Manual update: npm install -g spindb@latest'))
290
- }
291
- await pressEnterToContinue()
292
- } else if (action === 'disable') {
293
- await updateManager.setAutoCheckEnabled(false)
294
- console.log()
295
- console.log(info('Update checks disabled on startup.'))
296
- console.log(chalk.gray(' Re-enable with: spindb config update-check on'))
297
- console.log()
298
- await pressEnterToContinue()
299
- }
300
- // 'later' just returns to menu
301
- } else {
302
- spinner.succeed('You are on the latest version')
303
- console.log()
304
- console.log(chalk.gray(` Version: ${result.currentVersion}`))
305
- console.log()
306
- await pressEnterToContinue()
307
- }
308
- }
309
-
310
- async function handleCreate(): Promise<void> {
311
- console.log()
312
- const answers = await promptCreateOptions()
313
- let { name: containerName } = answers
314
- const { engine, version, port, database } = answers
315
-
316
- console.log()
317
- console.log(header('Creating Database Container'))
318
- console.log()
319
-
320
- const dbEngine = getEngine(engine)
321
-
322
- // Check for required client tools BEFORE creating anything
323
- const depsSpinner = createSpinner('Checking required tools...')
324
- depsSpinner.start()
325
-
326
- let missingDeps = await getMissingDependencies(engine)
327
- if (missingDeps.length > 0) {
328
- depsSpinner.warn(
329
- `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
330
- )
331
-
332
- // Offer to install
333
- const installed = await promptInstallDependencies(
334
- missingDeps[0].binary,
335
- engine,
336
- )
337
-
338
- if (!installed) {
339
- return
340
- }
341
-
342
- // Verify installation worked
343
- missingDeps = await getMissingDependencies(engine)
344
- if (missingDeps.length > 0) {
345
- console.log(
346
- error(
347
- `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
348
- ),
349
- )
350
- return
351
- }
352
-
353
- console.log(chalk.green(' ✓ All required tools are now available'))
354
- console.log()
355
- } else {
356
- depsSpinner.succeed('Required tools available')
357
- }
358
-
359
- // Check if port is currently in use
360
- const portAvailable = await portManager.isPortAvailable(port)
361
-
362
- // Ensure binaries
363
- const binarySpinner = createSpinner(
364
- `Checking PostgreSQL ${version} binaries...`,
365
- )
366
- binarySpinner.start()
367
-
368
- const isInstalled = await dbEngine.isBinaryInstalled(version)
369
- if (isInstalled) {
370
- binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
371
- } else {
372
- binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
373
- await dbEngine.ensureBinaries(version, ({ message }) => {
374
- binarySpinner.text = message
375
- })
376
- binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
377
- }
378
-
379
- // Check if container name already exists and prompt for new name if needed
380
- while (await containerManager.exists(containerName)) {
381
- console.log(chalk.yellow(` Container "${containerName}" already exists.`))
382
- containerName = await promptContainerName()
383
- }
384
-
385
- // Create container
386
- const createSpinnerInstance = createSpinner('Creating container...')
387
- createSpinnerInstance.start()
388
-
389
- await containerManager.create(containerName, {
390
- engine: dbEngine.name as Engine,
391
- version,
392
- port,
393
- database,
394
- })
395
-
396
- createSpinnerInstance.succeed('Container created')
397
-
398
- // Initialize database cluster
399
- const initSpinner = createSpinner('Initializing database cluster...')
400
- initSpinner.start()
401
-
402
- await dbEngine.initDataDir(containerName, version, {
403
- superuser: defaults.superuser,
404
- })
405
-
406
- initSpinner.succeed('Database cluster initialized')
407
-
408
- // Start container (only if port is available)
409
- if (portAvailable) {
410
- const startSpinner = createSpinner('Starting PostgreSQL...')
411
- startSpinner.start()
412
-
413
- const config = await containerManager.getConfig(containerName)
414
- if (config) {
415
- await dbEngine.start(config)
416
- await containerManager.updateConfig(containerName, { status: 'running' })
417
- }
418
-
419
- startSpinner.succeed('PostgreSQL started')
420
-
421
- // Create the user's database (if different from 'postgres')
422
- if (config && database !== 'postgres') {
423
- const dbSpinner = createSpinner(`Creating database "${database}"...`)
424
- dbSpinner.start()
425
-
426
- await dbEngine.createDatabase(config, database)
427
-
428
- dbSpinner.succeed(`Database "${database}" created`)
429
- }
430
-
431
- // Show success
432
- if (config) {
433
- const connectionString = dbEngine.getConnectionString(config)
434
- console.log()
435
- console.log(success('Database Created'))
436
- console.log()
437
- console.log(chalk.gray(` Container: ${containerName}`))
438
- console.log(chalk.gray(` Engine: ${dbEngine.name} ${version}`))
439
- console.log(chalk.gray(` Database: ${database}`))
440
- console.log(chalk.gray(` Port: ${port}`))
441
- console.log()
442
- console.log(success(`Started Running on port ${port}`))
443
- console.log()
444
- console.log(chalk.gray(' Connection string:'))
445
- console.log(chalk.cyan(` ${connectionString}`))
446
-
447
- // Copy connection string to clipboard using platform service
448
- try {
449
- const copied = await platformService.copyToClipboard(connectionString)
450
- if (copied) {
451
- console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
452
- } else {
453
- console.log(chalk.gray(' (Could not copy to clipboard)'))
454
- }
455
- } catch {
456
- console.log(chalk.gray(' (Could not copy to clipboard)'))
457
- }
458
-
459
- console.log()
460
-
461
- // Wait for user to see the result before returning to menu
462
- await inquirer.prompt([
463
- {
464
- type: 'input',
465
- name: 'continue',
466
- message: chalk.gray('Press Enter to return to the main menu...'),
467
- },
468
- ])
469
- }
470
- } else {
471
- console.log()
472
- console.log(
473
- warning(
474
- `Port ${port} is currently in use. Container created but not started.`,
475
- ),
476
- )
477
- console.log(
478
- info(
479
- `Start it later with: ${chalk.cyan(`spindb start ${containerName}`)}`,
480
- ),
481
- )
482
- }
483
- }
484
-
485
- async function handleList(): Promise<void> {
486
- console.clear()
487
- console.log(header('Containers'))
488
- console.log()
489
- const containers = await containerManager.list()
490
-
491
- if (containers.length === 0) {
492
- console.log(
493
- info('No containers found. Create one with the "Create" option.'),
494
- )
495
- console.log()
496
-
497
- await inquirer.prompt([
498
- {
499
- type: 'input',
500
- name: 'continue',
501
- message: chalk.gray('Press Enter to return to the main menu...'),
502
- },
503
- ])
504
- return
505
- }
506
-
507
- // Fetch sizes for running containers in parallel
508
- const sizes = await Promise.all(
509
- containers.map(async (container) => {
510
- if (container.status !== 'running') return null
511
- try {
512
- const engine = getEngine(container.engine)
513
- return await engine.getDatabaseSize(container)
514
- } catch {
515
- return null
516
- }
517
- }),
518
- )
519
-
520
- // Table header
521
- console.log()
522
- console.log(
523
- chalk.gray(' ') +
524
- chalk.bold.white('NAME'.padEnd(20)) +
525
- chalk.bold.white('ENGINE'.padEnd(12)) +
526
- chalk.bold.white('VERSION'.padEnd(10)) +
527
- chalk.bold.white('PORT'.padEnd(8)) +
528
- chalk.bold.white('SIZE'.padEnd(10)) +
529
- chalk.bold.white('STATUS'),
530
- )
531
- console.log(chalk.gray(' ' + '─'.repeat(70)))
532
-
533
- // Table rows
534
- for (let i = 0; i < containers.length; i++) {
535
- const container = containers[i]
536
- const size = sizes[i]
537
-
538
- const statusDisplay =
539
- container.status === 'running'
540
- ? chalk.green('● running')
541
- : chalk.gray('○ stopped')
542
-
543
- const sizeDisplay = size !== null ? formatBytes(size) : chalk.gray('—')
544
-
545
- console.log(
546
- chalk.gray(' ') +
547
- chalk.cyan(container.name.padEnd(20)) +
548
- chalk.white(container.engine.padEnd(12)) +
549
- chalk.yellow(container.version.padEnd(10)) +
550
- chalk.green(String(container.port).padEnd(8)) +
551
- chalk.magenta(sizeDisplay.padEnd(10)) +
552
- statusDisplay,
553
- )
554
- }
555
-
556
- console.log()
557
-
558
- const running = containers.filter((c) => c.status === 'running').length
559
- const stopped = containers.filter((c) => c.status !== 'running').length
560
- console.log(
561
- chalk.gray(
562
- ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
563
- ),
564
- )
565
-
566
- // Container selection with submenu
567
- console.log()
568
- const containerChoices = [
569
- ...containers.map((c, i) => {
570
- const size = sizes[i]
571
- const sizeLabel = size !== null ? `, ${formatBytes(size)}` : ''
572
- return {
573
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine} ${c.version}, port ${c.port}${sizeLabel})`)} ${
574
- c.status === 'running'
575
- ? chalk.green('● running')
576
- : chalk.gray('○ stopped')
577
- }`,
578
- value: c.name,
579
- short: c.name,
580
- }
581
- }),
582
- new inquirer.Separator(),
583
- { name: `${chalk.blue('←')} Back to main menu`, value: 'back' },
584
- ]
585
-
586
- const { selectedContainer } = await inquirer.prompt<{
587
- selectedContainer: string
588
- }>([
589
- {
590
- type: 'list',
591
- name: 'selectedContainer',
592
- message: 'Select a container for more options:',
593
- choices: containerChoices,
594
- pageSize: 15,
595
- },
596
- ])
597
-
598
- if (selectedContainer === 'back') {
599
- await showMainMenu()
600
- return
601
- }
602
-
603
- await showContainerSubmenu(selectedContainer)
604
- }
605
-
606
- async function showContainerSubmenu(containerName: string): Promise<void> {
607
- const config = await containerManager.getConfig(containerName)
608
- if (!config) {
609
- console.error(error(`Container "${containerName}" not found`))
610
- return
611
- }
612
-
613
- // Check actual running state
614
- const isRunning = await processManager.isRunning(containerName, {
615
- engine: config.engine,
616
- })
617
- const status = isRunning ? 'running' : 'stopped'
618
-
619
- console.clear()
620
- console.log(header(containerName))
621
- console.log()
622
- console.log(
623
- chalk.gray(
624
- ` ${config.engine} ${config.version} on port ${config.port} - ${status}`,
625
- ),
626
- )
627
- console.log()
628
-
629
- const actionChoices: MenuChoice[] = [
630
- // Start or Stop depending on current state
631
- !isRunning
632
- ? { name: `${chalk.green('▶')} Start container`, value: 'start' }
633
- : { name: `${chalk.red('■')} Stop container`, value: 'stop' },
634
- {
635
- name: isRunning
636
- ? `${chalk.blue('⌘')} Open shell`
637
- : chalk.gray('⌘ Open shell'),
638
- value: 'shell',
639
- disabled: isRunning ? false : 'Start container first',
640
- },
641
- {
642
- name: !isRunning
643
- ? `${chalk.white('⚙')} Edit container`
644
- : chalk.gray('⚙ Edit container'),
645
- value: 'edit',
646
- disabled: !isRunning ? false : 'Stop container first',
647
- },
648
- {
649
- name: !isRunning
650
- ? `${chalk.cyan('⧉')} Clone container`
651
- : chalk.gray('⧉ Clone container'),
652
- value: 'clone',
653
- disabled: !isRunning ? false : 'Stop container first',
654
- },
655
- { name: `${chalk.magenta('⎘')} Copy connection string`, value: 'copy' },
656
- {
657
- name: !isRunning
658
- ? `${chalk.red('✕')} Delete container`
659
- : chalk.gray('✕ Delete container'),
660
- value: 'delete',
661
- disabled: !isRunning ? false : 'Stop container first',
662
- },
663
- new inquirer.Separator(),
664
- { name: `${chalk.blue('←')} Back to containers`, value: 'back' },
665
- { name: `${chalk.blue('⌂')} Back to main menu`, value: 'main' },
666
- ]
667
-
668
- const { action } = await inquirer.prompt<{ action: string }>([
669
- {
670
- type: 'list',
671
- name: 'action',
672
- message: 'What would you like to do?',
673
- choices: actionChoices,
674
- pageSize: 15,
675
- },
676
- ])
677
-
678
- switch (action) {
679
- case 'start':
680
- await handleStartContainer(containerName)
681
- await showContainerSubmenu(containerName)
682
- return
683
- case 'stop':
684
- await handleStopContainer(containerName)
685
- await showContainerSubmenu(containerName)
686
- return
687
- case 'shell':
688
- await handleOpenShell(containerName)
689
- await showContainerSubmenu(containerName)
690
- return
691
- case 'edit': {
692
- const newName = await handleEditContainer(containerName)
693
- if (newName === null) {
694
- // User chose to go back to main menu
695
- return
696
- }
697
- if (newName !== containerName) {
698
- // Container was renamed, show submenu with new name
699
- await showContainerSubmenu(newName)
700
- } else {
701
- await showContainerSubmenu(containerName)
702
- }
703
- return
704
- }
705
- case 'clone':
706
- await handleCloneFromSubmenu(containerName)
707
- return
708
- case 'copy':
709
- await handleCopyConnectionString(containerName)
710
- await showContainerSubmenu(containerName)
711
- return
712
- case 'delete':
713
- await handleDelete(containerName)
714
- return // Don't show submenu again after delete
715
- case 'back':
716
- await handleList()
717
- return
718
- case 'main':
719
- return // Return to main menu
720
- }
721
- }
722
-
723
- async function handleStart(): Promise<void> {
724
- const containers = await containerManager.list()
725
- const stopped = containers.filter((c) => c.status !== 'running')
726
-
727
- if (stopped.length === 0) {
728
- console.log(warning('All containers are already running'))
729
- return
730
- }
731
-
732
- const containerName = await promptContainerSelect(
733
- stopped,
734
- 'Select container to start:',
735
- )
736
- if (!containerName) return
737
-
738
- const config = await containerManager.getConfig(containerName)
739
- if (!config) {
740
- console.error(error(`Container "${containerName}" not found`))
741
- return
742
- }
743
-
744
- // Check port availability
745
- const portAvailable = await portManager.isPortAvailable(config.port)
746
- if (!portAvailable) {
747
- const { port: newPort } = await portManager.findAvailablePort()
748
- console.log(
749
- warning(`Port ${config.port} is in use, switching to port ${newPort}`),
750
- )
751
- config.port = newPort
752
- await containerManager.updateConfig(containerName, { port: newPort })
753
- }
754
-
755
- const engine = getEngine(config.engine)
756
-
757
- const spinner = createSpinner(`Starting ${containerName}...`)
758
- spinner.start()
759
-
760
- await engine.start(config)
761
- await containerManager.updateConfig(containerName, { status: 'running' })
762
-
763
- spinner.succeed(`Container "${containerName}" started`)
764
-
765
- const connectionString = engine.getConnectionString(config)
766
- console.log()
767
- console.log(chalk.gray(' Connection string:'))
768
- console.log(chalk.cyan(` ${connectionString}`))
769
- }
770
-
771
- async function handleStop(): Promise<void> {
772
- const containers = await containerManager.list()
773
- const running = containers.filter((c) => c.status === 'running')
774
-
775
- if (running.length === 0) {
776
- console.log(warning('No running containers'))
777
- return
778
- }
779
-
780
- const containerName = await promptContainerSelect(
781
- running,
782
- 'Select container to stop:',
783
- )
784
- if (!containerName) return
785
-
786
- const config = await containerManager.getConfig(containerName)
787
- if (!config) {
788
- console.error(error(`Container "${containerName}" not found`))
789
- return
790
- }
791
-
792
- const engine = getEngine(config.engine)
793
-
794
- const spinner = createSpinner(`Stopping ${containerName}...`)
795
- spinner.start()
796
-
797
- await engine.stop(config)
798
- await containerManager.updateConfig(containerName, { status: 'stopped' })
799
-
800
- spinner.succeed(`Container "${containerName}" stopped`)
801
- }
802
-
803
- async function handleCopyConnectionString(
804
- containerName: string,
805
- ): Promise<void> {
806
- const config = await containerManager.getConfig(containerName)
807
- if (!config) {
808
- console.error(error(`Container "${containerName}" not found`))
809
- return
810
- }
811
-
812
- const engine = getEngine(config.engine)
813
- const connectionString = engine.getConnectionString(config)
814
-
815
- // Copy to clipboard using platform service
816
- const copied = await platformService.copyToClipboard(connectionString)
817
-
818
- console.log()
819
- if (copied) {
820
- console.log(success('Connection string copied to clipboard'))
821
- console.log(chalk.gray(` ${connectionString}`))
822
- } else {
823
- console.log(warning('Could not copy to clipboard. Connection string:'))
824
- console.log(chalk.cyan(` ${connectionString}`))
825
- }
826
- console.log()
827
-
828
- await inquirer.prompt([
829
- {
830
- type: 'input',
831
- name: 'continue',
832
- message: chalk.gray('Press Enter to continue...'),
833
- },
834
- ])
835
- }
836
-
837
- async function handleOpenShell(containerName: string): Promise<void> {
838
- const config = await containerManager.getConfig(containerName)
839
- if (!config) {
840
- console.error(error(`Container "${containerName}" not found`))
841
- return
842
- }
843
-
844
- const engine = getEngine(config.engine)
845
- const connectionString = engine.getConnectionString(config)
846
-
847
- // Check which enhanced shells are installed
848
- const usqlInstalled = await isUsqlInstalled()
849
- const pgcliInstalled = await isPgcliInstalled()
850
- const mycliInstalled = await isMycliInstalled()
851
-
852
- type ShellChoice =
853
- | 'default'
854
- | 'usql'
855
- | 'install-usql'
856
- | 'pgcli'
857
- | 'install-pgcli'
858
- | 'mycli'
859
- | 'install-mycli'
860
- | 'back'
861
-
862
- const defaultShellName = config.engine === 'mysql' ? 'mysql' : 'psql'
863
- const engineSpecificCli = config.engine === 'mysql' ? 'mycli' : 'pgcli'
864
- const engineSpecificInstalled =
865
- config.engine === 'mysql' ? mycliInstalled : pgcliInstalled
866
-
867
- const choices: Array<{ name: string; value: ShellChoice }> = [
868
- {
869
- name: `>_ Use default shell (${defaultShellName})`,
870
- value: 'default',
871
- },
872
- ]
873
-
874
- // Engine-specific enhanced CLI (pgcli for PostgreSQL, mycli for MySQL)
875
- if (engineSpecificInstalled) {
876
- choices.push({
877
- name: `⚡ Use ${engineSpecificCli} (enhanced features, recommended)`,
878
- value: config.engine === 'mysql' ? 'mycli' : 'pgcli',
879
- })
880
- } else {
881
- choices.push({
882
- name: `↓ Install ${engineSpecificCli} (enhanced features, recommended)`,
883
- value: config.engine === 'mysql' ? 'install-mycli' : 'install-pgcli',
884
- })
885
- }
886
-
887
- // usql - universal option
888
- if (usqlInstalled) {
889
- choices.push({
890
- name: '⚡ Use usql (universal SQL client)',
891
- value: 'usql',
892
- })
893
- } else {
894
- choices.push({
895
- name: '↓ Install usql (universal SQL client)',
896
- value: 'install-usql',
897
- })
898
- }
899
-
900
- choices.push({
901
- name: `${chalk.blue('←')} Back`,
902
- value: 'back',
903
- })
904
-
905
- const { shellChoice } = await inquirer.prompt<{ shellChoice: ShellChoice }>([
906
- {
907
- type: 'list',
908
- name: 'shellChoice',
909
- message: 'Select shell option:',
910
- choices,
911
- pageSize: 10,
912
- },
913
- ])
914
-
915
- if (shellChoice === 'back') {
916
- return
917
- }
918
-
919
- // Handle pgcli installation
920
- if (shellChoice === 'install-pgcli') {
921
- console.log()
922
- console.log(info('Installing pgcli for enhanced PostgreSQL shell...'))
923
- const pm = await detectPackageManager()
924
- if (pm) {
925
- const result = await installPgcli(pm)
926
- if (result.success) {
927
- console.log(success('pgcli installed successfully!'))
928
- console.log()
929
- await launchShell(containerName, config, connectionString, 'pgcli')
930
- } else {
931
- console.error(error(`Failed to install pgcli: ${result.error}`))
932
- console.log()
933
- console.log(chalk.gray('Manual installation:'))
934
- for (const instruction of getPgcliManualInstructions()) {
935
- console.log(chalk.cyan(` ${instruction}`))
936
- }
937
- console.log()
938
- await pressEnterToContinue()
939
- }
940
- } else {
941
- console.error(error('No supported package manager found'))
942
- console.log()
943
- console.log(chalk.gray('Manual installation:'))
944
- for (const instruction of getPgcliManualInstructions()) {
945
- console.log(chalk.cyan(` ${instruction}`))
946
- }
947
- console.log()
948
- await pressEnterToContinue()
949
- }
950
- return
951
- }
952
-
953
- // Handle mycli installation
954
- if (shellChoice === 'install-mycli') {
955
- console.log()
956
- console.log(info('Installing mycli for enhanced MySQL shell...'))
957
- const pm = await detectPackageManager()
958
- if (pm) {
959
- const result = await installMycli(pm)
960
- if (result.success) {
961
- console.log(success('mycli installed successfully!'))
962
- console.log()
963
- await launchShell(containerName, config, connectionString, 'mycli')
964
- } else {
965
- console.error(error(`Failed to install mycli: ${result.error}`))
966
- console.log()
967
- console.log(chalk.gray('Manual installation:'))
968
- for (const instruction of getMycliManualInstructions()) {
969
- console.log(chalk.cyan(` ${instruction}`))
970
- }
971
- console.log()
972
- await pressEnterToContinue()
973
- }
974
- } else {
975
- console.error(error('No supported package manager found'))
976
- console.log()
977
- console.log(chalk.gray('Manual installation:'))
978
- for (const instruction of getMycliManualInstructions()) {
979
- console.log(chalk.cyan(` ${instruction}`))
980
- }
981
- console.log()
982
- await pressEnterToContinue()
983
- }
984
- return
985
- }
986
-
987
- // Handle usql installation
988
- if (shellChoice === 'install-usql') {
989
- console.log()
990
- console.log(info('Installing usql for enhanced shell experience...'))
991
- const pm = await detectPackageManager()
992
- if (pm) {
993
- const result = await installUsql(pm)
994
- if (result.success) {
995
- console.log(success('usql installed successfully!'))
996
- console.log()
997
- await launchShell(containerName, config, connectionString, 'usql')
998
- } else {
999
- console.error(error(`Failed to install usql: ${result.error}`))
1000
- console.log()
1001
- console.log(chalk.gray('Manual installation:'))
1002
- for (const instruction of getUsqlManualInstructions()) {
1003
- console.log(chalk.cyan(` ${instruction}`))
1004
- }
1005
- console.log()
1006
- await pressEnterToContinue()
1007
- }
1008
- } else {
1009
- console.error(error('No supported package manager found'))
1010
- console.log()
1011
- console.log(chalk.gray('Manual installation:'))
1012
- for (const instruction of getUsqlManualInstructions()) {
1013
- console.log(chalk.cyan(` ${instruction}`))
1014
- }
1015
- console.log()
1016
- await pressEnterToContinue()
1017
- }
1018
- return
1019
- }
1020
-
1021
- // Launch the selected shell
1022
- await launchShell(containerName, config, connectionString, shellChoice)
1023
- }
1024
-
1025
- async function launchShell(
1026
- containerName: string,
1027
- config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>,
1028
- connectionString: string,
1029
- shellType: 'default' | 'usql' | 'pgcli' | 'mycli',
1030
- ): Promise<void> {
1031
- console.log(info(`Connecting to ${containerName}...`))
1032
- console.log()
1033
-
1034
- // Determine shell command based on engine and shell type
1035
- let shellCmd: string
1036
- let shellArgs: string[]
1037
- let installHint: string
1038
-
1039
- if (shellType === 'pgcli') {
1040
- // pgcli accepts connection strings
1041
- shellCmd = 'pgcli'
1042
- shellArgs = [connectionString]
1043
- installHint = 'brew install pgcli'
1044
- } else if (shellType === 'mycli') {
1045
- // mycli: mycli -h host -P port -u user database
1046
- shellCmd = 'mycli'
1047
- shellArgs = [
1048
- '-h',
1049
- '127.0.0.1',
1050
- '-P',
1051
- String(config.port),
1052
- '-u',
1053
- 'root',
1054
- config.database,
1055
- ]
1056
- installHint = 'brew install mycli'
1057
- } else if (shellType === 'usql') {
1058
- // usql accepts connection strings directly for both PostgreSQL and MySQL
1059
- shellCmd = 'usql'
1060
- shellArgs = [connectionString]
1061
- installHint = 'brew tap xo/xo && brew install xo/xo/usql'
1062
- } else if (config.engine === 'mysql') {
1063
- shellCmd = 'mysql'
1064
- // MySQL connection: mysql -u root -h 127.0.0.1 -P port database
1065
- shellArgs = [
1066
- '-u',
1067
- 'root',
1068
- '-h',
1069
- '127.0.0.1',
1070
- '-P',
1071
- String(config.port),
1072
- config.database,
1073
- ]
1074
- installHint = 'brew install mysql-client'
1075
- } else {
1076
- // PostgreSQL (default)
1077
- shellCmd = 'psql'
1078
- shellArgs = [connectionString]
1079
- installHint = 'brew install libpq && brew link --force libpq'
1080
- }
1081
-
1082
- const shellProcess = spawn(shellCmd, shellArgs, {
1083
- stdio: 'inherit',
1084
- })
1085
-
1086
- shellProcess.on('error', (err: NodeJS.ErrnoException) => {
1087
- if (err.code === 'ENOENT') {
1088
- console.log(warning(`${shellCmd} not found on your system.`))
1089
- console.log()
1090
- console.log(chalk.gray(' Connect manually with:'))
1091
- console.log(chalk.cyan(` ${connectionString}`))
1092
- console.log()
1093
- console.log(chalk.gray(` Install ${shellCmd}:`))
1094
- console.log(chalk.cyan(` ${installHint}`))
1095
- }
1096
- })
1097
-
1098
- await new Promise<void>((resolve) => {
1099
- shellProcess.on('close', () => resolve())
1100
- })
1101
- }
1102
-
1103
- /**
1104
- * Create a new container for the restore flow
1105
- * Returns the container name and config if successful, null if cancelled/error
1106
- */
1107
- async function handleCreateForRestore(): Promise<{
1108
- name: string
1109
- config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>
1110
- } | null> {
1111
- console.log()
1112
- const answers = await promptCreateOptions()
1113
- let { name: containerName } = answers
1114
- const { engine, version, port, database } = answers
1115
-
1116
- console.log()
1117
- console.log(header('Creating Database Container'))
1118
- console.log()
1119
-
1120
- const dbEngine = getEngine(engine)
1121
-
1122
- // Check if port is currently in use
1123
- const portAvailable = await portManager.isPortAvailable(port)
1124
- if (!portAvailable) {
1125
- console.log(
1126
- error(`Port ${port} is in use. Please choose a different port.`),
1127
- )
1128
- return null
1129
- }
1130
-
1131
- // Ensure binaries
1132
- const binarySpinner = createSpinner(
1133
- `Checking PostgreSQL ${version} binaries...`,
1134
- )
1135
- binarySpinner.start()
1136
-
1137
- const isInstalled = await dbEngine.isBinaryInstalled(version)
1138
- if (isInstalled) {
1139
- binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
1140
- } else {
1141
- binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
1142
- await dbEngine.ensureBinaries(version, ({ message }) => {
1143
- binarySpinner.text = message
1144
- })
1145
- binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
1146
- }
1147
-
1148
- // Check if container name already exists and prompt for new name if needed
1149
- while (await containerManager.exists(containerName)) {
1150
- console.log(chalk.yellow(` Container "${containerName}" already exists.`))
1151
- containerName = await promptContainerName()
1152
- }
1153
-
1154
- // Create container
1155
- const createSpinnerInstance = createSpinner('Creating container...')
1156
- createSpinnerInstance.start()
1157
-
1158
- await containerManager.create(containerName, {
1159
- engine: dbEngine.name as Engine,
1160
- version,
1161
- port,
1162
- database,
1163
- })
1164
-
1165
- createSpinnerInstance.succeed('Container created')
1166
-
1167
- // Initialize database cluster
1168
- const initSpinner = createSpinner('Initializing database cluster...')
1169
- initSpinner.start()
1170
-
1171
- await dbEngine.initDataDir(containerName, version, {
1172
- superuser: defaults.superuser,
1173
- })
1174
-
1175
- initSpinner.succeed('Database cluster initialized')
1176
-
1177
- // Start container
1178
- const startSpinner = createSpinner('Starting PostgreSQL...')
1179
- startSpinner.start()
1180
-
1181
- const config = await containerManager.getConfig(containerName)
1182
- if (!config) {
1183
- startSpinner.fail('Failed to get container config')
1184
- return null
1185
- }
1186
-
1187
- await dbEngine.start(config)
1188
- await containerManager.updateConfig(containerName, { status: 'running' })
1189
-
1190
- startSpinner.succeed('PostgreSQL started')
1191
-
1192
- // Create the user's database (if different from 'postgres')
1193
- if (database !== 'postgres') {
1194
- const dbSpinner = createSpinner(`Creating database "${database}"...`)
1195
- dbSpinner.start()
1196
-
1197
- await dbEngine.createDatabase(config, database)
1198
-
1199
- dbSpinner.succeed(`Database "${database}" created`)
1200
- }
1201
-
1202
- console.log()
1203
- console.log(success('Container ready for restore'))
1204
- console.log()
1205
-
1206
- return { name: containerName, config }
1207
- }
1208
-
1209
- async function handleRestore(): Promise<void> {
1210
- const containers = await containerManager.list()
1211
- const running = containers.filter((c) => c.status === 'running')
1212
-
1213
- // Build choices: running containers + create new option
1214
- const choices = [
1215
- ...running.map((c) => ({
1216
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
1217
- value: c.name,
1218
- short: c.name,
1219
- })),
1220
- new inquirer.Separator(),
1221
- {
1222
- name: `${chalk.green('➕')} Create new container`,
1223
- value: '__create_new__',
1224
- short: 'Create new',
1225
- },
1226
- ]
1227
-
1228
- const { selectedContainer } = await inquirer.prompt<{
1229
- selectedContainer: string
1230
- }>([
1231
- {
1232
- type: 'list',
1233
- name: 'selectedContainer',
1234
- message: 'Select container to restore to:',
1235
- choices,
1236
- pageSize: 15,
1237
- },
1238
- ])
1239
-
1240
- let containerName: string
1241
- let config: Awaited<ReturnType<typeof containerManager.getConfig>>
1242
-
1243
- if (selectedContainer === '__create_new__') {
1244
- // Run the create flow first
1245
- const createResult = await handleCreateForRestore()
1246
- if (!createResult) return // User cancelled or error
1247
- containerName = createResult.name
1248
- config = createResult.config
1249
- } else {
1250
- containerName = selectedContainer
1251
- config = await containerManager.getConfig(containerName)
1252
- if (!config) {
1253
- console.error(error(`Container "${containerName}" not found`))
1254
- return
1255
- }
1256
- }
1257
-
1258
- // Check for required client tools BEFORE doing anything
1259
- const depsSpinner = createSpinner('Checking required tools...')
1260
- depsSpinner.start()
1261
-
1262
- let missingDeps = await getMissingDependencies(config.engine)
1263
- if (missingDeps.length > 0) {
1264
- depsSpinner.warn(
1265
- `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
1266
- )
1267
-
1268
- // Offer to install
1269
- const installed = await promptInstallDependencies(
1270
- missingDeps[0].binary,
1271
- config.engine,
1272
- )
1273
-
1274
- if (!installed) {
1275
- return
1276
- }
1277
-
1278
- // Verify installation worked
1279
- missingDeps = await getMissingDependencies(config.engine)
1280
- if (missingDeps.length > 0) {
1281
- console.log(
1282
- error(
1283
- `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
1284
- ),
1285
- )
1286
- return
1287
- }
1288
-
1289
- console.log(chalk.green(' ✓ All required tools are now available'))
1290
- console.log()
1291
- } else {
1292
- depsSpinner.succeed('Required tools available')
1293
- }
1294
-
1295
- // Ask for restore source
1296
- const { restoreSource } = await inquirer.prompt<{
1297
- restoreSource: 'file' | 'connection'
1298
- }>([
1299
- {
1300
- type: 'list',
1301
- name: 'restoreSource',
1302
- message: 'Restore from:',
1303
- choices: [
1304
- {
1305
- name: `${chalk.magenta('📁')} Dump file (drag and drop or enter path)`,
1306
- value: 'file',
1307
- },
1308
- {
1309
- name: `${chalk.cyan('🔗')} Connection string (pull from remote database)`,
1310
- value: 'connection',
1311
- },
1312
- ],
1313
- },
1314
- ])
1315
-
1316
- let backupPath = ''
1317
- let isTempFile = false
1318
-
1319
- if (restoreSource === 'connection') {
1320
- // Get connection string and create dump
1321
- const { connectionString } = await inquirer.prompt<{
1322
- connectionString: string
1323
- }>([
1324
- {
1325
- type: 'input',
1326
- name: 'connectionString',
1327
- message: 'Connection string (postgresql://user:pass@host:port/dbname):',
1328
- validate: (input: string) => {
1329
- if (!input) return 'Connection string is required'
1330
- if (
1331
- !input.startsWith('postgresql://') &&
1332
- !input.startsWith('postgres://')
1333
- ) {
1334
- return 'Connection string must start with postgresql:// or postgres://'
1335
- }
1336
- return true
1337
- },
1338
- },
1339
- ])
1340
-
1341
- const engine = getEngine(config.engine)
1342
-
1343
- // Create temp file for the dump
1344
- const timestamp = Date.now()
1345
- const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
1346
-
1347
- let dumpSuccess = false
1348
- let attempts = 0
1349
- const maxAttempts = 2 // Allow one retry after installing deps
1350
-
1351
- while (!dumpSuccess && attempts < maxAttempts) {
1352
- attempts++
1353
- const dumpSpinner = createSpinner('Creating dump from remote database...')
1354
- dumpSpinner.start()
1355
-
1356
- try {
1357
- await engine.dumpFromConnectionString(connectionString, tempDumpPath)
1358
- dumpSpinner.succeed('Dump created from remote database')
1359
- backupPath = tempDumpPath
1360
- isTempFile = true
1361
- dumpSuccess = true
1362
- } catch (err) {
1363
- const e = err as Error
1364
- dumpSpinner.fail('Failed to create dump')
1365
-
1366
- // Check if this is a missing tool error
1367
- if (
1368
- e.message.includes('pg_dump not found') ||
1369
- e.message.includes('ENOENT')
1370
- ) {
1371
- const installed = await promptInstallDependencies('pg_dump')
1372
- if (installed) {
1373
- // Loop will retry
1374
- continue
1375
- }
1376
- } else {
1377
- console.log()
1378
- console.log(error('pg_dump error:'))
1379
- console.log(chalk.gray(` ${e.message}`))
1380
- console.log()
1381
- }
1382
-
1383
- // Clean up temp file if it was created
1384
- try {
1385
- await rm(tempDumpPath, { force: true })
1386
- } catch {
1387
- // Ignore cleanup errors
1388
- }
1389
-
1390
- // Wait for user to see the error
1391
- await inquirer.prompt([
1392
- {
1393
- type: 'input',
1394
- name: 'continue',
1395
- message: chalk.gray('Press Enter to continue...'),
1396
- },
1397
- ])
1398
- return
1399
- }
1400
- }
1401
-
1402
- // Safety check - should never reach here without backupPath set
1403
- if (!dumpSuccess) {
1404
- console.log(error('Failed to create dump after retries'))
1405
- return
1406
- }
1407
- } else {
1408
- // Get backup file path
1409
- // Strip quotes that terminals add when drag-and-dropping files
1410
- const stripQuotes = (path: string) =>
1411
- path.replace(/^['"]|['"]$/g, '').trim()
1412
-
1413
- const { backupPath: rawBackupPath } = await inquirer.prompt<{
1414
- backupPath: string
1415
- }>([
1416
- {
1417
- type: 'input',
1418
- name: 'backupPath',
1419
- message: 'Path to backup file (drag and drop or enter path):',
1420
- validate: (input: string) => {
1421
- if (!input) return 'Backup path is required'
1422
- const cleanPath = stripQuotes(input)
1423
- if (!existsSync(cleanPath)) return 'File not found'
1424
- return true
1425
- },
1426
- },
1427
- ])
1428
- backupPath = stripQuotes(rawBackupPath)
1429
- }
1430
-
1431
- const databaseName = await promptDatabaseName(containerName, config.engine)
1432
-
1433
- const engine = getEngine(config.engine)
1434
-
1435
- // Detect format
1436
- const detectSpinner = createSpinner('Detecting backup format...')
1437
- detectSpinner.start()
1438
-
1439
- const format = await engine.detectBackupFormat(backupPath)
1440
- detectSpinner.succeed(`Detected: ${format.description}`)
1441
-
1442
- // Create database
1443
- const dbSpinner = createSpinner(`Creating database "${databaseName}"...`)
1444
- dbSpinner.start()
1445
-
1446
- await engine.createDatabase(config, databaseName)
1447
- dbSpinner.succeed(`Database "${databaseName}" ready`)
1448
-
1449
- // Restore
1450
- const restoreSpinner = createSpinner('Restoring backup...')
1451
- restoreSpinner.start()
1452
-
1453
- const result = await engine.restore(config, backupPath, {
1454
- database: databaseName,
1455
- createDatabase: false,
1456
- })
1457
-
1458
- if (result.code === 0 || !result.stderr) {
1459
- restoreSpinner.succeed('Backup restored successfully')
1460
- } else {
1461
- const stderr = result.stderr || ''
1462
-
1463
- // Check for version compatibility errors
1464
- if (
1465
- stderr.includes('unsupported version') ||
1466
- stderr.includes('Archive version') ||
1467
- stderr.includes('too old')
1468
- ) {
1469
- restoreSpinner.fail('Version compatibility detected')
1470
- console.log()
1471
- console.log(error('PostgreSQL version incompatibility detected:'))
1472
- console.log(
1473
- warning('Your pg_restore version is too old for this backup file.'),
1474
- )
1475
-
1476
- // Clean up the failed database since restore didn't actually work
1477
- console.log(chalk.yellow('Cleaning up failed database...'))
1478
- try {
1479
- await engine.dropDatabase(config, databaseName)
1480
- console.log(chalk.gray(`✓ Removed database "${databaseName}"`))
1481
- } catch {
1482
- console.log(
1483
- chalk.yellow(`Warning: Could not remove database "${databaseName}"`),
1484
- )
1485
- }
1486
-
1487
- console.log()
1488
-
1489
- // Extract version info from error message
1490
- const versionMatch = stderr.match(/PostgreSQL (\d+)/)
1491
- const requiredVersion = versionMatch ? versionMatch[1] : '17'
1492
-
1493
- console.log(
1494
- chalk.gray(
1495
- `This backup was created with PostgreSQL ${requiredVersion}`,
1496
- ),
1497
- )
1498
- console.log()
1499
-
1500
- // Ask user if they want to upgrade
1501
- const { shouldUpgrade } = await inquirer.prompt({
1502
- type: 'list',
1503
- name: 'shouldUpgrade',
1504
- message: `Would you like to upgrade PostgreSQL client tools to support PostgreSQL ${requiredVersion}?`,
1505
- choices: [
1506
- { name: 'Yes', value: true },
1507
- { name: 'No', value: false },
1508
- ],
1509
- default: 0,
1510
- })
1511
-
1512
- if (shouldUpgrade) {
1513
- console.log()
1514
- const upgradeSpinner = createSpinner(
1515
- 'Upgrading PostgreSQL client tools...',
1516
- )
1517
- upgradeSpinner.start()
1518
-
1519
- try {
1520
- const { updatePostgresClientTools } = await import(
1521
- '../../engines/postgresql/binary-manager'
1522
- )
1523
- const updateSuccess = await updatePostgresClientTools()
1524
-
1525
- if (updateSuccess) {
1526
- upgradeSpinner.succeed('PostgreSQL client tools upgraded')
1527
- console.log()
1528
- console.log(
1529
- success('Please try the restore again with the updated tools.'),
1530
- )
1531
- await new Promise((resolve) => {
1532
- console.log(chalk.gray('Press Enter to continue...'))
1533
- process.stdin.once('data', resolve)
1534
- })
1535
- return
1536
- } else {
1537
- upgradeSpinner.fail('Upgrade failed')
1538
- console.log()
1539
- console.log(
1540
- error('Automatic upgrade failed. Please upgrade manually:'),
1541
- )
1542
- const pgPackage = getPostgresHomebrewPackage()
1543
- const latestMajor = pgPackage.split('@')[1]
1544
- console.log(
1545
- warning(
1546
- ` macOS: brew install ${pgPackage} && brew link --force ${pgPackage}`,
1547
- ),
1548
- )
1549
- console.log(
1550
- chalk.gray(
1551
- ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
1552
- ),
1553
- )
1554
- console.log(
1555
- warning(
1556
- ` Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-${latestMajor}`,
1557
- ),
1558
- )
1559
- console.log(
1560
- chalk.gray(
1561
- ` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
1562
- ),
1563
- )
1564
- await new Promise((resolve) => {
1565
- console.log(chalk.gray('Press Enter to continue...'))
1566
- process.stdin.once('data', resolve)
1567
- })
1568
- return
1569
- }
1570
- } catch {
1571
- upgradeSpinner.fail('Upgrade failed')
1572
- console.log(error('Failed to upgrade PostgreSQL client tools'))
1573
- console.log(
1574
- chalk.gray(
1575
- 'Manual upgrade may be required for pg_restore, pg_dump, and psql',
1576
- ),
1577
- )
1578
- await new Promise((resolve) => {
1579
- console.log(chalk.gray('Press Enter to continue...'))
1580
- process.stdin.once('data', resolve)
1581
- })
1582
- return
1583
- }
1584
- } else {
1585
- console.log()
1586
- console.log(
1587
- warning(
1588
- 'Restore cancelled. Please upgrade PostgreSQL client tools manually and try again.',
1589
- ),
1590
- )
1591
- await new Promise((resolve) => {
1592
- console.log(chalk.gray('Press Enter to continue...'))
1593
- process.stdin.once('data', resolve)
1594
- })
1595
- return
1596
- }
1597
- } else {
1598
- // Regular warnings/errors - show as before
1599
- restoreSpinner.warn('Restore completed with warnings')
1600
- // Show stderr output so user can see what went wrong
1601
- if (result.stderr) {
1602
- console.log()
1603
- console.log(chalk.yellow(' Warnings/Errors:'))
1604
- // Show first 20 lines of stderr to avoid overwhelming output
1605
- const lines = result.stderr.split('\n').filter((l) => l.trim())
1606
- const displayLines = lines.slice(0, 20)
1607
- for (const line of displayLines) {
1608
- console.log(chalk.gray(` ${line}`))
1609
- }
1610
- if (lines.length > 20) {
1611
- console.log(chalk.gray(` ... and ${lines.length - 20} more lines`))
1612
- }
1613
- }
1614
- }
1615
- }
1616
-
1617
- // Only show success message if restore actually succeeded
1618
- if (result.code === 0 || !result.stderr) {
1619
- const connectionString = engine.getConnectionString(config, databaseName)
1620
- console.log()
1621
- console.log(success(`Database "${databaseName}" restored`))
1622
- console.log(chalk.gray(' Connection string:'))
1623
- console.log(chalk.cyan(` ${connectionString}`))
1624
-
1625
- // Copy connection string to clipboard using platform service
1626
- const copied = await platformService.copyToClipboard(connectionString)
1627
- if (copied) {
1628
- console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
1629
- } else {
1630
- console.log(chalk.gray(' (Could not copy to clipboard)'))
1631
- }
1632
-
1633
- console.log()
1634
- }
1635
-
1636
- // Clean up temp file if we created one
1637
- if (isTempFile) {
1638
- try {
1639
- await rm(backupPath, { force: true })
1640
- } catch {
1641
- // Ignore cleanup errors
1642
- }
1643
- }
1644
-
1645
- // Wait for user to see the result before returning to menu
1646
- await inquirer.prompt([
1647
- {
1648
- type: 'input',
1649
- name: 'continue',
1650
- message: chalk.gray('Press Enter to continue...'),
1651
- },
1652
- ])
1653
- }
1654
-
1655
- /**
1656
- * Generate a timestamp string for backup filenames
1657
- */
1658
- function generateBackupTimestamp(): string {
1659
- const now = new Date()
1660
- return now.toISOString().replace(/:/g, '').split('.')[0]
1661
- }
1662
-
1663
- /**
1664
- * Get file extension for backup format
1665
- */
1666
- function getBackupExtension(format: 'sql' | 'dump', engine: string): string {
1667
- if (format === 'sql') {
1668
- return '.sql'
1669
- }
1670
- return engine === 'mysql' ? '.sql.gz' : '.dump'
1671
- }
1672
-
1673
- async function handleBackup(): Promise<void> {
1674
- const containers = await containerManager.list()
1675
- const running = containers.filter((c) => c.status === 'running')
1676
-
1677
- if (running.length === 0) {
1678
- console.log(warning('No running containers. Start a container first.'))
1679
- await inquirer.prompt([
1680
- {
1681
- type: 'input',
1682
- name: 'continue',
1683
- message: chalk.gray('Press Enter to continue...'),
1684
- },
1685
- ])
1686
- return
1687
- }
1688
-
1689
- // Select container
1690
- const containerName = await promptContainerSelect(
1691
- running,
1692
- 'Select container to backup:',
1693
- )
1694
- if (!containerName) return
1695
-
1696
- const config = await containerManager.getConfig(containerName)
1697
- if (!config) {
1698
- console.log(error(`Container "${containerName}" not found`))
1699
- return
1700
- }
1701
-
1702
- const engine = getEngine(config.engine)
1703
-
1704
- // Check for required tools
1705
- const depsSpinner = createSpinner('Checking required tools...')
1706
- depsSpinner.start()
1707
-
1708
- let missingDeps = await getMissingDependencies(config.engine)
1709
- if (missingDeps.length > 0) {
1710
- depsSpinner.warn(
1711
- `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
1712
- )
1713
-
1714
- const installed = await promptInstallDependencies(
1715
- missingDeps[0].binary,
1716
- config.engine,
1717
- )
1718
-
1719
- if (!installed) {
1720
- return
1721
- }
1722
-
1723
- missingDeps = await getMissingDependencies(config.engine)
1724
- if (missingDeps.length > 0) {
1725
- console.log(
1726
- error(
1727
- `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
1728
- ),
1729
- )
1730
- return
1731
- }
1732
-
1733
- console.log(chalk.green(' ✓ All required tools are now available'))
1734
- console.log()
1735
- } else {
1736
- depsSpinner.succeed('Required tools available')
1737
- }
1738
-
1739
- // Select database
1740
- const databases = config.databases || [config.database]
1741
- let databaseName: string
1742
-
1743
- if (databases.length > 1) {
1744
- databaseName = await promptDatabaseSelect(
1745
- databases,
1746
- 'Select database to backup:',
1747
- )
1748
- } else {
1749
- databaseName = databases[0]
1750
- }
1751
-
1752
- // Select format
1753
- const format = await promptBackupFormat(config.engine)
1754
-
1755
- // Get filename
1756
- const defaultFilename = `${containerName}-${databaseName}-backup-${generateBackupTimestamp()}`
1757
- const filename = await promptBackupFilename(defaultFilename)
1758
-
1759
- // Build output path
1760
- const extension = getBackupExtension(format, config.engine)
1761
- const outputPath = join(process.cwd(), `${filename}${extension}`)
1762
-
1763
- // Create backup
1764
- const backupSpinner = createSpinner(
1765
- `Creating ${format === 'sql' ? 'SQL' : 'dump'} backup of "${databaseName}"...`,
1766
- )
1767
- backupSpinner.start()
1768
-
1769
- try {
1770
- const result = await engine.backup(config, outputPath, {
1771
- database: databaseName,
1772
- format,
1773
- })
1774
-
1775
- backupSpinner.succeed('Backup created successfully')
1776
-
1777
- console.log()
1778
- console.log(success('Backup complete'))
1779
- console.log()
1780
- console.log(chalk.gray(' File:'), chalk.cyan(result.path))
1781
- console.log(chalk.gray(' Size:'), chalk.white(formatBytes(result.size)))
1782
- console.log(chalk.gray(' Format:'), chalk.white(result.format))
1783
- console.log()
1784
- } catch (err) {
1785
- const e = err as Error
1786
- backupSpinner.fail('Backup failed')
1787
- console.log()
1788
- console.log(error(e.message))
1789
- console.log()
1790
- }
1791
-
1792
- // Wait for user to see the result
1793
- await inquirer.prompt([
1794
- {
1795
- type: 'input',
1796
- name: 'continue',
1797
- message: chalk.gray('Press Enter to continue...'),
1798
- },
1799
- ])
1800
- }
1801
-
1802
- async function handleClone(): Promise<void> {
1803
- const containers = await containerManager.list()
1804
- const stopped = containers.filter((c) => c.status !== 'running')
1805
-
1806
- if (containers.length === 0) {
1807
- console.log(warning('No containers found'))
1808
- return
1809
- }
1810
-
1811
- if (stopped.length === 0) {
1812
- console.log(
1813
- warning(
1814
- 'All containers are running. Stop a container first to clone it.',
1815
- ),
1816
- )
1817
- return
1818
- }
1819
-
1820
- const sourceName = await promptContainerSelect(
1821
- stopped,
1822
- 'Select container to clone:',
1823
- )
1824
- if (!sourceName) return
1825
-
1826
- const { targetName } = await inquirer.prompt<{ targetName: string }>([
1827
- {
1828
- type: 'input',
1829
- name: 'targetName',
1830
- message: 'Name for the cloned container:',
1831
- default: `${sourceName}-copy`,
1832
- validate: (input: string) => {
1833
- if (!input) return 'Name is required'
1834
- if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
1835
- return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
1836
- }
1837
- return true
1838
- },
1839
- },
1840
- ])
1841
-
1842
- const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
1843
- spinner.start()
1844
-
1845
- const newConfig = await containerManager.clone(sourceName, targetName)
1846
-
1847
- spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
1848
-
1849
- const engine = getEngine(newConfig.engine)
1850
- const connectionString = engine.getConnectionString(newConfig)
1851
-
1852
- console.log()
1853
- console.log(connectionBox(targetName, connectionString, newConfig.port))
1854
- }
1855
-
1856
- async function handleStartContainer(containerName: string): Promise<void> {
1857
- const config = await containerManager.getConfig(containerName)
1858
- if (!config) {
1859
- console.error(error(`Container "${containerName}" not found`))
1860
- return
1861
- }
1862
-
1863
- // Check port availability
1864
- const portAvailable = await portManager.isPortAvailable(config.port)
1865
- if (!portAvailable) {
1866
- console.log(
1867
- warning(
1868
- `Port ${config.port} is in use. Stop the process using it or change this container's port.`,
1869
- ),
1870
- )
1871
- console.log()
1872
- console.log(
1873
- info(
1874
- 'Tip: If you installed MariaDB via apt, it may have started a system service.',
1875
- ),
1876
- )
1877
- console.log(
1878
- info(
1879
- 'Run: sudo systemctl stop mariadb && sudo systemctl disable mariadb',
1880
- ),
1881
- )
1882
- return
1883
- }
1884
-
1885
- const engine = getEngine(config.engine)
1886
-
1887
- const spinner = createSpinner(`Starting ${containerName}...`)
1888
- spinner.start()
1889
-
1890
- try {
1891
- await engine.start(config)
1892
- await containerManager.updateConfig(containerName, { status: 'running' })
1893
-
1894
- spinner.succeed(`Container "${containerName}" started`)
1895
-
1896
- const connectionString = engine.getConnectionString(config)
1897
- console.log()
1898
- console.log(chalk.gray(' Connection string:'))
1899
- console.log(chalk.cyan(` ${connectionString}`))
1900
- } catch (err) {
1901
- spinner.fail(`Failed to start "${containerName}"`)
1902
- const e = err as Error
1903
- console.log()
1904
- console.log(error(e.message))
1905
-
1906
- // Check if there's a log file with more details
1907
- const logPath = paths.getContainerLogPath(containerName, {
1908
- engine: config.engine,
1909
- })
1910
- if (existsSync(logPath)) {
1911
- console.log()
1912
- console.log(info(`Check the log file for details: ${logPath}`))
1913
- }
1914
- }
1915
- }
1916
-
1917
- async function handleStopContainer(containerName: string): Promise<void> {
1918
- const config = await containerManager.getConfig(containerName)
1919
- if (!config) {
1920
- console.error(error(`Container "${containerName}" not found`))
1921
- return
1922
- }
1923
-
1924
- const engine = getEngine(config.engine)
1925
-
1926
- const spinner = createSpinner(`Stopping ${containerName}...`)
1927
- spinner.start()
1928
-
1929
- await engine.stop(config)
1930
- await containerManager.updateConfig(containerName, { status: 'stopped' })
1931
-
1932
- spinner.succeed(`Container "${containerName}" stopped`)
1933
- }
1934
-
1935
- async function handleEditContainer(
1936
- containerName: string,
1937
- ): Promise<string | null> {
1938
- const config = await containerManager.getConfig(containerName)
1939
- if (!config) {
1940
- console.error(error(`Container "${containerName}" not found`))
1941
- return null
1942
- }
1943
-
1944
- console.clear()
1945
- console.log(header(`Edit: ${containerName}`))
1946
- console.log()
1947
-
1948
- const editChoices = [
1949
- {
1950
- name: `Name: ${chalk.white(containerName)}`,
1951
- value: 'name',
1952
- },
1953
- {
1954
- name: `Port: ${chalk.white(String(config.port))}`,
1955
- value: 'port',
1956
- },
1957
- new inquirer.Separator(),
1958
- { name: `${chalk.blue('←')} Back to container`, value: 'back' },
1959
- { name: `${chalk.blue('⌂')} Back to main menu`, value: 'main' },
1960
- ]
1961
-
1962
- const { field } = await inquirer.prompt<{ field: string }>([
1963
- {
1964
- type: 'list',
1965
- name: 'field',
1966
- message: 'Select field to edit:',
1967
- choices: editChoices,
1968
- pageSize: 10,
1969
- },
1970
- ])
1971
-
1972
- if (field === 'back') {
1973
- return containerName
1974
- }
1975
-
1976
- if (field === 'main') {
1977
- return null // Signal to go back to main menu
1978
- }
1979
-
1980
- if (field === 'name') {
1981
- const { newName } = await inquirer.prompt<{ newName: string }>([
1982
- {
1983
- type: 'input',
1984
- name: 'newName',
1985
- message: 'New name:',
1986
- default: containerName,
1987
- validate: (input: string) => {
1988
- if (!input) return 'Name is required'
1989
- if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
1990
- return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
1991
- }
1992
- return true
1993
- },
1994
- },
1995
- ])
1996
-
1997
- if (newName === containerName) {
1998
- console.log(info('Name unchanged'))
1999
- return await handleEditContainer(containerName)
2000
- }
2001
-
2002
- // Check if new name already exists
2003
- if (await containerManager.exists(newName)) {
2004
- console.log(error(`Container "${newName}" already exists`))
2005
- return await handleEditContainer(containerName)
2006
- }
2007
-
2008
- const spinner = createSpinner('Renaming container...')
2009
- spinner.start()
2010
-
2011
- await containerManager.rename(containerName, newName)
2012
-
2013
- spinner.succeed(`Renamed "${containerName}" to "${newName}"`)
2014
-
2015
- // Continue editing with new name
2016
- return await handleEditContainer(newName)
2017
- }
2018
-
2019
- if (field === 'port') {
2020
- const { newPort } = await inquirer.prompt<{ newPort: number }>([
2021
- {
2022
- type: 'input',
2023
- name: 'newPort',
2024
- message: 'New port:',
2025
- default: String(config.port),
2026
- validate: (input: string) => {
2027
- const num = parseInt(input, 10)
2028
- if (isNaN(num) || num < 1 || num > 65535) {
2029
- return 'Port must be a number between 1 and 65535'
2030
- }
2031
- return true
2032
- },
2033
- filter: (input: string) => parseInt(input, 10),
2034
- },
2035
- ])
2036
-
2037
- if (newPort === config.port) {
2038
- console.log(info('Port unchanged'))
2039
- return await handleEditContainer(containerName)
2040
- }
2041
-
2042
- // Check if port is in use
2043
- const portAvailable = await portManager.isPortAvailable(newPort)
2044
- if (!portAvailable) {
2045
- console.log(
2046
- warning(
2047
- `Port ${newPort} is currently in use. You'll need to stop the process using it before starting this container.`,
2048
- ),
2049
- )
2050
- }
2051
-
2052
- await containerManager.updateConfig(containerName, { port: newPort })
2053
- console.log(success(`Changed port from ${config.port} to ${newPort}`))
2054
-
2055
- // Continue editing
2056
- return await handleEditContainer(containerName)
2057
- }
2058
-
2059
- return containerName
2060
- }
2061
-
2062
- async function handleCloneFromSubmenu(sourceName: string): Promise<void> {
2063
- const { targetName } = await inquirer.prompt<{ targetName: string }>([
2064
- {
2065
- type: 'input',
2066
- name: 'targetName',
2067
- message: 'Name for the cloned container:',
2068
- default: `${sourceName}-copy`,
2069
- validate: (input: string) => {
2070
- if (!input) return 'Name is required'
2071
- if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
2072
- return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
2073
- }
2074
- return true
2075
- },
2076
- },
2077
- ])
2078
-
2079
- const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
2080
- spinner.start()
2081
-
2082
- const newConfig = await containerManager.clone(sourceName, targetName)
2083
-
2084
- spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
2085
-
2086
- const engine = getEngine(newConfig.engine)
2087
- const connectionString = engine.getConnectionString(newConfig)
2088
-
2089
- console.log()
2090
- console.log(connectionBox(targetName, connectionString, newConfig.port))
2091
-
2092
- // Go to the new container's submenu
2093
- await showContainerSubmenu(targetName)
2094
- }
2095
-
2096
- async function handleDelete(containerName: string): Promise<void> {
2097
- const config = await containerManager.getConfig(containerName)
2098
- if (!config) {
2099
- console.error(error(`Container "${containerName}" not found`))
2100
- return
2101
- }
2102
-
2103
- const confirmed = await promptConfirm(
2104
- `Are you sure you want to delete "${containerName}"? This cannot be undone.`,
2105
- false,
2106
- )
2107
-
2108
- if (!confirmed) {
2109
- console.log(warning('Deletion cancelled'))
2110
- return
2111
- }
2112
-
2113
- const isRunning = await processManager.isRunning(containerName, {
2114
- engine: config.engine,
2115
- })
2116
-
2117
- if (isRunning) {
2118
- const stopSpinner = createSpinner(`Stopping ${containerName}...`)
2119
- stopSpinner.start()
2120
-
2121
- const engine = getEngine(config.engine)
2122
- await engine.stop(config)
2123
-
2124
- stopSpinner.succeed(`Stopped "${containerName}"`)
2125
- }
2126
-
2127
- const deleteSpinner = createSpinner(`Deleting ${containerName}...`)
2128
- deleteSpinner.start()
2129
-
2130
- await containerManager.delete(containerName, { force: true })
2131
-
2132
- deleteSpinner.succeed(`Container "${containerName}" deleted`)
2133
- }
2134
-
2135
- type InstalledPostgresEngine = {
2136
- engine: 'postgresql'
2137
- version: string
2138
- platform: string
2139
- arch: string
2140
- path: string
2141
- sizeBytes: number
2142
- source: 'downloaded'
2143
- }
2144
-
2145
- type InstalledMysqlEngine = {
2146
- engine: 'mysql'
2147
- version: string
2148
- path: string
2149
- source: 'system'
2150
- isMariaDB: boolean
2151
- }
2152
-
2153
- type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
2154
-
2155
- const execAsync = promisify(exec)
2156
-
2157
- /**
2158
- * Get the actual PostgreSQL version from the binary
2159
- */
2160
- async function getPostgresVersionFromBinary(
2161
- binPath: string,
2162
- ): Promise<string | null> {
2163
- const postgresPath = join(binPath, 'bin', 'postgres')
2164
- if (!existsSync(postgresPath)) {
2165
- return null
2166
- }
2167
-
2168
- try {
2169
- const { stdout } = await execAsync(`"${postgresPath}" --version`)
2170
- // Output: postgres (PostgreSQL) 17.7
2171
- const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
2172
- return match ? match[1] : null
2173
- } catch {
2174
- return null
2175
- }
2176
- }
2177
-
2178
- async function getInstalledEngines(): Promise<InstalledEngine[]> {
2179
- const engines: InstalledEngine[] = []
2180
-
2181
- // Get PostgreSQL engines from ~/.spindb/bin/
2182
- const binDir = paths.bin
2183
- if (existsSync(binDir)) {
2184
- const entries = await readdir(binDir, { withFileTypes: true })
2185
-
2186
- for (const entry of entries) {
2187
- if (entry.isDirectory()) {
2188
- // Parse directory name: postgresql-17-darwin-arm64
2189
- const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
2190
- if (match && match[1] === 'postgresql') {
2191
- const [, , majorVersion, platform, arch] = match
2192
- const dirPath = join(binDir, entry.name)
2193
-
2194
- // Get actual version from the binary
2195
- const actualVersion =
2196
- (await getPostgresVersionFromBinary(dirPath)) || majorVersion
2197
-
2198
- // Get directory size (using lstat to avoid following symlinks)
2199
- let sizeBytes = 0
2200
- try {
2201
- const files = await readdir(dirPath, { recursive: true })
2202
- for (const file of files) {
2203
- try {
2204
- const filePath = join(dirPath, file.toString())
2205
- const fileStat = await lstat(filePath)
2206
- // Only count regular files (not symlinks or directories)
2207
- if (fileStat.isFile()) {
2208
- sizeBytes += fileStat.size
2209
- }
2210
- } catch {
2211
- // Skip files we can't stat
2212
- }
2213
- }
2214
- } catch {
2215
- // Skip directories we can't read
2216
- }
2217
-
2218
- engines.push({
2219
- engine: 'postgresql',
2220
- version: actualVersion,
2221
- platform,
2222
- arch,
2223
- path: dirPath,
2224
- sizeBytes,
2225
- source: 'downloaded',
2226
- })
2227
- }
2228
- }
2229
- }
2230
- }
2231
-
2232
- // Detect system-installed MySQL
2233
- const mysqldPath = await getMysqldPath()
2234
- if (mysqldPath) {
2235
- const version = await getMysqlVersion(mysqldPath)
2236
- if (version) {
2237
- const mariadb = await isMariaDB()
2238
- engines.push({
2239
- engine: 'mysql',
2240
- version,
2241
- path: mysqldPath,
2242
- source: 'system',
2243
- isMariaDB: mariadb,
2244
- })
2245
- }
2246
- }
2247
-
2248
- // Sort PostgreSQL by version (descending), MySQL stays at end
2249
- const pgEngines = engines.filter(
2250
- (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
2251
- )
2252
- const mysqlEngine = engines.find(
2253
- (e): e is InstalledMysqlEngine => e.engine === 'mysql',
2254
- )
2255
-
2256
- pgEngines.sort((a, b) => compareVersions(b.version, a.version))
2257
-
2258
- const result: InstalledEngine[] = [...pgEngines]
2259
- if (mysqlEngine) {
2260
- result.push(mysqlEngine)
2261
- }
2262
-
2263
- return result
2264
- }
2265
-
2266
- function compareVersions(a: string, b: string): number {
2267
- const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
2268
- const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
2269
-
2270
- for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
2271
- const numA = partsA[i] || 0
2272
- const numB = partsB[i] || 0
2273
- if (numA !== numB) return numA - numB
2274
- }
2275
- return 0
2276
- }
2277
-
2278
- async function handleEngines(): Promise<void> {
2279
- console.clear()
2280
- console.log(header('Installed Engines'))
2281
- console.log()
2282
-
2283
- const engines = await getInstalledEngines()
2284
-
2285
- if (engines.length === 0) {
2286
- console.log(info('No engines installed yet.'))
2287
- console.log(
2288
- chalk.gray(
2289
- ' PostgreSQL engines are downloaded automatically when you create a container.',
2290
- ),
2291
- )
2292
- console.log(
2293
- chalk.gray(
2294
- ' MySQL requires system installation (brew install mysql or apt install mysql-server).',
2295
- ),
2296
- )
2297
- return
2298
- }
2299
-
2300
- // Separate PostgreSQL and MySQL
2301
- const pgEngines = engines.filter(
2302
- (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
2303
- )
2304
- const mysqlEngine = engines.find(
2305
- (e): e is InstalledMysqlEngine => e.engine === 'mysql',
2306
- )
2307
-
2308
- // Calculate total size for PostgreSQL
2309
- const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
2310
-
2311
- // Table header
2312
- console.log()
2313
- console.log(
2314
- chalk.gray(' ') +
2315
- chalk.bold.white('ENGINE'.padEnd(14)) +
2316
- chalk.bold.white('VERSION'.padEnd(12)) +
2317
- chalk.bold.white('SOURCE'.padEnd(18)) +
2318
- chalk.bold.white('SIZE'),
2319
- )
2320
- console.log(chalk.gray(' ' + '─'.repeat(55)))
2321
-
2322
- // PostgreSQL rows
2323
- for (const engine of pgEngines) {
2324
- const icon = engineIcons[engine.engine] || '▣'
2325
- const platformInfo = `${engine.platform}-${engine.arch}`
2326
-
2327
- console.log(
2328
- chalk.gray(' ') +
2329
- chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
2330
- chalk.yellow(engine.version.padEnd(12)) +
2331
- chalk.gray(platformInfo.padEnd(18)) +
2332
- chalk.white(formatBytes(engine.sizeBytes)),
2333
- )
2334
- }
2335
-
2336
- // MySQL row
2337
- if (mysqlEngine) {
2338
- const icon = engineIcons.mysql
2339
- const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
2340
-
2341
- console.log(
2342
- chalk.gray(' ') +
2343
- chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
2344
- chalk.yellow(mysqlEngine.version.padEnd(12)) +
2345
- chalk.gray('system'.padEnd(18)) +
2346
- chalk.gray('(system-installed)'),
2347
- )
2348
- }
2349
-
2350
- console.log(chalk.gray(' ' + '─'.repeat(55)))
2351
-
2352
- // Summary
2353
- console.log()
2354
- if (pgEngines.length > 0) {
2355
- console.log(
2356
- chalk.gray(
2357
- ` PostgreSQL: ${pgEngines.length} version(s), ${formatBytes(totalPgSize)}`,
2358
- ),
2359
- )
2360
- }
2361
- if (mysqlEngine) {
2362
- console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
2363
- }
2364
- console.log()
2365
-
2366
- // Menu options - only allow deletion of PostgreSQL engines
2367
- const choices: MenuChoice[] = []
2368
-
2369
- for (const e of pgEngines) {
2370
- choices.push({
2371
- name: `${chalk.red('✕')} Delete ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
2372
- value: `delete:${e.path}:${e.engine}:${e.version}`,
2373
- })
2374
- }
2375
-
2376
- // MySQL info option (not disabled, shows info icon)
2377
- if (mysqlEngine) {
2378
- const displayName = mysqlEngine.isMariaDB ? 'MariaDB' : 'MySQL'
2379
- choices.push({
2380
- name: `${chalk.blue('ℹ')} ${displayName} ${mysqlEngine.version} ${chalk.gray('(system-installed)')}`,
2381
- value: `mysql-info:${mysqlEngine.path}`,
2382
- })
2383
- }
2384
-
2385
- choices.push(new inquirer.Separator())
2386
- choices.push({ name: `${chalk.blue('←')} Back to main menu`, value: 'back' })
2387
-
2388
- const { action } = await inquirer.prompt<{ action: string }>([
2389
- {
2390
- type: 'list',
2391
- name: 'action',
2392
- message: 'Manage engines:',
2393
- choices,
2394
- pageSize: 15,
2395
- },
2396
- ])
2397
-
2398
- if (action === 'back') {
2399
- return
2400
- }
2401
-
2402
- if (action.startsWith('delete:')) {
2403
- const [, enginePath, engineName, engineVersion] = action.split(':')
2404
- await handleDeleteEngine(enginePath, engineName, engineVersion)
2405
- // Return to engines menu
2406
- await handleEngines()
2407
- }
2408
-
2409
- if (action.startsWith('mysql-info:')) {
2410
- const mysqldPath = action.replace('mysql-info:', '')
2411
- await handleMysqlInfo(mysqldPath)
2412
- // Return to engines menu
2413
- await handleEngines()
2414
- }
2415
- }
2416
-
2417
- async function handleDeleteEngine(
2418
- enginePath: string,
2419
- engineName: string,
2420
- engineVersion: string,
2421
- ): Promise<void> {
2422
- // Check if any container is using this engine version
2423
- const containers = await containerManager.list()
2424
- const usingContainers = containers.filter(
2425
- (c) => c.engine === engineName && c.version === engineVersion,
2426
- )
2427
-
2428
- if (usingContainers.length > 0) {
2429
- console.log()
2430
- console.log(
2431
- error(
2432
- `Cannot delete: ${usingContainers.length} container(s) are using ${engineName} ${engineVersion}`,
2433
- ),
2434
- )
2435
- console.log(
2436
- chalk.gray(
2437
- ` Containers: ${usingContainers.map((c) => c.name).join(', ')}`,
2438
- ),
2439
- )
2440
- console.log()
2441
- await inquirer.prompt([
2442
- {
2443
- type: 'input',
2444
- name: 'continue',
2445
- message: chalk.gray('Press Enter to continue...'),
2446
- },
2447
- ])
2448
- return
2449
- }
2450
-
2451
- const confirmed = await promptConfirm(
2452
- `Delete ${engineName} ${engineVersion}? This cannot be undone.`,
2453
- false,
2454
- )
2455
-
2456
- if (!confirmed) {
2457
- console.log(warning('Deletion cancelled'))
2458
- return
2459
- }
2460
-
2461
- const spinner = createSpinner(`Deleting ${engineName} ${engineVersion}...`)
2462
- spinner.start()
2463
-
2464
- try {
2465
- await rm(enginePath, { recursive: true, force: true })
2466
- spinner.succeed(`Deleted ${engineName} ${engineVersion}`)
2467
- } catch (err) {
2468
- const e = err as Error
2469
- spinner.fail(`Failed to delete: ${e.message}`)
2470
- }
2471
- }
2472
-
2473
- async function handleMysqlInfo(mysqldPath: string): Promise<void> {
2474
- console.clear()
2475
-
2476
- // Get install info
2477
- const installInfo = await getMysqlInstallInfo(mysqldPath)
2478
- const displayName = installInfo.isMariaDB ? 'MariaDB' : 'MySQL'
2479
-
2480
- // Get version
2481
- const version = await getMysqlVersion(mysqldPath)
2482
-
2483
- console.log(header(`${displayName} Information`))
2484
- console.log()
2485
-
2486
- // Check for containers using MySQL
2487
- const containers = await containerManager.list()
2488
- const mysqlContainers = containers.filter((c) => c.engine === 'mysql')
2489
-
2490
- // Track running containers for uninstall instructions
2491
- const runningContainers: string[] = []
2492
-
2493
- if (mysqlContainers.length > 0) {
2494
- console.log(
2495
- warning(
2496
- `${mysqlContainers.length} container(s) are using ${displayName}:`,
2497
- ),
2498
- )
2499
- console.log()
2500
- for (const c of mysqlContainers) {
2501
- const isRunning = await processManager.isRunning(c.name, {
2502
- engine: c.engine,
2503
- })
2504
- if (isRunning) {
2505
- runningContainers.push(c.name)
2506
- }
2507
- const status = isRunning
2508
- ? chalk.green('● running')
2509
- : chalk.gray('○ stopped')
2510
- console.log(chalk.gray(` • ${c.name} ${status}`))
2511
- }
2512
- console.log()
2513
- console.log(
2514
- chalk.yellow(
2515
- ' Uninstalling will break these containers. Delete them first.',
2516
- ),
2517
- )
2518
- console.log()
2519
- }
2520
-
2521
- // Show installation details
2522
- console.log(chalk.white(' Installation Details:'))
2523
- console.log(chalk.gray(' ' + '─'.repeat(50)))
2524
- console.log(
2525
- chalk.gray(' ') +
2526
- chalk.white('Version:'.padEnd(18)) +
2527
- chalk.yellow(version || 'unknown'),
2528
- )
2529
- console.log(
2530
- chalk.gray(' ') +
2531
- chalk.white('Binary Path:'.padEnd(18)) +
2532
- chalk.gray(mysqldPath),
2533
- )
2534
- console.log(
2535
- chalk.gray(' ') +
2536
- chalk.white('Package Manager:'.padEnd(18)) +
2537
- chalk.cyan(installInfo.packageManager),
2538
- )
2539
- console.log(
2540
- chalk.gray(' ') +
2541
- chalk.white('Package Name:'.padEnd(18)) +
2542
- chalk.cyan(installInfo.packageName),
2543
- )
2544
- console.log()
2545
-
2546
- // Uninstall instructions
2547
- console.log(chalk.white(' To uninstall:'))
2548
- console.log(chalk.gray(' ' + '─'.repeat(50)))
2549
-
2550
- let stepNum = 1
2551
-
2552
- // Step: Stop running containers first
2553
- if (runningContainers.length > 0) {
2554
- console.log(chalk.gray(` # ${stepNum}. Stop running SpinDB containers`))
2555
- console.log(chalk.cyan(' spindb stop <container-name>'))
2556
- console.log()
2557
- stepNum++
2558
- }
2559
-
2560
- // Step: Delete SpinDB containers
2561
- if (mysqlContainers.length > 0) {
2562
- console.log(chalk.gray(` # ${stepNum}. Delete SpinDB containers`))
2563
- console.log(chalk.cyan(' spindb delete <container-name>'))
2564
- console.log()
2565
- stepNum++
2566
- }
2567
-
2568
- if (installInfo.packageManager === 'homebrew') {
2569
- console.log(
2570
- chalk.gray(
2571
- ` # ${stepNum}. Stop Homebrew service (if running separately)`,
2572
- ),
2573
- )
2574
- console.log(chalk.cyan(` brew services stop ${installInfo.packageName}`))
2575
- console.log()
2576
- console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2577
- console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2578
- } else if (installInfo.packageManager === 'apt') {
2579
- console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2580
- console.log(
2581
- chalk.cyan(
2582
- ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
2583
- ),
2584
- )
2585
- console.log()
2586
- console.log(chalk.gray(` # ${stepNum + 1}. Disable auto-start on boot`))
2587
- console.log(
2588
- chalk.cyan(
2589
- ` sudo systemctl disable ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
2590
- ),
2591
- )
2592
- console.log()
2593
- console.log(chalk.gray(` # ${stepNum + 2}. Uninstall the package`))
2594
- console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2595
- console.log()
2596
- console.log(chalk.gray(` # ${stepNum + 3}. Remove data files (optional)`))
2597
- console.log(
2598
- chalk.cyan(' sudo apt purge mysql-server mysql-client mysql-common'),
2599
- )
2600
- console.log(chalk.cyan(' sudo rm -rf /var/lib/mysql /etc/mysql'))
2601
- } else if (
2602
- installInfo.packageManager === 'yum' ||
2603
- installInfo.packageManager === 'dnf'
2604
- ) {
2605
- console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2606
- console.log(
2607
- chalk.cyan(
2608
- ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
2609
- ),
2610
- )
2611
- console.log()
2612
- console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2613
- console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2614
- } else if (installInfo.packageManager === 'pacman') {
2615
- console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
2616
- console.log(
2617
- chalk.cyan(
2618
- ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
2619
- ),
2620
- )
2621
- console.log()
2622
- console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
2623
- console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
2624
- } else {
2625
- console.log(chalk.gray(' Use your system package manager to uninstall.'))
2626
- console.log(chalk.gray(` The binary is located at: ${mysqldPath}`))
2627
- }
2628
-
2629
- console.log()
2630
-
2631
- // Wait for user
2632
- await inquirer.prompt([
2633
- {
2634
- type: 'input',
2635
- name: 'continue',
2636
- message: chalk.gray('Press Enter to go back...'),
2637
- },
2638
- ])
2639
- }
2640
-
2641
- export const menuCommand = new Command('menu')
2642
- .description('Interactive menu for managing containers')
2643
- .action(async () => {
2644
- try {
2645
- await showMainMenu()
2646
- } catch (err) {
2647
- const e = err as Error
2648
-
2649
- // Check if this is a missing tool error
2650
- if (
2651
- e.message.includes('pg_restore not found') ||
2652
- e.message.includes('psql not found') ||
2653
- e.message.includes('pg_dump not found')
2654
- ) {
2655
- const missingTool = e.message.includes('pg_restore')
2656
- ? 'pg_restore'
2657
- : e.message.includes('pg_dump')
2658
- ? 'pg_dump'
2659
- : 'psql'
2660
- const installed = await promptInstallDependencies(missingTool)
2661
- if (installed) {
2662
- console.log(chalk.yellow(' Please re-run spindb to continue.'))
2663
- }
2664
- process.exit(1)
2665
- }
2666
-
2667
- console.error(error(e.message))
2668
- process.exit(1)
2669
- }
2670
- })