spindb 0.1.0 → 0.2.1

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,16 +1,13 @@
1
1
  import { Command } from 'commander'
2
- import inquirer from 'inquirer'
3
2
  import chalk from 'chalk'
4
3
  import { containerManager } from '@/core/container-manager'
5
4
  import { processManager } from '@/core/process-manager'
6
5
  import { getEngine } from '@/engines'
7
- import { portManager } from '@/core/port-manager'
8
- import { defaults } from '@/config/defaults'
9
6
  import {
10
- promptCreateOptions,
11
7
  promptContainerSelect,
12
- promptConfirm,
13
8
  promptDatabaseName,
9
+ promptCreateOptions,
10
+ promptConfirm,
14
11
  } from '@/cli/ui/prompts'
15
12
  import { createSpinner } from '@/cli/ui/spinner'
16
13
  import {
@@ -22,13 +19,22 @@ import {
22
19
  connectionBox,
23
20
  } from '@/cli/ui/theme'
24
21
  import { existsSync } from 'fs'
22
+ import { readdir, rm, lstat } from 'fs/promises'
25
23
  import { spawn } from 'child_process'
24
+ import { platform } from 'os'
25
+ import { join } from 'path'
26
+ import { paths } from '@/config/paths'
27
+ import { portManager } from '@/core/port-manager'
28
+ import { defaults } from '@/config/defaults'
29
+ import inquirer from 'inquirer'
26
30
 
27
- interface MenuChoice {
28
- name: string
29
- value: string
30
- disabled?: boolean | string
31
- }
31
+ type MenuChoice =
32
+ | {
33
+ name: string
34
+ value: string
35
+ disabled?: boolean | string
36
+ }
37
+ | inquirer.Separator
32
38
 
33
39
  async function showMainMenu(): Promise<void> {
34
40
  console.clear()
@@ -46,43 +52,70 @@ async function showMainMenu(): Promise<void> {
46
52
  )
47
53
  console.log()
48
54
 
55
+ const canStart = stopped > 0
56
+ const canStop = running > 0
57
+ const canConnect = running > 0
58
+ const canRestore = running > 0
59
+ const canClone = containers.length > 0
60
+
61
+ // Check if any engines are installed
62
+ const engines = await getInstalledEngines()
63
+ const hasEngines = engines.length > 0
64
+
65
+ // If containers exist, show List first; otherwise show Create first
66
+ const hasContainers = containers.length > 0
67
+
49
68
  const choices: MenuChoice[] = [
50
- { name: `${chalk.green('+')} Create new container`, value: 'create' },
51
- { name: `${chalk.cyan('◉')} List containers`, value: 'list' },
69
+ ...(hasContainers
70
+ ? [
71
+ { name: `${chalk.cyan('◉')} List containers`, value: 'list' },
72
+ { name: `${chalk.green('+')} Create new container`, value: 'create' },
73
+ ]
74
+ : [
75
+ { name: `${chalk.green('+')} Create new container`, value: 'create' },
76
+ { name: `${chalk.cyan('◉')} List containers`, value: 'list' },
77
+ ]),
52
78
  {
53
- name: `${chalk.green('▶')} Start a container`,
79
+ name: canStart
80
+ ? `${chalk.green('▶')} Start a container`
81
+ : chalk.gray('▶ Start a container'),
54
82
  value: 'start',
55
- disabled: stopped === 0 ? 'No stopped containers' : false,
83
+ disabled: canStart ? false : 'No stopped containers',
56
84
  },
57
85
  {
58
- name: `${chalk.yellow('■')} Stop a container`,
86
+ name: canStop
87
+ ? `${chalk.yellow('■')} Stop a container`
88
+ : chalk.gray('■ Stop a container'),
59
89
  value: 'stop',
60
- disabled: running === 0 ? 'No running containers' : false,
90
+ disabled: canStop ? false : 'No running containers',
61
91
  },
62
92
  {
63
- name: `${chalk.blue('⌘')} Open psql shell`,
93
+ name: canConnect
94
+ ? `${chalk.blue('⌘')} Open psql shell`
95
+ : chalk.gray('⌘ Open psql shell'),
64
96
  value: 'connect',
65
- disabled: running === 0 ? 'No running containers' : false,
97
+ disabled: canConnect ? false : 'No running containers',
66
98
  },
67
99
  {
68
- name: `${chalk.magenta('↓')} Restore backup`,
100
+ name: canRestore
101
+ ? `${chalk.magenta('↓')} Restore backup`
102
+ : chalk.gray('↓ Restore backup'),
69
103
  value: 'restore',
70
- disabled: running === 0 ? 'No running containers' : false,
104
+ disabled: canRestore ? false : 'No running containers',
71
105
  },
72
106
  {
73
- name: `${chalk.cyan('⧉')} Clone a container`,
107
+ name: canClone
108
+ ? `${chalk.cyan('⧉')} Clone a container`
109
+ : chalk.gray('⧉ Clone a container'),
74
110
  value: 'clone',
75
- disabled: containers.length === 0 ? 'No containers' : false,
76
- },
77
- {
78
- name: `${chalk.white('⚙')} Change port`,
79
- value: 'port',
80
- disabled: stopped === 0 ? 'No stopped containers' : false,
111
+ disabled: canClone ? false : 'No containers',
81
112
  },
82
113
  {
83
- name: `${chalk.red('✕')} Delete a container`,
84
- value: 'delete',
85
- disabled: containers.length === 0 ? 'No containers' : false,
114
+ name: hasEngines
115
+ ? `${chalk.yellow('')} List installed engines`
116
+ : chalk.gray('⚙ List installed engines'),
117
+ value: 'engines',
118
+ disabled: hasEngines ? false : 'No engines installed',
86
119
  },
87
120
  new inquirer.Separator(),
88
121
  { name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
@@ -120,11 +153,8 @@ async function showMainMenu(): Promise<void> {
120
153
  case 'clone':
121
154
  await handleClone()
122
155
  break
123
- case 'port':
124
- await handleChangePort()
125
- break
126
- case 'delete':
127
- await handleDelete()
156
+ case 'engines':
157
+ await handleEngines()
128
158
  break
129
159
  case 'exit':
130
160
  console.log(chalk.gray('\n Goodbye!\n'))
@@ -132,36 +162,13 @@ async function showMainMenu(): Promise<void> {
132
162
  }
133
163
 
134
164
  // Return to menu after action
135
- await promptReturnToMenu()
136
- }
137
-
138
- async function promptReturnToMenu(): Promise<void> {
139
- console.log()
140
- const { returnToMenu } = await inquirer.prompt<{ returnToMenu: string }>([
141
- {
142
- type: 'list',
143
- name: 'returnToMenu',
144
- message: 'Return to main menu?',
145
- choices: [
146
- { name: 'Yes', value: 'yes' },
147
- { name: 'No', value: 'no' },
148
- ],
149
- default: 'yes',
150
- },
151
- ])
152
-
153
- if (returnToMenu === 'yes') {
154
- await showMainMenu()
155
- } else {
156
- console.log(chalk.gray('\n Goodbye!\n'))
157
- process.exit(0)
158
- }
165
+ await showMainMenu()
159
166
  }
160
167
 
161
168
  async function handleCreate(): Promise<void> {
162
169
  console.log()
163
170
  const answers = await promptCreateOptions()
164
- const { name: containerName, engine, version } = answers
171
+ const { name: containerName, engine, version, port, database } = answers
165
172
 
166
173
  console.log()
167
174
  console.log(header('Creating Database Container'))
@@ -169,16 +176,8 @@ async function handleCreate(): Promise<void> {
169
176
 
170
177
  const dbEngine = getEngine(engine)
171
178
 
172
- // Find available port
173
- const portSpinner = createSpinner('Finding available port...')
174
- portSpinner.start()
175
-
176
- const { port, isDefault } = await portManager.findAvailablePort()
177
- if (isDefault) {
178
- portSpinner.succeed(`Using default port ${port}`)
179
- } else {
180
- portSpinner.warn(`Default port 5432 is in use, using port ${port}`)
181
- }
179
+ // Check if port is currently in use
180
+ const portAvailable = await portManager.isPortAvailable(port)
182
181
 
183
182
  // Ensure binaries
184
183
  const binarySpinner = createSpinner(
@@ -205,41 +204,112 @@ async function handleCreate(): Promise<void> {
205
204
  engine: dbEngine.name,
206
205
  version,
207
206
  port,
207
+ database,
208
208
  })
209
209
 
210
210
  createSpinnerInstance.succeed('Container created')
211
211
 
212
- // Initialize database
213
- const initSpinner = createSpinner('Initializing database...')
212
+ // Initialize database cluster
213
+ const initSpinner = createSpinner('Initializing database cluster...')
214
214
  initSpinner.start()
215
215
 
216
216
  await dbEngine.initDataDir(containerName, version, {
217
217
  superuser: defaults.superuser,
218
218
  })
219
219
 
220
- initSpinner.succeed('Database initialized')
220
+ initSpinner.succeed('Database cluster initialized')
221
221
 
222
- // Start container
223
- const startSpinner = createSpinner('Starting PostgreSQL...')
224
- startSpinner.start()
222
+ // Start container (only if port is available)
223
+ if (portAvailable) {
224
+ const startSpinner = createSpinner('Starting PostgreSQL...')
225
+ startSpinner.start()
225
226
 
226
- const config = await containerManager.getConfig(containerName)
227
- if (config) {
228
- await dbEngine.start(config)
229
- await containerManager.updateConfig(containerName, { status: 'running' })
230
- }
227
+ const config = await containerManager.getConfig(containerName)
228
+ if (config) {
229
+ await dbEngine.start(config)
230
+ await containerManager.updateConfig(containerName, { status: 'running' })
231
+ }
232
+
233
+ startSpinner.succeed('PostgreSQL started')
234
+
235
+ // Create the user's database (if different from 'postgres')
236
+ if (config && database !== 'postgres') {
237
+ const dbSpinner = createSpinner(`Creating database "${database}"...`)
238
+ dbSpinner.start()
239
+
240
+ await dbEngine.createDatabase(config, database)
241
+
242
+ dbSpinner.succeed(`Database "${database}" created`)
243
+ }
244
+
245
+ // Show success
246
+ if (config) {
247
+ const connectionString = dbEngine.getConnectionString(config)
248
+ console.log()
249
+ console.log(success('Database Created'))
250
+ console.log()
251
+ console.log(chalk.gray(` Container: ${containerName}`))
252
+ console.log(chalk.gray(` Engine: ${dbEngine.name} ${version}`))
253
+ console.log(chalk.gray(` Database: ${database}`))
254
+ console.log(chalk.gray(` Port: ${port}`))
255
+ console.log()
256
+ console.log(success(`Started Running on port ${port}`))
257
+ console.log()
258
+ console.log(chalk.gray(' Connection string:'))
259
+ console.log(chalk.cyan(` ${connectionString}`))
231
260
 
232
- startSpinner.succeed('PostgreSQL started')
261
+ // Copy connection string to clipboard using platform-specific command
262
+ try {
263
+ const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
264
+ const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
265
+
266
+ await new Promise<void>((resolve, reject) => {
267
+ const proc = spawn(cmd, args, {
268
+ stdio: ['pipe', 'inherit', 'inherit'],
269
+ })
270
+ proc.stdin?.write(connectionString)
271
+ proc.stdin?.end()
272
+ proc.on('close', (code) => {
273
+ if (code === 0) resolve()
274
+ else reject(new Error(`Clipboard command exited with code ${code}`))
275
+ })
276
+ proc.on('error', reject)
277
+ })
278
+
279
+ console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
280
+ } catch {
281
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
282
+ }
233
283
 
234
- // Show success
235
- if (config) {
236
- const connectionString = dbEngine.getConnectionString(config)
284
+ console.log()
285
+
286
+ // Wait for user to see the result before returning to menu
287
+ await inquirer.prompt([
288
+ {
289
+ type: 'input',
290
+ name: 'continue',
291
+ message: chalk.gray('Press Enter to return to the main menu...'),
292
+ },
293
+ ])
294
+ }
295
+ } else {
237
296
  console.log()
238
- console.log(connectionBox(containerName, connectionString, port))
297
+ console.log(
298
+ warning(
299
+ `Port ${port} is currently in use. Container created but not started.`,
300
+ ),
301
+ )
302
+ console.log(
303
+ info(
304
+ `Start it later with: ${chalk.cyan(`spindb start ${containerName}`)}`,
305
+ ),
306
+ )
239
307
  }
240
308
  }
241
309
 
242
310
  async function handleList(): Promise<void> {
311
+ console.clear()
312
+ console.log(header('Containers'))
243
313
  console.log()
244
314
  const containers = await containerManager.list()
245
315
 
@@ -288,6 +358,137 @@ async function handleList(): Promise<void> {
288
358
  ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
289
359
  ),
290
360
  )
361
+
362
+ // Container selection with submenu
363
+ console.log()
364
+ const containerChoices = [
365
+ ...containers.map((c) => ({
366
+ name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${
367
+ c.status === 'running'
368
+ ? chalk.green('● running')
369
+ : chalk.gray('○ stopped')
370
+ }`,
371
+ value: c.name,
372
+ short: c.name,
373
+ })),
374
+ new inquirer.Separator(),
375
+ { name: `${chalk.blue('←')} Back to main menu`, value: 'back' },
376
+ ]
377
+
378
+ const { selectedContainer } = await inquirer.prompt<{
379
+ selectedContainer: string
380
+ }>([
381
+ {
382
+ type: 'list',
383
+ name: 'selectedContainer',
384
+ message: 'Select a container for more options:',
385
+ choices: containerChoices,
386
+ },
387
+ ])
388
+
389
+ if (selectedContainer === 'back') {
390
+ await showMainMenu()
391
+ return
392
+ }
393
+
394
+ await showContainerSubmenu(selectedContainer)
395
+ }
396
+
397
+ async function showContainerSubmenu(containerName: string): Promise<void> {
398
+ const config = await containerManager.getConfig(containerName)
399
+ if (!config) {
400
+ console.error(error(`Container "${containerName}" not found`))
401
+ return
402
+ }
403
+
404
+ // Check actual running state
405
+ const isRunning = await processManager.isRunning(containerName)
406
+ const status = isRunning ? 'running' : 'stopped'
407
+
408
+ console.clear()
409
+ console.log(header(containerName))
410
+ console.log()
411
+ console.log(
412
+ chalk.gray(
413
+ ` ${config.engine} ${config.version} on port ${config.port} - ${status}`,
414
+ ),
415
+ )
416
+ console.log()
417
+
418
+ const actionChoices: MenuChoice[] = [
419
+ // Start or Stop depending on current state
420
+ !isRunning
421
+ ? { name: `${chalk.green('▶')} Start container`, value: 'start' }
422
+ : { name: `${chalk.yellow('■')} Stop container`, value: 'stop' },
423
+ {
424
+ name: !isRunning
425
+ ? `${chalk.white('⚙')} Edit container`
426
+ : chalk.gray('⚙ Edit container'),
427
+ value: 'edit',
428
+ disabled: !isRunning ? false : 'Stop container first',
429
+ },
430
+ {
431
+ name: !isRunning
432
+ ? `${chalk.cyan('⧉')} Clone container`
433
+ : chalk.gray('⧉ Clone container'),
434
+ value: 'clone',
435
+ disabled: !isRunning ? false : 'Stop container first',
436
+ },
437
+ { name: `${chalk.magenta('⎘')} Copy connection string`, value: 'copy' },
438
+ { name: `${chalk.red('✕')} Delete container`, value: 'delete' },
439
+ new inquirer.Separator(),
440
+ { name: `${chalk.blue('←')} Back to container list`, value: 'back' },
441
+ { name: `${chalk.blue('🏠')} Back to main menu`, value: 'main' },
442
+ ]
443
+
444
+ const { action } = await inquirer.prompt<{ action: string }>([
445
+ {
446
+ type: 'list',
447
+ name: 'action',
448
+ message: 'What would you like to do?',
449
+ choices: actionChoices,
450
+ },
451
+ ])
452
+
453
+ switch (action) {
454
+ case 'start':
455
+ await handleStartContainer(containerName)
456
+ await showContainerSubmenu(containerName)
457
+ return
458
+ case 'stop':
459
+ await handleStopContainer(containerName)
460
+ await showContainerSubmenu(containerName)
461
+ return
462
+ case 'edit': {
463
+ const newName = await handleEditContainer(containerName)
464
+ if (newName === null) {
465
+ // User chose to go back to main menu
466
+ return
467
+ }
468
+ if (newName !== containerName) {
469
+ // Container was renamed, show submenu with new name
470
+ await showContainerSubmenu(newName)
471
+ } else {
472
+ await showContainerSubmenu(containerName)
473
+ }
474
+ return
475
+ }
476
+ case 'clone':
477
+ await handleCloneFromSubmenu(containerName)
478
+ return
479
+ case 'copy':
480
+ await handleCopyConnectionString(containerName)
481
+ await showContainerSubmenu(containerName)
482
+ return
483
+ case 'delete':
484
+ await handleDelete(containerName)
485
+ return // Don't show submenu again after delete
486
+ case 'back':
487
+ await handleList()
488
+ return
489
+ case 'main':
490
+ return // Return to main menu
491
+ }
291
492
  }
292
493
 
293
494
  async function handleStart(): Promise<void> {
@@ -370,6 +571,64 @@ async function handleStop(): Promise<void> {
370
571
  spinner.succeed(`Container "${containerName}" stopped`)
371
572
  }
372
573
 
574
+ async function handleCopyConnectionString(
575
+ containerName: string,
576
+ ): Promise<void> {
577
+ const config = await containerManager.getConfig(containerName)
578
+ if (!config) {
579
+ console.error(error(`Container "${containerName}" not found`))
580
+ return
581
+ }
582
+
583
+ const engine = getEngine(config.engine)
584
+ const connectionString = engine.getConnectionString(config)
585
+
586
+ // Copy to clipboard using platform-specific command
587
+ const { platform } = await import('os')
588
+ const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
589
+ const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
590
+
591
+ try {
592
+ await new Promise<void>((resolve, reject) => {
593
+ const proc = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'] })
594
+ proc.stdin?.write(connectionString)
595
+ proc.stdin?.end()
596
+ proc.on('close', (code) => {
597
+ if (code === 0) resolve()
598
+ else reject(new Error(`Clipboard command exited with code ${code}`))
599
+ })
600
+ proc.on('error', reject)
601
+ })
602
+
603
+ console.log()
604
+ console.log(success('Connection string copied to clipboard'))
605
+ console.log(chalk.gray(` ${connectionString}`))
606
+ console.log()
607
+
608
+ await inquirer.prompt([
609
+ {
610
+ type: 'input',
611
+ name: 'continue',
612
+ message: chalk.gray('Press Enter to continue...'),
613
+ },
614
+ ])
615
+ } catch {
616
+ // Fallback: just display the string
617
+ console.log()
618
+ console.log(warning('Could not copy to clipboard. Connection string:'))
619
+ console.log(chalk.cyan(` ${connectionString}`))
620
+ console.log()
621
+
622
+ await inquirer.prompt([
623
+ {
624
+ type: 'input',
625
+ name: 'continue',
626
+ message: chalk.gray('Press Enter to continue...'),
627
+ },
628
+ ])
629
+ }
630
+ }
631
+
373
632
  async function handleConnect(): Promise<void> {
374
633
  const containers = await containerManager.list()
375
634
  const running = containers.filter((c) => c.status === 'running')
@@ -441,18 +700,25 @@ async function handleRestore(): Promise<void> {
441
700
  }
442
701
 
443
702
  // Get backup file path
444
- const { backupPath } = await inquirer.prompt<{ backupPath: string }>([
703
+ // Strip quotes that terminals add when drag-and-dropping files
704
+ const stripQuotes = (path: string) => path.replace(/^['"]|['"]$/g, '').trim()
705
+
706
+ const { backupPath: rawBackupPath } = await inquirer.prompt<{
707
+ backupPath: string
708
+ }>([
445
709
  {
446
710
  type: 'input',
447
711
  name: 'backupPath',
448
- message: 'Path to backup file:',
712
+ message: 'Path to backup file (drag and drop or enter path):',
449
713
  validate: (input: string) => {
450
714
  if (!input) return 'Backup path is required'
451
- if (!existsSync(input)) return 'File not found'
715
+ const cleanPath = stripQuotes(input)
716
+ if (!existsSync(cleanPath)) return 'File not found'
452
717
  return true
453
718
  },
454
719
  },
455
720
  ])
721
+ const backupPath = stripQuotes(rawBackupPath)
456
722
 
457
723
  const databaseName = await promptDatabaseName(containerName)
458
724
 
@@ -484,14 +750,200 @@ async function handleRestore(): Promise<void> {
484
750
  if (result.code === 0 || !result.stderr) {
485
751
  restoreSpinner.succeed('Backup restored successfully')
486
752
  } else {
487
- restoreSpinner.warn('Restore completed with warnings')
753
+ const stderr = result.stderr || ''
754
+
755
+ // Check for version compatibility errors
756
+ if (
757
+ stderr.includes('unsupported version') ||
758
+ stderr.includes('Archive version') ||
759
+ stderr.includes('too old')
760
+ ) {
761
+ restoreSpinner.fail('Version compatibility detected')
762
+ console.log()
763
+ console.log(error('PostgreSQL version incompatibility detected:'))
764
+ console.log(
765
+ warning('Your pg_restore version is too old for this backup file.'),
766
+ )
767
+
768
+ // Clean up the failed database since restore didn't actually work
769
+ console.log(chalk.yellow('Cleaning up failed database...'))
770
+ try {
771
+ await engine.dropDatabase(config, databaseName)
772
+ console.log(chalk.gray(`✓ Removed database "${databaseName}"`))
773
+ } catch {
774
+ console.log(
775
+ chalk.yellow(`Warning: Could not remove database "${databaseName}"`),
776
+ )
777
+ }
778
+
779
+ console.log()
780
+
781
+ // Extract version info from error message
782
+ const versionMatch = stderr.match(/PostgreSQL (\d+)/)
783
+ const requiredVersion = versionMatch ? versionMatch[1] : '17'
784
+
785
+ console.log(
786
+ chalk.gray(
787
+ `This backup was created with PostgreSQL ${requiredVersion}`,
788
+ ),
789
+ )
790
+ console.log()
791
+
792
+ // Ask user if they want to upgrade
793
+ const { shouldUpgrade } = await inquirer.prompt({
794
+ type: 'list',
795
+ name: 'shouldUpgrade',
796
+ message: `Would you like to upgrade PostgreSQL client tools to support PostgreSQL ${requiredVersion}?`,
797
+ choices: [
798
+ { name: 'Yes', value: true },
799
+ { name: 'No', value: false },
800
+ ],
801
+ default: 0,
802
+ })
803
+
804
+ if (shouldUpgrade) {
805
+ console.log()
806
+ const upgradeSpinner = createSpinner(
807
+ 'Upgrading PostgreSQL client tools...',
808
+ )
809
+ upgradeSpinner.start()
810
+
811
+ try {
812
+ const { updatePostgresClientTools } = await import(
813
+ '@/core/postgres-binary-manager'
814
+ )
815
+ const updateSuccess = await updatePostgresClientTools()
816
+
817
+ if (updateSuccess) {
818
+ upgradeSpinner.succeed('PostgreSQL client tools upgraded')
819
+ console.log()
820
+ console.log(
821
+ success('Please try the restore again with the updated tools.'),
822
+ )
823
+ await new Promise((resolve) => {
824
+ console.log(chalk.gray('Press Enter to continue...'))
825
+ process.stdin.once('data', resolve)
826
+ })
827
+ return
828
+ } else {
829
+ upgradeSpinner.fail('Upgrade failed')
830
+ console.log()
831
+ console.log(
832
+ error('Automatic upgrade failed. Please upgrade manually:'),
833
+ )
834
+ console.log(
835
+ warning(
836
+ ' macOS: brew install postgresql@17 && brew link --force postgresql@17',
837
+ ),
838
+ )
839
+ console.log(
840
+ chalk.gray(
841
+ ' This installs PostgreSQL 17 client tools: pg_restore, pg_dump, psql, and libpq',
842
+ ),
843
+ )
844
+ console.log(
845
+ warning(
846
+ ' Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-17',
847
+ ),
848
+ )
849
+ console.log(
850
+ chalk.gray(
851
+ ' This installs PostgreSQL 17 client tools: pg_restore, pg_dump, psql, and libpq',
852
+ ),
853
+ )
854
+ await new Promise((resolve) => {
855
+ console.log(chalk.gray('Press Enter to continue...'))
856
+ process.stdin.once('data', resolve)
857
+ })
858
+ return
859
+ }
860
+ } catch {
861
+ upgradeSpinner.fail('Upgrade failed')
862
+ console.log(error('Failed to upgrade PostgreSQL client tools'))
863
+ console.log(
864
+ chalk.gray(
865
+ 'Manual upgrade may be required for pg_restore, pg_dump, and psql',
866
+ ),
867
+ )
868
+ await new Promise((resolve) => {
869
+ console.log(chalk.gray('Press Enter to continue...'))
870
+ process.stdin.once('data', resolve)
871
+ })
872
+ return
873
+ }
874
+ } else {
875
+ console.log()
876
+ console.log(
877
+ warning(
878
+ 'Restore cancelled. Please upgrade PostgreSQL client tools manually and try again.',
879
+ ),
880
+ )
881
+ await new Promise((resolve) => {
882
+ console.log(chalk.gray('Press Enter to continue...'))
883
+ process.stdin.once('data', resolve)
884
+ })
885
+ return
886
+ }
887
+ } else {
888
+ // Regular warnings/errors - show as before
889
+ restoreSpinner.warn('Restore completed with warnings')
890
+ // Show stderr output so user can see what went wrong
891
+ if (result.stderr) {
892
+ console.log()
893
+ console.log(chalk.yellow(' Warnings/Errors:'))
894
+ // Show first 20 lines of stderr to avoid overwhelming output
895
+ const lines = result.stderr.split('\n').filter((l) => l.trim())
896
+ const displayLines = lines.slice(0, 20)
897
+ for (const line of displayLines) {
898
+ console.log(chalk.gray(` ${line}`))
899
+ }
900
+ if (lines.length > 20) {
901
+ console.log(chalk.gray(` ... and ${lines.length - 20} more lines`))
902
+ }
903
+ }
904
+ }
488
905
  }
489
906
 
490
- const connectionString = engine.getConnectionString(config, databaseName)
491
- console.log()
492
- console.log(success(`Database "${databaseName}" restored`))
493
- console.log(chalk.gray(' Connection string:'))
494
- console.log(chalk.cyan(` ${connectionString}`))
907
+ // Only show success message if restore actually succeeded
908
+ if (result.code === 0 || !result.stderr) {
909
+ const connectionString = engine.getConnectionString(config, databaseName)
910
+ console.log()
911
+ console.log(success(`Database "${databaseName}" restored`))
912
+ console.log(chalk.gray(' Connection string:'))
913
+ console.log(chalk.cyan(` ${connectionString}`))
914
+
915
+ // Copy connection string to clipboard using platform-specific command
916
+ try {
917
+ const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
918
+ const args = platform() === 'darwin' ? [] : ['-selection', 'clipboard']
919
+
920
+ await new Promise<void>((resolve, reject) => {
921
+ const proc = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'] })
922
+ proc.stdin?.write(connectionString)
923
+ proc.stdin?.end()
924
+ proc.on('close', (code) => {
925
+ if (code === 0) resolve()
926
+ else reject(new Error(`Clipboard command exited with code ${code}`))
927
+ })
928
+ proc.on('error', reject)
929
+ })
930
+
931
+ console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
932
+ } catch {
933
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
934
+ }
935
+
936
+ console.log()
937
+ }
938
+
939
+ // Wait for user to see the result before returning to menu
940
+ await inquirer.prompt([
941
+ {
942
+ type: 'input',
943
+ name: 'continue',
944
+ message: chalk.gray('Press Enter to continue...'),
945
+ },
946
+ ])
495
947
  }
496
948
 
497
949
  async function handleClone(): Promise<void> {
@@ -548,20 +1000,219 @@ async function handleClone(): Promise<void> {
548
1000
  console.log(connectionBox(targetName, connectionString, newConfig.port))
549
1001
  }
550
1002
 
551
- async function handleDelete(): Promise<void> {
552
- const containers = await containerManager.list()
1003
+ async function handleStartContainer(containerName: string): Promise<void> {
1004
+ const config = await containerManager.getConfig(containerName)
1005
+ if (!config) {
1006
+ console.error(error(`Container "${containerName}" not found`))
1007
+ return
1008
+ }
553
1009
 
554
- if (containers.length === 0) {
555
- console.log(warning('No containers found'))
1010
+ // Check port availability
1011
+ const portAvailable = await portManager.isPortAvailable(config.port)
1012
+ if (!portAvailable) {
1013
+ console.log(
1014
+ warning(
1015
+ `Port ${config.port} is in use. Stop the process using it or change this container's port.`,
1016
+ ),
1017
+ )
556
1018
  return
557
1019
  }
558
1020
 
559
- const containerName = await promptContainerSelect(
560
- containers,
561
- 'Select container to delete:',
562
- )
563
- if (!containerName) return
1021
+ const engine = getEngine(config.engine)
1022
+
1023
+ const spinner = createSpinner(`Starting ${containerName}...`)
1024
+ spinner.start()
1025
+
1026
+ await engine.start(config)
1027
+ await containerManager.updateConfig(containerName, { status: 'running' })
1028
+
1029
+ spinner.succeed(`Container "${containerName}" started`)
1030
+
1031
+ const connectionString = engine.getConnectionString(config)
1032
+ console.log()
1033
+ console.log(chalk.gray(' Connection string:'))
1034
+ console.log(chalk.cyan(` ${connectionString}`))
1035
+ }
1036
+
1037
+ async function handleStopContainer(containerName: string): Promise<void> {
1038
+ const config = await containerManager.getConfig(containerName)
1039
+ if (!config) {
1040
+ console.error(error(`Container "${containerName}" not found`))
1041
+ return
1042
+ }
1043
+
1044
+ const engine = getEngine(config.engine)
1045
+
1046
+ const spinner = createSpinner(`Stopping ${containerName}...`)
1047
+ spinner.start()
1048
+
1049
+ await engine.stop(config)
1050
+ await containerManager.updateConfig(containerName, { status: 'stopped' })
1051
+
1052
+ spinner.succeed(`Container "${containerName}" stopped`)
1053
+ }
1054
+
1055
+ async function handleEditContainer(
1056
+ containerName: string,
1057
+ ): Promise<string | null> {
1058
+ const config = await containerManager.getConfig(containerName)
1059
+ if (!config) {
1060
+ console.error(error(`Container "${containerName}" not found`))
1061
+ return null
1062
+ }
1063
+
1064
+ console.clear()
1065
+ console.log(header(`Edit: ${containerName}`))
1066
+ console.log()
1067
+
1068
+ const editChoices = [
1069
+ {
1070
+ name: `Name: ${chalk.white(containerName)}`,
1071
+ value: 'name',
1072
+ },
1073
+ {
1074
+ name: `Port: ${chalk.white(String(config.port))}`,
1075
+ value: 'port',
1076
+ },
1077
+ new inquirer.Separator(),
1078
+ { name: `${chalk.blue('←')} Back to container`, value: 'back' },
1079
+ { name: `${chalk.blue('🏠')} Back to main menu`, value: 'main' },
1080
+ ]
1081
+
1082
+ const { field } = await inquirer.prompt<{ field: string }>([
1083
+ {
1084
+ type: 'list',
1085
+ name: 'field',
1086
+ message: 'Select field to edit:',
1087
+ choices: editChoices,
1088
+ },
1089
+ ])
1090
+
1091
+ if (field === 'back') {
1092
+ return containerName
1093
+ }
1094
+
1095
+ if (field === 'main') {
1096
+ return null // Signal to go back to main menu
1097
+ }
1098
+
1099
+ if (field === 'name') {
1100
+ const { newName } = await inquirer.prompt<{ newName: string }>([
1101
+ {
1102
+ type: 'input',
1103
+ name: 'newName',
1104
+ message: 'New name:',
1105
+ default: containerName,
1106
+ validate: (input: string) => {
1107
+ if (!input) return 'Name is required'
1108
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
1109
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
1110
+ }
1111
+ return true
1112
+ },
1113
+ },
1114
+ ])
1115
+
1116
+ if (newName === containerName) {
1117
+ console.log(info('Name unchanged'))
1118
+ return await handleEditContainer(containerName)
1119
+ }
1120
+
1121
+ // Check if new name already exists
1122
+ if (await containerManager.exists(newName)) {
1123
+ console.log(error(`Container "${newName}" already exists`))
1124
+ return await handleEditContainer(containerName)
1125
+ }
1126
+
1127
+ const spinner = createSpinner('Renaming container...')
1128
+ spinner.start()
1129
+
1130
+ await containerManager.rename(containerName, newName)
1131
+
1132
+ spinner.succeed(`Renamed "${containerName}" to "${newName}"`)
1133
+
1134
+ // Continue editing with new name
1135
+ return await handleEditContainer(newName)
1136
+ }
1137
+
1138
+ if (field === 'port') {
1139
+ const { newPort } = await inquirer.prompt<{ newPort: number }>([
1140
+ {
1141
+ type: 'input',
1142
+ name: 'newPort',
1143
+ message: 'New port:',
1144
+ default: String(config.port),
1145
+ validate: (input: string) => {
1146
+ const num = parseInt(input, 10)
1147
+ if (isNaN(num) || num < 1 || num > 65535) {
1148
+ return 'Port must be a number between 1 and 65535'
1149
+ }
1150
+ return true
1151
+ },
1152
+ filter: (input: string) => parseInt(input, 10),
1153
+ },
1154
+ ])
1155
+
1156
+ if (newPort === config.port) {
1157
+ console.log(info('Port unchanged'))
1158
+ return await handleEditContainer(containerName)
1159
+ }
1160
+
1161
+ // Check if port is in use
1162
+ const portAvailable = await portManager.isPortAvailable(newPort)
1163
+ if (!portAvailable) {
1164
+ console.log(
1165
+ warning(
1166
+ `Port ${newPort} is currently in use. You'll need to stop the process using it before starting this container.`,
1167
+ ),
1168
+ )
1169
+ }
1170
+
1171
+ await containerManager.updateConfig(containerName, { port: newPort })
1172
+ console.log(success(`Changed port from ${config.port} to ${newPort}`))
1173
+
1174
+ // Continue editing
1175
+ return await handleEditContainer(containerName)
1176
+ }
1177
+
1178
+ return containerName
1179
+ }
1180
+
1181
+ async function handleCloneFromSubmenu(sourceName: string): Promise<void> {
1182
+ const { targetName } = await inquirer.prompt<{ targetName: string }>([
1183
+ {
1184
+ type: 'input',
1185
+ name: 'targetName',
1186
+ message: 'Name for the cloned container:',
1187
+ default: `${sourceName}-copy`,
1188
+ validate: (input: string) => {
1189
+ if (!input) return 'Name is required'
1190
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
1191
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
1192
+ }
1193
+ return true
1194
+ },
1195
+ },
1196
+ ])
1197
+
1198
+ const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
1199
+ spinner.start()
1200
+
1201
+ const newConfig = await containerManager.clone(sourceName, targetName)
1202
+
1203
+ spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
1204
+
1205
+ const engine = getEngine(newConfig.engine)
1206
+ const connectionString = engine.getConnectionString(newConfig)
1207
+
1208
+ console.log()
1209
+ console.log(connectionBox(targetName, connectionString, newConfig.port))
1210
+
1211
+ // Go to the new container's submenu
1212
+ await showContainerSubmenu(targetName)
1213
+ }
564
1214
 
1215
+ async function handleDelete(containerName: string): Promise<void> {
565
1216
  const config = await containerManager.getConfig(containerName)
566
1217
  if (!config) {
567
1218
  console.error(error(`Container "${containerName}" not found`))
@@ -598,68 +1249,230 @@ async function handleDelete(): Promise<void> {
598
1249
  deleteSpinner.succeed(`Container "${containerName}" deleted`)
599
1250
  }
600
1251
 
601
- async function handleChangePort(): Promise<void> {
602
- const containers = await containerManager.list()
603
- const stopped = containers.filter((c) => c.status !== 'running')
1252
+ type InstalledEngine = {
1253
+ engine: string
1254
+ version: string
1255
+ platform: string
1256
+ arch: string
1257
+ path: string
1258
+ sizeBytes: number
1259
+ }
604
1260
 
605
- if (stopped.length === 0) {
1261
+ async function getInstalledEngines(): Promise<InstalledEngine[]> {
1262
+ const binDir = paths.bin
1263
+
1264
+ if (!existsSync(binDir)) {
1265
+ return []
1266
+ }
1267
+
1268
+ const entries = await readdir(binDir, { withFileTypes: true })
1269
+ const engines: InstalledEngine[] = []
1270
+
1271
+ for (const entry of entries) {
1272
+ if (entry.isDirectory()) {
1273
+ // Parse directory name: postgresql-17-darwin-arm64
1274
+ const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
1275
+ if (match) {
1276
+ const [, engine, version, platform, arch] = match
1277
+ const dirPath = join(binDir, entry.name)
1278
+
1279
+ // Get directory size (using lstat to avoid following symlinks)
1280
+ let sizeBytes = 0
1281
+ try {
1282
+ const files = await readdir(dirPath, { recursive: true })
1283
+ for (const file of files) {
1284
+ try {
1285
+ const filePath = join(dirPath, file.toString())
1286
+ const fileStat = await lstat(filePath)
1287
+ // Only count regular files (not symlinks or directories)
1288
+ if (fileStat.isFile()) {
1289
+ sizeBytes += fileStat.size
1290
+ }
1291
+ } catch {
1292
+ // Skip files we can't stat
1293
+ }
1294
+ }
1295
+ } catch {
1296
+ // Skip directories we can't read
1297
+ }
1298
+
1299
+ engines.push({
1300
+ engine,
1301
+ version,
1302
+ platform,
1303
+ arch,
1304
+ path: dirPath,
1305
+ sizeBytes,
1306
+ })
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ // Sort by engine name, then by version (descending)
1312
+ engines.sort((a, b) => {
1313
+ if (a.engine !== b.engine) return a.engine.localeCompare(b.engine)
1314
+ return compareVersions(b.version, a.version)
1315
+ })
1316
+
1317
+ return engines
1318
+ }
1319
+
1320
+ function compareVersions(a: string, b: string): number {
1321
+ const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
1322
+ const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
1323
+
1324
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
1325
+ const numA = partsA[i] || 0
1326
+ const numB = partsB[i] || 0
1327
+ if (numA !== numB) return numA - numB
1328
+ }
1329
+ return 0
1330
+ }
1331
+
1332
+ function formatBytes(bytes: number): string {
1333
+ if (bytes === 0) return '0 B'
1334
+ const k = 1024
1335
+ const sizes = ['B', 'KB', 'MB', 'GB']
1336
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
1337
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
1338
+ }
1339
+
1340
+ async function handleEngines(): Promise<void> {
1341
+ console.clear()
1342
+ console.log(header('Installed Engines'))
1343
+ console.log()
1344
+
1345
+ const engines = await getInstalledEngines()
1346
+
1347
+ if (engines.length === 0) {
1348
+ console.log(info('No engines installed yet.'))
606
1349
  console.log(
607
- warning(
608
- 'No stopped containers. Stop a container first to change its port.',
1350
+ chalk.gray(
1351
+ ' Engines are downloaded automatically when you create a container.',
609
1352
  ),
610
1353
  )
611
1354
  return
612
1355
  }
613
1356
 
614
- const containerName = await promptContainerSelect(
615
- stopped,
616
- 'Select container to change port:',
1357
+ // Calculate total size
1358
+ const totalSize = engines.reduce((acc, e) => acc + e.sizeBytes, 0)
1359
+
1360
+ // Table header
1361
+ console.log()
1362
+ console.log(
1363
+ chalk.gray(' ') +
1364
+ chalk.bold.white('ENGINE'.padEnd(12)) +
1365
+ chalk.bold.white('VERSION'.padEnd(12)) +
1366
+ chalk.bold.white('PLATFORM'.padEnd(20)) +
1367
+ chalk.bold.white('SIZE'),
617
1368
  )
618
- if (!containerName) return
1369
+ console.log(chalk.gray(' ' + '─'.repeat(55)))
619
1370
 
620
- const config = await containerManager.getConfig(containerName)
621
- if (!config) {
622
- console.error(error(`Container "${containerName}" not found`))
623
- return
1371
+ // Table rows
1372
+ for (const engine of engines) {
1373
+ console.log(
1374
+ chalk.gray(' ') +
1375
+ chalk.cyan(engine.engine.padEnd(12)) +
1376
+ chalk.yellow(engine.version.padEnd(12)) +
1377
+ chalk.gray(`${engine.platform}-${engine.arch}`.padEnd(20)) +
1378
+ chalk.white(formatBytes(engine.sizeBytes)),
1379
+ )
624
1380
  }
625
1381
 
626
- console.log(chalk.gray(` Current port: ${config.port}`))
1382
+ console.log(chalk.gray(' ' + '─'.repeat(55)))
1383
+ console.log(
1384
+ chalk.gray(' ') +
1385
+ chalk.bold.white(`${engines.length} version(s)`.padEnd(44)) +
1386
+ chalk.bold.white(formatBytes(totalSize)),
1387
+ )
627
1388
  console.log()
628
1389
 
629
- const { newPort } = await inquirer.prompt<{ newPort: number }>([
1390
+ // Menu options
1391
+ const choices: MenuChoice[] = [
1392
+ ...engines.map((e) => ({
1393
+ name: `${chalk.red('✕')} Delete ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
1394
+ value: `delete:${e.path}:${e.engine}:${e.version}`,
1395
+ })),
1396
+ new inquirer.Separator(),
1397
+ { name: `${chalk.blue('←')} Back to main menu`, value: 'back' },
1398
+ ]
1399
+
1400
+ const { action } = await inquirer.prompt<{ action: string }>([
630
1401
  {
631
- type: 'input',
632
- name: 'newPort',
633
- message: 'New port:',
634
- default: String(config.port),
635
- validate: (input: string) => {
636
- const num = parseInt(input, 10)
637
- if (isNaN(num) || num < 1 || num > 65535) {
638
- return 'Port must be a number between 1 and 65535'
639
- }
640
- return true
641
- },
642
- filter: (input: string) => parseInt(input, 10),
1402
+ type: 'list',
1403
+ name: 'action',
1404
+ message: 'Manage engines:',
1405
+ choices,
1406
+ pageSize: 15,
643
1407
  },
644
1408
  ])
645
1409
 
646
- if (newPort === config.port) {
647
- console.log(info('Port unchanged'))
1410
+ if (action === 'back') {
648
1411
  return
649
1412
  }
650
1413
 
651
- // Check if port is available
652
- const portAvailable = await portManager.isPortAvailable(newPort)
653
- if (!portAvailable) {
654
- console.log(warning(`Port ${newPort} is already in use`))
655
- return
1414
+ if (action.startsWith('delete:')) {
1415
+ const [, enginePath, engineName, engineVersion] = action.split(':')
1416
+ await handleDeleteEngine(enginePath, engineName, engineVersion)
1417
+ // Return to engines menu
1418
+ await handleEngines()
656
1419
  }
1420
+ }
1421
+
1422
+ async function handleDeleteEngine(
1423
+ enginePath: string,
1424
+ engineName: string,
1425
+ engineVersion: string,
1426
+ ): Promise<void> {
1427
+ // Check if any container is using this engine version
1428
+ const containers = await containerManager.list()
1429
+ const usingContainers = containers.filter(
1430
+ (c) => c.engine === engineName && c.version === engineVersion,
1431
+ )
657
1432
 
658
- await containerManager.updateConfig(containerName, { port: newPort })
1433
+ if (usingContainers.length > 0) {
1434
+ console.log()
1435
+ console.log(
1436
+ error(
1437
+ `Cannot delete: ${usingContainers.length} container(s) are using ${engineName} ${engineVersion}`,
1438
+ ),
1439
+ )
1440
+ console.log(
1441
+ chalk.gray(
1442
+ ` Containers: ${usingContainers.map((c) => c.name).join(', ')}`,
1443
+ ),
1444
+ )
1445
+ console.log()
1446
+ await inquirer.prompt([
1447
+ {
1448
+ type: 'input',
1449
+ name: 'continue',
1450
+ message: chalk.gray('Press Enter to continue...'),
1451
+ },
1452
+ ])
1453
+ return
1454
+ }
659
1455
 
660
- console.log(
661
- success(`Changed ${containerName} port from ${config.port} to ${newPort}`),
1456
+ const confirmed = await promptConfirm(
1457
+ `Delete ${engineName} ${engineVersion}? This cannot be undone.`,
1458
+ false,
662
1459
  )
1460
+
1461
+ if (!confirmed) {
1462
+ console.log(warning('Deletion cancelled'))
1463
+ return
1464
+ }
1465
+
1466
+ const spinner = createSpinner(`Deleting ${engineName} ${engineVersion}...`)
1467
+ spinner.start()
1468
+
1469
+ try {
1470
+ await rm(enginePath, { recursive: true, force: true })
1471
+ spinner.succeed(`Deleted ${engineName} ${engineVersion}`)
1472
+ } catch (err) {
1473
+ const e = err as Error
1474
+ spinner.fail(`Failed to delete: ${e.message}`)
1475
+ }
663
1476
  }
664
1477
 
665
1478
  export const menuCommand = new Command('menu')