spindb 0.1.0 → 0.2.0
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.
- package/.claude/settings.local.json +6 -1
- package/CLAUDE.md +42 -12
- package/README.md +24 -3
- package/TODO.md +12 -3
- package/eslint.config.js +7 -1
- package/package.json +1 -1
- package/src/cli/commands/create.ts +23 -3
- package/src/cli/commands/menu.ts +955 -142
- package/src/cli/commands/postgres-tools.ts +216 -0
- package/src/cli/commands/restore.ts +28 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/ui/prompts.ts +111 -21
- package/src/cli/ui/theme.ts +54 -10
- package/src/config/defaults.ts +6 -3
- package/src/core/binary-manager.ts +42 -12
- package/src/core/container-manager.ts +53 -5
- package/src/core/port-manager.ts +76 -1
- package/src/core/postgres-binary-manager.ts +499 -0
- package/src/core/process-manager.ts +4 -4
- package/src/engines/base-engine.ts +22 -0
- package/src/engines/postgresql/binary-urls.ts +130 -12
- package/src/engines/postgresql/index.ts +40 -4
- package/src/engines/postgresql/restore.ts +20 -9
- package/src/types/index.ts +15 -13
- package/tsconfig.json +6 -3
package/src/cli/commands/menu.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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:
|
|
79
|
+
name: canStart
|
|
80
|
+
? `${chalk.green('▶')} Start a container`
|
|
81
|
+
: chalk.gray('▶ Start a container'),
|
|
54
82
|
value: 'start',
|
|
55
|
-
disabled:
|
|
83
|
+
disabled: canStart ? false : 'No stopped containers',
|
|
56
84
|
},
|
|
57
85
|
{
|
|
58
|
-
name:
|
|
86
|
+
name: canStop
|
|
87
|
+
? `${chalk.yellow('■')} Stop a container`
|
|
88
|
+
: chalk.gray('■ Stop a container'),
|
|
59
89
|
value: 'stop',
|
|
60
|
-
disabled:
|
|
90
|
+
disabled: canStop ? false : 'No running containers',
|
|
61
91
|
},
|
|
62
92
|
{
|
|
63
|
-
name:
|
|
93
|
+
name: canConnect
|
|
94
|
+
? `${chalk.blue('⌘')} Open psql shell`
|
|
95
|
+
: chalk.gray('⌘ Open psql shell'),
|
|
64
96
|
value: 'connect',
|
|
65
|
-
disabled:
|
|
97
|
+
disabled: canConnect ? false : 'No running containers',
|
|
66
98
|
},
|
|
67
99
|
{
|
|
68
|
-
name:
|
|
100
|
+
name: canRestore
|
|
101
|
+
? `${chalk.magenta('↓')} Restore backup`
|
|
102
|
+
: chalk.gray('↓ Restore backup'),
|
|
69
103
|
value: 'restore',
|
|
70
|
-
disabled:
|
|
104
|
+
disabled: canRestore ? false : 'No running containers',
|
|
71
105
|
},
|
|
72
106
|
{
|
|
73
|
-
name:
|
|
107
|
+
name: canClone
|
|
108
|
+
? `${chalk.cyan('⧉')} Clone a container`
|
|
109
|
+
: chalk.gray('⧉ Clone a container'),
|
|
74
110
|
value: 'clone',
|
|
75
|
-
disabled:
|
|
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:
|
|
84
|
-
|
|
85
|
-
|
|
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 '
|
|
124
|
-
await
|
|
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
|
|
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
|
-
//
|
|
173
|
-
const
|
|
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
|
-
|
|
224
|
-
|
|
222
|
+
// Start container (only if port is available)
|
|
223
|
+
if (portAvailable) {
|
|
224
|
+
const startSpinner = createSpinner('Starting PostgreSQL...')
|
|
225
|
+
startSpinner.start()
|
|
225
226
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
552
|
-
const
|
|
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
|
-
|
|
555
|
-
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
)
|
|
563
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
'
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
1369
|
+
console.log(chalk.gray(' ' + '─'.repeat(55)))
|
|
619
1370
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
console.
|
|
623
|
-
|
|
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(
|
|
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
|
-
|
|
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: '
|
|
632
|
-
name: '
|
|
633
|
-
message: '
|
|
634
|
-
|
|
635
|
-
|
|
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 (
|
|
647
|
-
console.log(info('Port unchanged'))
|
|
1410
|
+
if (action === 'back') {
|
|
648
1411
|
return
|
|
649
1412
|
}
|
|
650
1413
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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')
|