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.
- package/README.md +421 -294
- package/cli/commands/config.ts +7 -1
- package/cli/commands/connect.ts +1 -0
- package/cli/commands/edit.ts +10 -0
- package/cli/commands/engines.ts +10 -188
- package/cli/commands/info.ts +7 -14
- package/cli/commands/list.ts +2 -9
- package/cli/commands/logs.ts +130 -0
- package/cli/commands/menu/backup-handlers.ts +798 -0
- package/cli/commands/menu/container-handlers.ts +832 -0
- package/cli/commands/menu/engine-handlers.ts +382 -0
- package/cli/commands/menu/index.ts +184 -0
- package/cli/commands/menu/shared.ts +26 -0
- package/cli/commands/menu/shell-handlers.ts +331 -0
- package/cli/commands/menu/sql-handlers.ts +197 -0
- package/cli/commands/menu/update-handlers.ts +94 -0
- package/cli/commands/run.ts +150 -0
- package/cli/commands/url.ts +19 -5
- package/cli/constants.ts +10 -0
- package/cli/helpers.ts +152 -0
- package/cli/index.ts +5 -2
- package/cli/ui/prompts.ts +3 -11
- package/core/dependency-manager.ts +0 -163
- package/core/error-handler.ts +0 -26
- package/core/platform-service.ts +60 -40
- package/core/start-with-retry.ts +3 -28
- package/core/transaction-manager.ts +0 -8
- package/engines/base-engine.ts +10 -0
- package/engines/mysql/binary-detection.ts +1 -1
- package/engines/mysql/index.ts +78 -2
- package/engines/postgresql/index.ts +49 -0
- package/package.json +1 -1
- package/cli/commands/menu.ts +0 -2670
package/cli/commands/menu.ts
DELETED
|
@@ -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
|
-
})
|