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