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,161 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
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 { promptContainerSelect, promptDatabaseName } from '@/cli/ui/prompts'
|
|
8
|
+
import { createSpinner } from '@/cli/ui/spinner'
|
|
9
|
+
import { success, error, warning } from '@/cli/ui/theme'
|
|
10
|
+
|
|
11
|
+
export const restoreCommand = new Command('restore')
|
|
12
|
+
.description('Restore a backup to a container')
|
|
13
|
+
.argument('[name]', 'Container name')
|
|
14
|
+
.argument('[backup]', 'Path to backup file')
|
|
15
|
+
.option('-d, --database <name>', 'Target database name')
|
|
16
|
+
.action(
|
|
17
|
+
async (
|
|
18
|
+
name: string | undefined,
|
|
19
|
+
backup: string | undefined,
|
|
20
|
+
options: { database?: string },
|
|
21
|
+
) => {
|
|
22
|
+
try {
|
|
23
|
+
let containerName = name
|
|
24
|
+
const backupPath = backup
|
|
25
|
+
|
|
26
|
+
// Interactive selection if no name provided
|
|
27
|
+
if (!containerName) {
|
|
28
|
+
const containers = await containerManager.list()
|
|
29
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
30
|
+
|
|
31
|
+
if (running.length === 0) {
|
|
32
|
+
if (containers.length === 0) {
|
|
33
|
+
console.log(
|
|
34
|
+
warning('No containers found. Create one with: spindb create'),
|
|
35
|
+
)
|
|
36
|
+
} else {
|
|
37
|
+
console.log(
|
|
38
|
+
warning(
|
|
39
|
+
'No running containers. Start one first with: spindb start',
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const selected = await promptContainerSelect(
|
|
47
|
+
running,
|
|
48
|
+
'Select container to restore to:',
|
|
49
|
+
)
|
|
50
|
+
if (!selected) return
|
|
51
|
+
containerName = selected
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Get container config
|
|
55
|
+
const config = await containerManager.getConfig(containerName)
|
|
56
|
+
if (!config) {
|
|
57
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if running
|
|
62
|
+
const running = await processManager.isRunning(containerName)
|
|
63
|
+
if (!running) {
|
|
64
|
+
console.error(
|
|
65
|
+
error(
|
|
66
|
+
`Container "${containerName}" is not running. Start it first.`,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check backup file
|
|
73
|
+
if (!backupPath) {
|
|
74
|
+
console.error(error('Backup file path is required'))
|
|
75
|
+
console.log(
|
|
76
|
+
chalk.gray(' Usage: spindb restore <container> <backup-file>'),
|
|
77
|
+
)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!existsSync(backupPath)) {
|
|
82
|
+
console.error(error(`Backup file not found: ${backupPath}`))
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Get database name
|
|
87
|
+
let databaseName = options.database
|
|
88
|
+
if (!databaseName) {
|
|
89
|
+
databaseName = await promptDatabaseName(containerName)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Get engine
|
|
93
|
+
const engine = getEngine(config.engine)
|
|
94
|
+
|
|
95
|
+
// Detect backup format
|
|
96
|
+
const detectSpinner = createSpinner('Detecting backup format...')
|
|
97
|
+
detectSpinner.start()
|
|
98
|
+
|
|
99
|
+
const format = await engine.detectBackupFormat(backupPath)
|
|
100
|
+
detectSpinner.succeed(`Detected: ${format.description}`)
|
|
101
|
+
|
|
102
|
+
// Create database
|
|
103
|
+
const dbSpinner = createSpinner(
|
|
104
|
+
`Creating database "${databaseName}"...`,
|
|
105
|
+
)
|
|
106
|
+
dbSpinner.start()
|
|
107
|
+
|
|
108
|
+
await engine.createDatabase(config, databaseName)
|
|
109
|
+
dbSpinner.succeed(`Database "${databaseName}" ready`)
|
|
110
|
+
|
|
111
|
+
// Restore backup
|
|
112
|
+
const restoreSpinner = createSpinner('Restoring backup...')
|
|
113
|
+
restoreSpinner.start()
|
|
114
|
+
|
|
115
|
+
const result = await engine.restore(config, backupPath, {
|
|
116
|
+
database: databaseName,
|
|
117
|
+
createDatabase: false, // Already created
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (result.code === 0 || !result.stderr) {
|
|
121
|
+
restoreSpinner.succeed('Backup restored successfully')
|
|
122
|
+
} else {
|
|
123
|
+
// pg_restore often returns warnings even on success
|
|
124
|
+
restoreSpinner.warn('Restore completed with warnings')
|
|
125
|
+
if (result.stderr) {
|
|
126
|
+
console.log(chalk.yellow('\n Warnings:'))
|
|
127
|
+
const lines = result.stderr.split('\n').slice(0, 5)
|
|
128
|
+
lines.forEach((line) => {
|
|
129
|
+
if (line.trim()) {
|
|
130
|
+
console.log(chalk.gray(` ${line}`))
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
if (result.stderr.split('\n').length > 5) {
|
|
134
|
+
console.log(chalk.gray(' ...'))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Show connection info
|
|
140
|
+
const connectionString = engine.getConnectionString(
|
|
141
|
+
config,
|
|
142
|
+
databaseName,
|
|
143
|
+
)
|
|
144
|
+
console.log()
|
|
145
|
+
console.log(success(`Database "${databaseName}" restored`))
|
|
146
|
+
console.log()
|
|
147
|
+
console.log(chalk.gray(' Connection string:'))
|
|
148
|
+
console.log(chalk.cyan(` ${connectionString}`))
|
|
149
|
+
console.log()
|
|
150
|
+
console.log(chalk.gray(' Connect with:'))
|
|
151
|
+
console.log(
|
|
152
|
+
chalk.cyan(` spindb connect ${containerName} -d ${databaseName}`),
|
|
153
|
+
)
|
|
154
|
+
console.log()
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const e = err as Error
|
|
157
|
+
console.error(error(e.message))
|
|
158
|
+
process.exit(1)
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { containerManager } from '@/core/container-manager'
|
|
4
|
+
import { portManager } from '@/core/port-manager'
|
|
5
|
+
import { processManager } from '@/core/process-manager'
|
|
6
|
+
import { getEngine } from '@/engines'
|
|
7
|
+
import { promptContainerSelect } from '@/cli/ui/prompts'
|
|
8
|
+
import { createSpinner } from '@/cli/ui/spinner'
|
|
9
|
+
import { error, warning } from '@/cli/ui/theme'
|
|
10
|
+
|
|
11
|
+
export const startCommand = new Command('start')
|
|
12
|
+
.description('Start a container')
|
|
13
|
+
.argument('[name]', 'Container name')
|
|
14
|
+
.action(async (name: string | undefined) => {
|
|
15
|
+
try {
|
|
16
|
+
let containerName = name
|
|
17
|
+
|
|
18
|
+
// Interactive selection if no name provided
|
|
19
|
+
if (!containerName) {
|
|
20
|
+
const containers = await containerManager.list()
|
|
21
|
+
const stopped = containers.filter((c) => c.status !== 'running')
|
|
22
|
+
|
|
23
|
+
if (stopped.length === 0) {
|
|
24
|
+
if (containers.length === 0) {
|
|
25
|
+
console.log(
|
|
26
|
+
warning('No containers found. Create one with: spindb create'),
|
|
27
|
+
)
|
|
28
|
+
} else {
|
|
29
|
+
console.log(warning('All containers are already running'))
|
|
30
|
+
}
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const selected = await promptContainerSelect(
|
|
35
|
+
stopped,
|
|
36
|
+
'Select container to start:',
|
|
37
|
+
)
|
|
38
|
+
if (!selected) return
|
|
39
|
+
containerName = selected
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Get container config
|
|
43
|
+
const config = await containerManager.getConfig(containerName)
|
|
44
|
+
if (!config) {
|
|
45
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check if already running
|
|
50
|
+
const running = await processManager.isRunning(containerName)
|
|
51
|
+
if (running) {
|
|
52
|
+
console.log(warning(`Container "${containerName}" is already running`))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check port availability
|
|
57
|
+
const portAvailable = await portManager.isPortAvailable(config.port)
|
|
58
|
+
if (!portAvailable) {
|
|
59
|
+
// Try to find a new port
|
|
60
|
+
const { port: newPort } = await portManager.findAvailablePort()
|
|
61
|
+
console.log(
|
|
62
|
+
warning(
|
|
63
|
+
`Port ${config.port} is in use, switching to port ${newPort}`,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
config.port = newPort
|
|
67
|
+
await containerManager.updateConfig(containerName, { port: newPort })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Get engine and start
|
|
71
|
+
const engine = getEngine(config.engine)
|
|
72
|
+
|
|
73
|
+
const spinner = createSpinner(`Starting ${containerName}...`)
|
|
74
|
+
spinner.start()
|
|
75
|
+
|
|
76
|
+
await engine.start(config)
|
|
77
|
+
await containerManager.updateConfig(containerName, { status: 'running' })
|
|
78
|
+
|
|
79
|
+
spinner.succeed(`Container "${containerName}" started`)
|
|
80
|
+
|
|
81
|
+
// Show connection info
|
|
82
|
+
const connectionString = engine.getConnectionString(config)
|
|
83
|
+
console.log()
|
|
84
|
+
console.log(chalk.gray(' Connection string:'))
|
|
85
|
+
console.log(chalk.cyan(` ${connectionString}`))
|
|
86
|
+
console.log()
|
|
87
|
+
console.log(chalk.gray(' Connect with:'))
|
|
88
|
+
console.log(chalk.cyan(` spindb connect ${containerName}`))
|
|
89
|
+
console.log()
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const e = err as Error
|
|
92
|
+
console.error(error(e.message))
|
|
93
|
+
process.exit(1)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { containerManager } from '@/core/container-manager'
|
|
3
|
+
import { processManager } from '@/core/process-manager'
|
|
4
|
+
import { getEngine } from '@/engines'
|
|
5
|
+
import { promptContainerSelect } from '@/cli/ui/prompts'
|
|
6
|
+
import { createSpinner } from '@/cli/ui/spinner'
|
|
7
|
+
import { success, error, warning } from '@/cli/ui/theme'
|
|
8
|
+
|
|
9
|
+
export const stopCommand = new Command('stop')
|
|
10
|
+
.description('Stop a container')
|
|
11
|
+
.argument('[name]', 'Container name')
|
|
12
|
+
.option('-a, --all', 'Stop all running containers')
|
|
13
|
+
.action(async (name: string | undefined, options: { all?: boolean }) => {
|
|
14
|
+
try {
|
|
15
|
+
if (options.all) {
|
|
16
|
+
// Stop all running containers
|
|
17
|
+
const containers = await containerManager.list()
|
|
18
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
19
|
+
|
|
20
|
+
if (running.length === 0) {
|
|
21
|
+
console.log(warning('No running containers found'))
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const container of running) {
|
|
26
|
+
const spinner = createSpinner(`Stopping ${container.name}...`)
|
|
27
|
+
spinner.start()
|
|
28
|
+
|
|
29
|
+
const engine = getEngine(container.engine)
|
|
30
|
+
await engine.stop(container)
|
|
31
|
+
await containerManager.updateConfig(container.name, {
|
|
32
|
+
status: 'stopped',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
spinner.succeed(`Stopped "${container.name}"`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(success(`Stopped ${running.length} container(s)`))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let containerName = name
|
|
43
|
+
|
|
44
|
+
// Interactive selection if no name provided
|
|
45
|
+
if (!containerName) {
|
|
46
|
+
const containers = await containerManager.list()
|
|
47
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
48
|
+
|
|
49
|
+
if (running.length === 0) {
|
|
50
|
+
console.log(warning('No running containers found'))
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const selected = await promptContainerSelect(
|
|
55
|
+
running,
|
|
56
|
+
'Select container to stop:',
|
|
57
|
+
)
|
|
58
|
+
if (!selected) return
|
|
59
|
+
containerName = selected
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Get container config
|
|
63
|
+
const config = await containerManager.getConfig(containerName)
|
|
64
|
+
if (!config) {
|
|
65
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if running
|
|
70
|
+
const running = await processManager.isRunning(containerName)
|
|
71
|
+
if (!running) {
|
|
72
|
+
console.log(warning(`Container "${containerName}" is not running`))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Get engine and stop
|
|
77
|
+
const engine = getEngine(config.engine)
|
|
78
|
+
|
|
79
|
+
const spinner = createSpinner(`Stopping ${containerName}...`)
|
|
80
|
+
spinner.start()
|
|
81
|
+
|
|
82
|
+
await engine.stop(config)
|
|
83
|
+
await containerManager.updateConfig(containerName, { status: 'stopped' })
|
|
84
|
+
|
|
85
|
+
spinner.succeed(`Container "${containerName}" stopped`)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const e = err as Error
|
|
88
|
+
console.error(error(e.message))
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
})
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { program } from 'commander'
|
|
2
|
+
import { createCommand } from '@/cli/commands/create'
|
|
3
|
+
import { listCommand } from '@/cli/commands/list'
|
|
4
|
+
import { startCommand } from '@/cli/commands/start'
|
|
5
|
+
import { stopCommand } from '@/cli/commands/stop'
|
|
6
|
+
import { deleteCommand } from '@/cli/commands/delete'
|
|
7
|
+
import { restoreCommand } from '@/cli/commands/restore'
|
|
8
|
+
import { connectCommand } from '@/cli/commands/connect'
|
|
9
|
+
import { cloneCommand } from '@/cli/commands/clone'
|
|
10
|
+
import { menuCommand } from '@/cli/commands/menu'
|
|
11
|
+
import { configCommand } from '@/cli/commands/config'
|
|
12
|
+
|
|
13
|
+
export async function run(): Promise<void> {
|
|
14
|
+
program
|
|
15
|
+
.name('spindb')
|
|
16
|
+
.description('Spin up local database containers without Docker')
|
|
17
|
+
.version('0.1.0')
|
|
18
|
+
|
|
19
|
+
program.addCommand(createCommand)
|
|
20
|
+
program.addCommand(listCommand)
|
|
21
|
+
program.addCommand(startCommand)
|
|
22
|
+
program.addCommand(stopCommand)
|
|
23
|
+
program.addCommand(deleteCommand)
|
|
24
|
+
program.addCommand(restoreCommand)
|
|
25
|
+
program.addCommand(connectCommand)
|
|
26
|
+
program.addCommand(cloneCommand)
|
|
27
|
+
program.addCommand(menuCommand)
|
|
28
|
+
program.addCommand(configCommand)
|
|
29
|
+
|
|
30
|
+
// If no arguments provided, show interactive menu
|
|
31
|
+
if (process.argv.length <= 2) {
|
|
32
|
+
const { menuCommand: menu } = await import('@/cli/commands/menu')
|
|
33
|
+
await menu.parseAsync([])
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
program.parse()
|
|
38
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import inquirer from 'inquirer'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { listEngines } from '@/engines'
|
|
4
|
+
import { defaults } from '@/config/defaults'
|
|
5
|
+
import type { ContainerConfig } from '@/types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Prompt for container name
|
|
9
|
+
*/
|
|
10
|
+
export async function promptContainerName(
|
|
11
|
+
defaultName?: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const { name } = await inquirer.prompt<{ name: string }>([
|
|
14
|
+
{
|
|
15
|
+
type: 'input',
|
|
16
|
+
name: 'name',
|
|
17
|
+
message: 'Container name:',
|
|
18
|
+
default: defaultName,
|
|
19
|
+
validate: (input: string) => {
|
|
20
|
+
if (!input) return 'Name is required'
|
|
21
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
|
|
22
|
+
return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
|
|
23
|
+
}
|
|
24
|
+
return true
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
])
|
|
28
|
+
return name
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Prompt for database engine selection
|
|
33
|
+
*/
|
|
34
|
+
export async function promptEngine(): Promise<string> {
|
|
35
|
+
const engines = listEngines()
|
|
36
|
+
|
|
37
|
+
const { engine } = await inquirer.prompt<{ engine: string }>([
|
|
38
|
+
{
|
|
39
|
+
type: 'list',
|
|
40
|
+
name: 'engine',
|
|
41
|
+
message: 'Select database engine:',
|
|
42
|
+
choices: engines.map((e) => ({
|
|
43
|
+
name: `${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
|
|
44
|
+
value: e.name,
|
|
45
|
+
short: e.displayName,
|
|
46
|
+
})),
|
|
47
|
+
},
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
return engine
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Prompt for PostgreSQL version
|
|
55
|
+
*/
|
|
56
|
+
export async function promptVersion(engine: string): Promise<string> {
|
|
57
|
+
const engines = listEngines()
|
|
58
|
+
const selectedEngine = engines.find((e) => e.name === engine)
|
|
59
|
+
const versions =
|
|
60
|
+
selectedEngine?.supportedVersions || defaults.supportedPostgresVersions
|
|
61
|
+
|
|
62
|
+
const { version } = await inquirer.prompt<{ version: string }>([
|
|
63
|
+
{
|
|
64
|
+
type: 'list',
|
|
65
|
+
name: 'version',
|
|
66
|
+
message: 'Select version:',
|
|
67
|
+
choices: versions.map((v, i) => ({
|
|
68
|
+
name: i === versions.length - 1 ? `${v} ${chalk.green('(latest)')}` : v,
|
|
69
|
+
value: v,
|
|
70
|
+
})),
|
|
71
|
+
default: versions[versions.length - 1], // Default to latest
|
|
72
|
+
},
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
return version
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Prompt for port
|
|
80
|
+
*/
|
|
81
|
+
export async function promptPort(
|
|
82
|
+
defaultPort: number = defaults.port,
|
|
83
|
+
): Promise<number> {
|
|
84
|
+
const { port } = await inquirer.prompt<{ port: number }>([
|
|
85
|
+
{
|
|
86
|
+
type: 'input',
|
|
87
|
+
name: 'port',
|
|
88
|
+
message: 'Port:',
|
|
89
|
+
default: String(defaultPort),
|
|
90
|
+
validate: (input: string) => {
|
|
91
|
+
const num = parseInt(input, 10)
|
|
92
|
+
if (isNaN(num) || num < 1 || num > 65535) {
|
|
93
|
+
return 'Port must be a number between 1 and 65535'
|
|
94
|
+
}
|
|
95
|
+
return true
|
|
96
|
+
},
|
|
97
|
+
filter: (input: string) => parseInt(input, 10),
|
|
98
|
+
},
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
return port
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Prompt for confirmation using arrow-key selection
|
|
106
|
+
*/
|
|
107
|
+
export async function promptConfirm(
|
|
108
|
+
message: string,
|
|
109
|
+
defaultValue: boolean = true,
|
|
110
|
+
): Promise<boolean> {
|
|
111
|
+
const { confirmed } = await inquirer.prompt<{ confirmed: string }>([
|
|
112
|
+
{
|
|
113
|
+
type: 'list',
|
|
114
|
+
name: 'confirmed',
|
|
115
|
+
message,
|
|
116
|
+
choices: [
|
|
117
|
+
{ name: 'Yes', value: 'yes' },
|
|
118
|
+
{ name: 'No', value: 'no' },
|
|
119
|
+
],
|
|
120
|
+
default: defaultValue ? 'yes' : 'no',
|
|
121
|
+
},
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
return confirmed === 'yes'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Prompt for container selection from a list
|
|
129
|
+
*/
|
|
130
|
+
export async function promptContainerSelect(
|
|
131
|
+
containers: ContainerConfig[],
|
|
132
|
+
message: string = 'Select container:',
|
|
133
|
+
): Promise<string | null> {
|
|
134
|
+
if (containers.length === 0) {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { container } = await inquirer.prompt<{ container: string }>([
|
|
139
|
+
{
|
|
140
|
+
type: 'list',
|
|
141
|
+
name: 'container',
|
|
142
|
+
message,
|
|
143
|
+
choices: containers.map((c) => ({
|
|
144
|
+
name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${
|
|
145
|
+
c.status === 'running'
|
|
146
|
+
? chalk.green('● running')
|
|
147
|
+
: chalk.gray('○ stopped')
|
|
148
|
+
}`,
|
|
149
|
+
value: c.name,
|
|
150
|
+
short: c.name,
|
|
151
|
+
})),
|
|
152
|
+
},
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
return container
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Prompt for database name
|
|
160
|
+
*/
|
|
161
|
+
export async function promptDatabaseName(
|
|
162
|
+
defaultName?: string,
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
const { database } = await inquirer.prompt<{ database: string }>([
|
|
165
|
+
{
|
|
166
|
+
type: 'input',
|
|
167
|
+
name: 'database',
|
|
168
|
+
message: 'Database name:',
|
|
169
|
+
default: defaultName,
|
|
170
|
+
validate: (input: string) => {
|
|
171
|
+
if (!input) return 'Database name is required'
|
|
172
|
+
return true
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
])
|
|
176
|
+
|
|
177
|
+
return database
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface CreateOptions {
|
|
181
|
+
name: string
|
|
182
|
+
engine: string
|
|
183
|
+
version: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Full interactive create flow
|
|
188
|
+
*/
|
|
189
|
+
export async function promptCreateOptions(): Promise<CreateOptions> {
|
|
190
|
+
console.log(chalk.cyan('\n 🗄️ Create New Database Container\n'))
|
|
191
|
+
|
|
192
|
+
const name = await promptContainerName()
|
|
193
|
+
const engine = await promptEngine()
|
|
194
|
+
const version = await promptVersion(engine)
|
|
195
|
+
|
|
196
|
+
return { name, engine, version }
|
|
197
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import ora, { type Ora } from 'ora'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a spinner with consistent styling
|
|
5
|
+
*/
|
|
6
|
+
export function createSpinner(text: string): Ora {
|
|
7
|
+
return ora({
|
|
8
|
+
text,
|
|
9
|
+
color: 'cyan',
|
|
10
|
+
spinner: 'dots',
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run an async operation with a spinner
|
|
16
|
+
*/
|
|
17
|
+
export async function withSpinner<T>(
|
|
18
|
+
text: string,
|
|
19
|
+
operation: (updateText: (message: string) => void) => Promise<T>,
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
const spinner = createSpinner(text)
|
|
22
|
+
spinner.start()
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const result = await operation((message: string) => {
|
|
26
|
+
spinner.text = message
|
|
27
|
+
})
|
|
28
|
+
spinner.succeed()
|
|
29
|
+
return result
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const error = err as Error
|
|
32
|
+
spinner.fail(error.message)
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Progress tracker for multi-step operations
|
|
39
|
+
*/
|
|
40
|
+
export class ProgressTracker {
|
|
41
|
+
private steps: string[]
|
|
42
|
+
private currentStep: number
|
|
43
|
+
private spinner: Ora | null
|
|
44
|
+
|
|
45
|
+
constructor(steps: string[]) {
|
|
46
|
+
this.steps = steps
|
|
47
|
+
this.currentStep = 0
|
|
48
|
+
this.spinner = null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
start(): void {
|
|
52
|
+
if (this.steps.length > 0) {
|
|
53
|
+
this.spinner = createSpinner(this.steps[0])
|
|
54
|
+
this.spinner.start()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
nextStep(): void {
|
|
59
|
+
if (this.spinner) {
|
|
60
|
+
this.spinner.succeed()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.currentStep++
|
|
64
|
+
|
|
65
|
+
if (this.currentStep < this.steps.length) {
|
|
66
|
+
this.spinner = createSpinner(this.steps[this.currentStep])
|
|
67
|
+
this.spinner.start()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
updateText(text: string): void {
|
|
72
|
+
if (this.spinner) {
|
|
73
|
+
this.spinner.text = text
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
succeed(text?: string): void {
|
|
78
|
+
if (this.spinner) {
|
|
79
|
+
this.spinner.succeed(text)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fail(text?: string): void {
|
|
84
|
+
if (this.spinner) {
|
|
85
|
+
this.spinner.fail(text)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
warn(text?: string): void {
|
|
90
|
+
if (this.spinner) {
|
|
91
|
+
this.spinner.warn(text)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|