spindb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +20 -0
- package/.env.example +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +6 -0
- package/CLAUDE.md +162 -0
- package/README.md +204 -0
- package/TODO.md +66 -0
- package/bin/cli.js +7 -0
- package/eslint.config.js +18 -0
- package/package.json +52 -0
- package/seeds/mysql/sample-db.sql +22 -0
- package/seeds/postgres/sample-db.sql +27 -0
- package/src/bin/cli.ts +8 -0
- package/src/cli/commands/clone.ts +101 -0
- package/src/cli/commands/config.ts +215 -0
- package/src/cli/commands/connect.ts +106 -0
- package/src/cli/commands/create.ts +148 -0
- package/src/cli/commands/delete.ts +94 -0
- package/src/cli/commands/list.ts +69 -0
- package/src/cli/commands/menu.ts +675 -0
- package/src/cli/commands/restore.ts +161 -0
- package/src/cli/commands/start.ts +95 -0
- package/src/cli/commands/stop.ts +91 -0
- package/src/cli/index.ts +38 -0
- package/src/cli/ui/prompts.ts +197 -0
- package/src/cli/ui/spinner.ts +94 -0
- package/src/cli/ui/theme.ts +113 -0
- package/src/config/defaults.ts +49 -0
- package/src/config/paths.ts +53 -0
- package/src/core/binary-manager.ts +239 -0
- package/src/core/config-manager.ts +259 -0
- package/src/core/container-manager.ts +234 -0
- package/src/core/port-manager.ts +84 -0
- package/src/core/process-manager.ts +353 -0
- package/src/engines/base-engine.ts +103 -0
- package/src/engines/index.ts +46 -0
- package/src/engines/postgresql/binary-urls.ts +52 -0
- package/src/engines/postgresql/index.ts +298 -0
- package/src/engines/postgresql/restore.ts +173 -0
- package/src/types/index.ts +97 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import inquirer from 'inquirer'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { containerManager } from '@/core/container-manager'
|
|
5
|
+
import { processManager } from '@/core/process-manager'
|
|
6
|
+
import { getEngine } from '@/engines'
|
|
7
|
+
import { portManager } from '@/core/port-manager'
|
|
8
|
+
import { defaults } from '@/config/defaults'
|
|
9
|
+
import {
|
|
10
|
+
promptCreateOptions,
|
|
11
|
+
promptContainerSelect,
|
|
12
|
+
promptConfirm,
|
|
13
|
+
promptDatabaseName,
|
|
14
|
+
} from '@/cli/ui/prompts'
|
|
15
|
+
import { createSpinner } from '@/cli/ui/spinner'
|
|
16
|
+
import {
|
|
17
|
+
header,
|
|
18
|
+
success,
|
|
19
|
+
error,
|
|
20
|
+
warning,
|
|
21
|
+
info,
|
|
22
|
+
connectionBox,
|
|
23
|
+
} from '@/cli/ui/theme'
|
|
24
|
+
import { existsSync } from 'fs'
|
|
25
|
+
import { spawn } from 'child_process'
|
|
26
|
+
|
|
27
|
+
interface MenuChoice {
|
|
28
|
+
name: string
|
|
29
|
+
value: string
|
|
30
|
+
disabled?: boolean | string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function showMainMenu(): Promise<void> {
|
|
34
|
+
console.clear()
|
|
35
|
+
console.log(header('SpinDB - Local Database Manager'))
|
|
36
|
+
console.log()
|
|
37
|
+
|
|
38
|
+
const containers = await containerManager.list()
|
|
39
|
+
const running = containers.filter((c) => c.status === 'running').length
|
|
40
|
+
const stopped = containers.filter((c) => c.status !== 'running').length
|
|
41
|
+
|
|
42
|
+
console.log(
|
|
43
|
+
chalk.gray(
|
|
44
|
+
` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
console.log()
|
|
48
|
+
|
|
49
|
+
const choices: MenuChoice[] = [
|
|
50
|
+
{ name: `${chalk.green('+')} Create new container`, value: 'create' },
|
|
51
|
+
{ name: `${chalk.cyan('◉')} List containers`, value: 'list' },
|
|
52
|
+
{
|
|
53
|
+
name: `${chalk.green('▶')} Start a container`,
|
|
54
|
+
value: 'start',
|
|
55
|
+
disabled: stopped === 0 ? 'No stopped containers' : false,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: `${chalk.yellow('■')} Stop a container`,
|
|
59
|
+
value: 'stop',
|
|
60
|
+
disabled: running === 0 ? 'No running containers' : false,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: `${chalk.blue('⌘')} Open psql shell`,
|
|
64
|
+
value: 'connect',
|
|
65
|
+
disabled: running === 0 ? 'No running containers' : false,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: `${chalk.magenta('↓')} Restore backup`,
|
|
69
|
+
value: 'restore',
|
|
70
|
+
disabled: running === 0 ? 'No running containers' : false,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: `${chalk.cyan('⧉')} Clone a container`,
|
|
74
|
+
value: 'clone',
|
|
75
|
+
disabled: containers.length === 0 ? 'No containers' : false,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: `${chalk.white('⚙')} Change port`,
|
|
79
|
+
value: 'port',
|
|
80
|
+
disabled: stopped === 0 ? 'No stopped containers' : false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: `${chalk.red('✕')} Delete a container`,
|
|
84
|
+
value: 'delete',
|
|
85
|
+
disabled: containers.length === 0 ? 'No containers' : false,
|
|
86
|
+
},
|
|
87
|
+
new inquirer.Separator(),
|
|
88
|
+
{ name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
const { action } = await inquirer.prompt<{ action: string }>([
|
|
92
|
+
{
|
|
93
|
+
type: 'list',
|
|
94
|
+
name: 'action',
|
|
95
|
+
message: 'What would you like to do?',
|
|
96
|
+
choices,
|
|
97
|
+
pageSize: 12,
|
|
98
|
+
},
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
switch (action) {
|
|
102
|
+
case 'create':
|
|
103
|
+
await handleCreate()
|
|
104
|
+
break
|
|
105
|
+
case 'list':
|
|
106
|
+
await handleList()
|
|
107
|
+
break
|
|
108
|
+
case 'start':
|
|
109
|
+
await handleStart()
|
|
110
|
+
break
|
|
111
|
+
case 'stop':
|
|
112
|
+
await handleStop()
|
|
113
|
+
break
|
|
114
|
+
case 'connect':
|
|
115
|
+
await handleConnect()
|
|
116
|
+
break
|
|
117
|
+
case 'restore':
|
|
118
|
+
await handleRestore()
|
|
119
|
+
break
|
|
120
|
+
case 'clone':
|
|
121
|
+
await handleClone()
|
|
122
|
+
break
|
|
123
|
+
case 'port':
|
|
124
|
+
await handleChangePort()
|
|
125
|
+
break
|
|
126
|
+
case 'delete':
|
|
127
|
+
await handleDelete()
|
|
128
|
+
break
|
|
129
|
+
case 'exit':
|
|
130
|
+
console.log(chalk.gray('\n Goodbye!\n'))
|
|
131
|
+
process.exit(0)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Return to menu after action
|
|
135
|
+
await promptReturnToMenu()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function promptReturnToMenu(): Promise<void> {
|
|
139
|
+
console.log()
|
|
140
|
+
const { returnToMenu } = await inquirer.prompt<{ returnToMenu: string }>([
|
|
141
|
+
{
|
|
142
|
+
type: 'list',
|
|
143
|
+
name: 'returnToMenu',
|
|
144
|
+
message: 'Return to main menu?',
|
|
145
|
+
choices: [
|
|
146
|
+
{ name: 'Yes', value: 'yes' },
|
|
147
|
+
{ name: 'No', value: 'no' },
|
|
148
|
+
],
|
|
149
|
+
default: 'yes',
|
|
150
|
+
},
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
if (returnToMenu === 'yes') {
|
|
154
|
+
await showMainMenu()
|
|
155
|
+
} else {
|
|
156
|
+
console.log(chalk.gray('\n Goodbye!\n'))
|
|
157
|
+
process.exit(0)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function handleCreate(): Promise<void> {
|
|
162
|
+
console.log()
|
|
163
|
+
const answers = await promptCreateOptions()
|
|
164
|
+
const { name: containerName, engine, version } = answers
|
|
165
|
+
|
|
166
|
+
console.log()
|
|
167
|
+
console.log(header('Creating Database Container'))
|
|
168
|
+
console.log()
|
|
169
|
+
|
|
170
|
+
const dbEngine = getEngine(engine)
|
|
171
|
+
|
|
172
|
+
// Find available port
|
|
173
|
+
const portSpinner = createSpinner('Finding available port...')
|
|
174
|
+
portSpinner.start()
|
|
175
|
+
|
|
176
|
+
const { port, isDefault } = await portManager.findAvailablePort()
|
|
177
|
+
if (isDefault) {
|
|
178
|
+
portSpinner.succeed(`Using default port ${port}`)
|
|
179
|
+
} else {
|
|
180
|
+
portSpinner.warn(`Default port 5432 is in use, using port ${port}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Ensure binaries
|
|
184
|
+
const binarySpinner = createSpinner(
|
|
185
|
+
`Checking PostgreSQL ${version} binaries...`,
|
|
186
|
+
)
|
|
187
|
+
binarySpinner.start()
|
|
188
|
+
|
|
189
|
+
const isInstalled = await dbEngine.isBinaryInstalled(version)
|
|
190
|
+
if (isInstalled) {
|
|
191
|
+
binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
|
|
192
|
+
} else {
|
|
193
|
+
binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
|
|
194
|
+
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
195
|
+
binarySpinner.text = message
|
|
196
|
+
})
|
|
197
|
+
binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Create container
|
|
201
|
+
const createSpinnerInstance = createSpinner('Creating container...')
|
|
202
|
+
createSpinnerInstance.start()
|
|
203
|
+
|
|
204
|
+
await containerManager.create(containerName, {
|
|
205
|
+
engine: dbEngine.name,
|
|
206
|
+
version,
|
|
207
|
+
port,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
createSpinnerInstance.succeed('Container created')
|
|
211
|
+
|
|
212
|
+
// Initialize database
|
|
213
|
+
const initSpinner = createSpinner('Initializing database...')
|
|
214
|
+
initSpinner.start()
|
|
215
|
+
|
|
216
|
+
await dbEngine.initDataDir(containerName, version, {
|
|
217
|
+
superuser: defaults.superuser,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
initSpinner.succeed('Database initialized')
|
|
221
|
+
|
|
222
|
+
// Start container
|
|
223
|
+
const startSpinner = createSpinner('Starting PostgreSQL...')
|
|
224
|
+
startSpinner.start()
|
|
225
|
+
|
|
226
|
+
const config = await containerManager.getConfig(containerName)
|
|
227
|
+
if (config) {
|
|
228
|
+
await dbEngine.start(config)
|
|
229
|
+
await containerManager.updateConfig(containerName, { status: 'running' })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
startSpinner.succeed('PostgreSQL started')
|
|
233
|
+
|
|
234
|
+
// Show success
|
|
235
|
+
if (config) {
|
|
236
|
+
const connectionString = dbEngine.getConnectionString(config)
|
|
237
|
+
console.log()
|
|
238
|
+
console.log(connectionBox(containerName, connectionString, port))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function handleList(): Promise<void> {
|
|
243
|
+
console.log()
|
|
244
|
+
const containers = await containerManager.list()
|
|
245
|
+
|
|
246
|
+
if (containers.length === 0) {
|
|
247
|
+
console.log(
|
|
248
|
+
info('No containers found. Create one with the "Create" option.'),
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Table header
|
|
254
|
+
console.log()
|
|
255
|
+
console.log(
|
|
256
|
+
chalk.gray(' ') +
|
|
257
|
+
chalk.bold.white('NAME'.padEnd(20)) +
|
|
258
|
+
chalk.bold.white('ENGINE'.padEnd(12)) +
|
|
259
|
+
chalk.bold.white('VERSION'.padEnd(10)) +
|
|
260
|
+
chalk.bold.white('PORT'.padEnd(8)) +
|
|
261
|
+
chalk.bold.white('STATUS'),
|
|
262
|
+
)
|
|
263
|
+
console.log(chalk.gray(' ' + '─'.repeat(60)))
|
|
264
|
+
|
|
265
|
+
// Table rows
|
|
266
|
+
for (const container of containers) {
|
|
267
|
+
const statusDisplay =
|
|
268
|
+
container.status === 'running'
|
|
269
|
+
? chalk.green('● running')
|
|
270
|
+
: chalk.gray('○ stopped')
|
|
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
|
+
statusDisplay,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log()
|
|
283
|
+
|
|
284
|
+
const running = containers.filter((c) => c.status === 'running').length
|
|
285
|
+
const stopped = containers.filter((c) => c.status !== 'running').length
|
|
286
|
+
console.log(
|
|
287
|
+
chalk.gray(
|
|
288
|
+
` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function handleStart(): Promise<void> {
|
|
294
|
+
const containers = await containerManager.list()
|
|
295
|
+
const stopped = containers.filter((c) => c.status !== 'running')
|
|
296
|
+
|
|
297
|
+
if (stopped.length === 0) {
|
|
298
|
+
console.log(warning('All containers are already running'))
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const containerName = await promptContainerSelect(
|
|
303
|
+
stopped,
|
|
304
|
+
'Select container to start:',
|
|
305
|
+
)
|
|
306
|
+
if (!containerName) return
|
|
307
|
+
|
|
308
|
+
const config = await containerManager.getConfig(containerName)
|
|
309
|
+
if (!config) {
|
|
310
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Check port availability
|
|
315
|
+
const portAvailable = await portManager.isPortAvailable(config.port)
|
|
316
|
+
if (!portAvailable) {
|
|
317
|
+
const { port: newPort } = await portManager.findAvailablePort()
|
|
318
|
+
console.log(
|
|
319
|
+
warning(`Port ${config.port} is in use, switching to port ${newPort}`),
|
|
320
|
+
)
|
|
321
|
+
config.port = newPort
|
|
322
|
+
await containerManager.updateConfig(containerName, { port: newPort })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const engine = getEngine(config.engine)
|
|
326
|
+
|
|
327
|
+
const spinner = createSpinner(`Starting ${containerName}...`)
|
|
328
|
+
spinner.start()
|
|
329
|
+
|
|
330
|
+
await engine.start(config)
|
|
331
|
+
await containerManager.updateConfig(containerName, { status: 'running' })
|
|
332
|
+
|
|
333
|
+
spinner.succeed(`Container "${containerName}" started`)
|
|
334
|
+
|
|
335
|
+
const connectionString = engine.getConnectionString(config)
|
|
336
|
+
console.log()
|
|
337
|
+
console.log(chalk.gray(' Connection string:'))
|
|
338
|
+
console.log(chalk.cyan(` ${connectionString}`))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function handleStop(): Promise<void> {
|
|
342
|
+
const containers = await containerManager.list()
|
|
343
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
344
|
+
|
|
345
|
+
if (running.length === 0) {
|
|
346
|
+
console.log(warning('No running containers'))
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const containerName = await promptContainerSelect(
|
|
351
|
+
running,
|
|
352
|
+
'Select container to stop:',
|
|
353
|
+
)
|
|
354
|
+
if (!containerName) return
|
|
355
|
+
|
|
356
|
+
const config = await containerManager.getConfig(containerName)
|
|
357
|
+
if (!config) {
|
|
358
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const engine = getEngine(config.engine)
|
|
363
|
+
|
|
364
|
+
const spinner = createSpinner(`Stopping ${containerName}...`)
|
|
365
|
+
spinner.start()
|
|
366
|
+
|
|
367
|
+
await engine.stop(config)
|
|
368
|
+
await containerManager.updateConfig(containerName, { status: 'stopped' })
|
|
369
|
+
|
|
370
|
+
spinner.succeed(`Container "${containerName}" stopped`)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleConnect(): Promise<void> {
|
|
374
|
+
const containers = await containerManager.list()
|
|
375
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
376
|
+
|
|
377
|
+
if (running.length === 0) {
|
|
378
|
+
console.log(warning('No running containers'))
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const containerName = await promptContainerSelect(
|
|
383
|
+
running,
|
|
384
|
+
'Select container to connect to:',
|
|
385
|
+
)
|
|
386
|
+
if (!containerName) return
|
|
387
|
+
|
|
388
|
+
const config = await containerManager.getConfig(containerName)
|
|
389
|
+
if (!config) {
|
|
390
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const engine = getEngine(config.engine)
|
|
395
|
+
const connectionString = engine.getConnectionString(config)
|
|
396
|
+
|
|
397
|
+
console.log(info(`Connecting to ${containerName}...`))
|
|
398
|
+
console.log()
|
|
399
|
+
|
|
400
|
+
// Spawn psql
|
|
401
|
+
const psqlProcess = spawn('psql', [connectionString], {
|
|
402
|
+
stdio: 'inherit',
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
psqlProcess.on('error', (err: NodeJS.ErrnoException) => {
|
|
406
|
+
if (err.code === 'ENOENT') {
|
|
407
|
+
console.log(warning('psql not found on your system.'))
|
|
408
|
+
console.log()
|
|
409
|
+
console.log(chalk.gray(' Connect manually with:'))
|
|
410
|
+
console.log(chalk.cyan(` ${connectionString}`))
|
|
411
|
+
console.log()
|
|
412
|
+
console.log(chalk.gray(' Install PostgreSQL client:'))
|
|
413
|
+
console.log(chalk.cyan(' brew install libpq && brew link --force libpq'))
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
await new Promise<void>((resolve) => {
|
|
418
|
+
psqlProcess.on('close', () => resolve())
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function handleRestore(): Promise<void> {
|
|
423
|
+
const containers = await containerManager.list()
|
|
424
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
425
|
+
|
|
426
|
+
if (running.length === 0) {
|
|
427
|
+
console.log(warning('No running containers. Start one first.'))
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const containerName = await promptContainerSelect(
|
|
432
|
+
running,
|
|
433
|
+
'Select container to restore to:',
|
|
434
|
+
)
|
|
435
|
+
if (!containerName) return
|
|
436
|
+
|
|
437
|
+
const config = await containerManager.getConfig(containerName)
|
|
438
|
+
if (!config) {
|
|
439
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Get backup file path
|
|
444
|
+
const { backupPath } = await inquirer.prompt<{ backupPath: string }>([
|
|
445
|
+
{
|
|
446
|
+
type: 'input',
|
|
447
|
+
name: 'backupPath',
|
|
448
|
+
message: 'Path to backup file:',
|
|
449
|
+
validate: (input: string) => {
|
|
450
|
+
if (!input) return 'Backup path is required'
|
|
451
|
+
if (!existsSync(input)) return 'File not found'
|
|
452
|
+
return true
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
])
|
|
456
|
+
|
|
457
|
+
const databaseName = await promptDatabaseName(containerName)
|
|
458
|
+
|
|
459
|
+
const engine = getEngine(config.engine)
|
|
460
|
+
|
|
461
|
+
// Detect format
|
|
462
|
+
const detectSpinner = createSpinner('Detecting backup format...')
|
|
463
|
+
detectSpinner.start()
|
|
464
|
+
|
|
465
|
+
const format = await engine.detectBackupFormat(backupPath)
|
|
466
|
+
detectSpinner.succeed(`Detected: ${format.description}`)
|
|
467
|
+
|
|
468
|
+
// Create database
|
|
469
|
+
const dbSpinner = createSpinner(`Creating database "${databaseName}"...`)
|
|
470
|
+
dbSpinner.start()
|
|
471
|
+
|
|
472
|
+
await engine.createDatabase(config, databaseName)
|
|
473
|
+
dbSpinner.succeed(`Database "${databaseName}" ready`)
|
|
474
|
+
|
|
475
|
+
// Restore
|
|
476
|
+
const restoreSpinner = createSpinner('Restoring backup...')
|
|
477
|
+
restoreSpinner.start()
|
|
478
|
+
|
|
479
|
+
const result = await engine.restore(config, backupPath, {
|
|
480
|
+
database: databaseName,
|
|
481
|
+
createDatabase: false,
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
if (result.code === 0 || !result.stderr) {
|
|
485
|
+
restoreSpinner.succeed('Backup restored successfully')
|
|
486
|
+
} else {
|
|
487
|
+
restoreSpinner.warn('Restore completed with warnings')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const connectionString = engine.getConnectionString(config, databaseName)
|
|
491
|
+
console.log()
|
|
492
|
+
console.log(success(`Database "${databaseName}" restored`))
|
|
493
|
+
console.log(chalk.gray(' Connection string:'))
|
|
494
|
+
console.log(chalk.cyan(` ${connectionString}`))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function handleClone(): Promise<void> {
|
|
498
|
+
const containers = await containerManager.list()
|
|
499
|
+
const stopped = containers.filter((c) => c.status !== 'running')
|
|
500
|
+
|
|
501
|
+
if (containers.length === 0) {
|
|
502
|
+
console.log(warning('No containers found'))
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (stopped.length === 0) {
|
|
507
|
+
console.log(
|
|
508
|
+
warning(
|
|
509
|
+
'All containers are running. Stop a container first to clone it.',
|
|
510
|
+
),
|
|
511
|
+
)
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const sourceName = await promptContainerSelect(
|
|
516
|
+
stopped,
|
|
517
|
+
'Select container to clone:',
|
|
518
|
+
)
|
|
519
|
+
if (!sourceName) return
|
|
520
|
+
|
|
521
|
+
const { targetName } = await inquirer.prompt<{ targetName: string }>([
|
|
522
|
+
{
|
|
523
|
+
type: 'input',
|
|
524
|
+
name: 'targetName',
|
|
525
|
+
message: 'Name for the cloned container:',
|
|
526
|
+
default: `${sourceName}-copy`,
|
|
527
|
+
validate: (input: string) => {
|
|
528
|
+
if (!input) return 'Name is required'
|
|
529
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
|
|
530
|
+
return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
|
|
531
|
+
}
|
|
532
|
+
return true
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
])
|
|
536
|
+
|
|
537
|
+
const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
|
|
538
|
+
spinner.start()
|
|
539
|
+
|
|
540
|
+
const newConfig = await containerManager.clone(sourceName, targetName)
|
|
541
|
+
|
|
542
|
+
spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
|
|
543
|
+
|
|
544
|
+
const engine = getEngine(newConfig.engine)
|
|
545
|
+
const connectionString = engine.getConnectionString(newConfig)
|
|
546
|
+
|
|
547
|
+
console.log()
|
|
548
|
+
console.log(connectionBox(targetName, connectionString, newConfig.port))
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function handleDelete(): Promise<void> {
|
|
552
|
+
const containers = await containerManager.list()
|
|
553
|
+
|
|
554
|
+
if (containers.length === 0) {
|
|
555
|
+
console.log(warning('No containers found'))
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const containerName = await promptContainerSelect(
|
|
560
|
+
containers,
|
|
561
|
+
'Select container to delete:',
|
|
562
|
+
)
|
|
563
|
+
if (!containerName) return
|
|
564
|
+
|
|
565
|
+
const config = await containerManager.getConfig(containerName)
|
|
566
|
+
if (!config) {
|
|
567
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const confirmed = await promptConfirm(
|
|
572
|
+
`Are you sure you want to delete "${containerName}"? This cannot be undone.`,
|
|
573
|
+
false,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if (!confirmed) {
|
|
577
|
+
console.log(warning('Deletion cancelled'))
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const isRunning = await processManager.isRunning(containerName)
|
|
582
|
+
|
|
583
|
+
if (isRunning) {
|
|
584
|
+
const stopSpinner = createSpinner(`Stopping ${containerName}...`)
|
|
585
|
+
stopSpinner.start()
|
|
586
|
+
|
|
587
|
+
const engine = getEngine(config.engine)
|
|
588
|
+
await engine.stop(config)
|
|
589
|
+
|
|
590
|
+
stopSpinner.succeed(`Stopped "${containerName}"`)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const deleteSpinner = createSpinner(`Deleting ${containerName}...`)
|
|
594
|
+
deleteSpinner.start()
|
|
595
|
+
|
|
596
|
+
await containerManager.delete(containerName, { force: true })
|
|
597
|
+
|
|
598
|
+
deleteSpinner.succeed(`Container "${containerName}" deleted`)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function handleChangePort(): Promise<void> {
|
|
602
|
+
const containers = await containerManager.list()
|
|
603
|
+
const stopped = containers.filter((c) => c.status !== 'running')
|
|
604
|
+
|
|
605
|
+
if (stopped.length === 0) {
|
|
606
|
+
console.log(
|
|
607
|
+
warning(
|
|
608
|
+
'No stopped containers. Stop a container first to change its port.',
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const containerName = await promptContainerSelect(
|
|
615
|
+
stopped,
|
|
616
|
+
'Select container to change port:',
|
|
617
|
+
)
|
|
618
|
+
if (!containerName) return
|
|
619
|
+
|
|
620
|
+
const config = await containerManager.getConfig(containerName)
|
|
621
|
+
if (!config) {
|
|
622
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
console.log(chalk.gray(` Current port: ${config.port}`))
|
|
627
|
+
console.log()
|
|
628
|
+
|
|
629
|
+
const { newPort } = await inquirer.prompt<{ newPort: number }>([
|
|
630
|
+
{
|
|
631
|
+
type: 'input',
|
|
632
|
+
name: 'newPort',
|
|
633
|
+
message: 'New port:',
|
|
634
|
+
default: String(config.port),
|
|
635
|
+
validate: (input: string) => {
|
|
636
|
+
const num = parseInt(input, 10)
|
|
637
|
+
if (isNaN(num) || num < 1 || num > 65535) {
|
|
638
|
+
return 'Port must be a number between 1 and 65535'
|
|
639
|
+
}
|
|
640
|
+
return true
|
|
641
|
+
},
|
|
642
|
+
filter: (input: string) => parseInt(input, 10),
|
|
643
|
+
},
|
|
644
|
+
])
|
|
645
|
+
|
|
646
|
+
if (newPort === config.port) {
|
|
647
|
+
console.log(info('Port unchanged'))
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Check if port is available
|
|
652
|
+
const portAvailable = await portManager.isPortAvailable(newPort)
|
|
653
|
+
if (!portAvailable) {
|
|
654
|
+
console.log(warning(`Port ${newPort} is already in use`))
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
await containerManager.updateConfig(containerName, { port: newPort })
|
|
659
|
+
|
|
660
|
+
console.log(
|
|
661
|
+
success(`Changed ${containerName} port from ${config.port} to ${newPort}`),
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export const menuCommand = new Command('menu')
|
|
666
|
+
.description('Interactive menu for managing containers')
|
|
667
|
+
.action(async () => {
|
|
668
|
+
try {
|
|
669
|
+
await showMainMenu()
|
|
670
|
+
} catch (err) {
|
|
671
|
+
const e = err as Error
|
|
672
|
+
console.error(error(e.message))
|
|
673
|
+
process.exit(1)
|
|
674
|
+
}
|
|
675
|
+
})
|