spindb 0.5.2 → 0.5.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 +137 -8
- package/cli/commands/connect.ts +8 -4
- package/cli/commands/create.ts +106 -67
- package/cli/commands/deps.ts +19 -4
- package/cli/commands/edit.ts +245 -0
- package/cli/commands/engines.ts +434 -0
- package/cli/commands/info.ts +279 -0
- package/cli/commands/menu.ts +408 -153
- package/cli/commands/restore.ts +10 -24
- package/cli/commands/start.ts +25 -20
- package/cli/commands/url.ts +79 -0
- package/cli/index.ts +9 -3
- package/cli/ui/prompts.ts +8 -6
- package/config/engine-defaults.ts +24 -1
- package/config/os-dependencies.ts +59 -113
- package/config/paths.ts +7 -36
- package/core/binary-manager.ts +19 -6
- package/core/config-manager.ts +17 -5
- package/core/dependency-manager.ts +9 -15
- package/core/error-handler.ts +336 -0
- package/core/platform-service.ts +634 -0
- package/core/port-manager.ts +11 -3
- package/core/process-manager.ts +12 -2
- package/core/start-with-retry.ts +167 -0
- package/core/transaction-manager.ts +170 -0
- package/engines/mysql/binary-detection.ts +177 -100
- package/engines/mysql/index.ts +240 -131
- package/engines/mysql/restore.ts +257 -0
- package/engines/mysql/version-validator.ts +373 -0
- package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
- package/engines/postgresql/binary-urls.ts +5 -3
- package/engines/postgresql/index.ts +4 -3
- package/engines/postgresql/restore.ts +54 -5
- package/engines/postgresql/version-validator.ts +262 -0
- package/package.json +6 -2
- package/cli/commands/postgres-tools.ts +0 -216
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import inquirer from 'inquirer'
|
|
4
|
+
import { containerManager } from '../../core/container-manager'
|
|
5
|
+
import { processManager } from '../../core/process-manager'
|
|
6
|
+
import { portManager } from '../../core/port-manager'
|
|
7
|
+
import { promptContainerSelect } from '../ui/prompts'
|
|
8
|
+
import { createSpinner } from '../ui/spinner'
|
|
9
|
+
import { error, warning, success } from '../ui/theme'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate container name format
|
|
13
|
+
*/
|
|
14
|
+
function isValidName(name: string): boolean {
|
|
15
|
+
return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Prompt for what to edit when no options provided
|
|
20
|
+
*/
|
|
21
|
+
async function promptEditAction(): Promise<'name' | 'port' | null> {
|
|
22
|
+
const { action } = await inquirer.prompt<{ action: string }>([
|
|
23
|
+
{
|
|
24
|
+
type: 'list',
|
|
25
|
+
name: 'action',
|
|
26
|
+
message: 'What would you like to edit?',
|
|
27
|
+
choices: [
|
|
28
|
+
{ name: 'Rename container', value: 'name' },
|
|
29
|
+
{ name: 'Change port', value: 'port' },
|
|
30
|
+
{ name: chalk.gray('Cancel'), value: 'cancel' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
if (action === 'cancel') return null
|
|
36
|
+
return action as 'name' | 'port'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prompt for new container name
|
|
41
|
+
*/
|
|
42
|
+
async function promptNewName(currentName: string): Promise<string | null> {
|
|
43
|
+
const { newName } = await inquirer.prompt<{ newName: string }>([
|
|
44
|
+
{
|
|
45
|
+
type: 'input',
|
|
46
|
+
name: 'newName',
|
|
47
|
+
message: 'New container name:',
|
|
48
|
+
default: currentName,
|
|
49
|
+
validate: (input: string) => {
|
|
50
|
+
if (!input) return 'Name is required'
|
|
51
|
+
if (!isValidName(input)) {
|
|
52
|
+
return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
|
|
53
|
+
}
|
|
54
|
+
return true
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
if (newName === currentName) {
|
|
60
|
+
console.log(warning('Name unchanged'))
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return newName
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prompt for new port
|
|
69
|
+
*/
|
|
70
|
+
async function promptNewPort(currentPort: number): Promise<number | null> {
|
|
71
|
+
const { newPort } = await inquirer.prompt<{ newPort: number }>([
|
|
72
|
+
{
|
|
73
|
+
type: 'input',
|
|
74
|
+
name: 'newPort',
|
|
75
|
+
message: 'New port:',
|
|
76
|
+
default: String(currentPort),
|
|
77
|
+
validate: (input: string) => {
|
|
78
|
+
const num = parseInt(input, 10)
|
|
79
|
+
if (isNaN(num) || num < 1 || num > 65535) {
|
|
80
|
+
return 'Port must be a number between 1 and 65535'
|
|
81
|
+
}
|
|
82
|
+
return true
|
|
83
|
+
},
|
|
84
|
+
filter: (input: string) => parseInt(input, 10),
|
|
85
|
+
},
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
if (newPort === currentPort) {
|
|
89
|
+
console.log(warning('Port unchanged'))
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return newPort
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const editCommand = new Command('edit')
|
|
97
|
+
.description('Edit container properties (rename or change port)')
|
|
98
|
+
.argument('[name]', 'Container name')
|
|
99
|
+
.option('-n, --name <newName>', 'New container name')
|
|
100
|
+
.option('-p, --port <port>', 'New port number', parseInt)
|
|
101
|
+
.action(
|
|
102
|
+
async (
|
|
103
|
+
name: string | undefined,
|
|
104
|
+
options: { name?: string; port?: number },
|
|
105
|
+
) => {
|
|
106
|
+
try {
|
|
107
|
+
let containerName = name
|
|
108
|
+
|
|
109
|
+
// Interactive selection if no name provided
|
|
110
|
+
if (!containerName) {
|
|
111
|
+
const containers = await containerManager.list()
|
|
112
|
+
|
|
113
|
+
if (containers.length === 0) {
|
|
114
|
+
console.log(warning('No containers found'))
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const selected = await promptContainerSelect(
|
|
119
|
+
containers,
|
|
120
|
+
'Select container to edit:',
|
|
121
|
+
)
|
|
122
|
+
if (!selected) return
|
|
123
|
+
containerName = selected
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Get container config
|
|
127
|
+
const config = await containerManager.getConfig(containerName)
|
|
128
|
+
if (!config) {
|
|
129
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// If no options provided, prompt for what to edit
|
|
134
|
+
if (options.name === undefined && options.port === undefined) {
|
|
135
|
+
const action = await promptEditAction()
|
|
136
|
+
if (!action) return
|
|
137
|
+
|
|
138
|
+
if (action === 'name') {
|
|
139
|
+
const newName = await promptNewName(containerName)
|
|
140
|
+
if (newName) {
|
|
141
|
+
options.name = newName
|
|
142
|
+
} else {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
} else if (action === 'port') {
|
|
146
|
+
const newPort = await promptNewPort(config.port)
|
|
147
|
+
if (newPort) {
|
|
148
|
+
options.port = newPort
|
|
149
|
+
} else {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle rename
|
|
156
|
+
if (options.name) {
|
|
157
|
+
// Validate new name
|
|
158
|
+
if (!isValidName(options.name)) {
|
|
159
|
+
console.error(
|
|
160
|
+
error(
|
|
161
|
+
'Name must start with a letter and contain only letters, numbers, hyphens, and underscores',
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
process.exit(1)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if new name already exists
|
|
168
|
+
const exists = await containerManager.exists(options.name, {
|
|
169
|
+
engine: config.engine,
|
|
170
|
+
})
|
|
171
|
+
if (exists) {
|
|
172
|
+
console.error(error(`Container "${options.name}" already exists`))
|
|
173
|
+
process.exit(1)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check if container is running
|
|
177
|
+
const running = await processManager.isRunning(containerName, {
|
|
178
|
+
engine: config.engine,
|
|
179
|
+
})
|
|
180
|
+
if (running) {
|
|
181
|
+
console.error(
|
|
182
|
+
error(
|
|
183
|
+
`Container "${containerName}" is running. Stop it first to rename.`,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
process.exit(1)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Rename the container
|
|
190
|
+
const spinner = createSpinner(
|
|
191
|
+
`Renaming "${containerName}" to "${options.name}"...`,
|
|
192
|
+
)
|
|
193
|
+
spinner.start()
|
|
194
|
+
|
|
195
|
+
await containerManager.rename(containerName, options.name)
|
|
196
|
+
|
|
197
|
+
spinner.succeed(`Renamed "${containerName}" to "${options.name}"`)
|
|
198
|
+
|
|
199
|
+
// Update containerName for subsequent operations
|
|
200
|
+
containerName = options.name
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle port change
|
|
204
|
+
if (options.port !== undefined) {
|
|
205
|
+
// Validate port
|
|
206
|
+
if (options.port < 1 || options.port > 65535) {
|
|
207
|
+
console.error(error('Port must be between 1 and 65535'))
|
|
208
|
+
process.exit(1)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check port availability (warning only)
|
|
212
|
+
const portAvailable = await portManager.isPortAvailable(options.port)
|
|
213
|
+
if (!portAvailable) {
|
|
214
|
+
console.log(
|
|
215
|
+
warning(
|
|
216
|
+
`Port ${options.port} is currently in use. The container will use this port on next start.`,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Update the config
|
|
222
|
+
const spinner = createSpinner(`Changing port to ${options.port}...`)
|
|
223
|
+
spinner.start()
|
|
224
|
+
|
|
225
|
+
await containerManager.updateConfig(containerName, {
|
|
226
|
+
port: options.port,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
spinner.succeed(`Port changed to ${options.port}`)
|
|
230
|
+
console.log(
|
|
231
|
+
chalk.gray(
|
|
232
|
+
' Note: Port change takes effect on next container start.',
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log()
|
|
238
|
+
console.log(success('Container updated successfully'))
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const e = err as Error
|
|
241
|
+
console.error(error(e.message))
|
|
242
|
+
process.exit(1)
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { readdir, lstat, rm } from 'fs/promises'
|
|
4
|
+
import { existsSync } from 'fs'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { exec } from 'child_process'
|
|
7
|
+
import { promisify } from 'util'
|
|
8
|
+
import inquirer from 'inquirer'
|
|
9
|
+
import { paths } from '../../config/paths'
|
|
10
|
+
import { containerManager } from '../../core/container-manager'
|
|
11
|
+
import { promptConfirm } from '../ui/prompts'
|
|
12
|
+
import { createSpinner } from '../ui/spinner'
|
|
13
|
+
import { error, warning, info } from '../ui/theme'
|
|
14
|
+
import {
|
|
15
|
+
getMysqldPath,
|
|
16
|
+
getMysqlVersion,
|
|
17
|
+
isMariaDB,
|
|
18
|
+
} from '../../engines/mysql/binary-detection'
|
|
19
|
+
|
|
20
|
+
const execAsync = promisify(exec)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Installed engine info for PostgreSQL (downloaded binaries)
|
|
24
|
+
*/
|
|
25
|
+
type InstalledPostgresEngine = {
|
|
26
|
+
engine: 'postgresql'
|
|
27
|
+
version: string
|
|
28
|
+
platform: string
|
|
29
|
+
arch: string
|
|
30
|
+
path: string
|
|
31
|
+
sizeBytes: number
|
|
32
|
+
source: 'downloaded'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Installed engine info for MySQL (system-installed)
|
|
37
|
+
*/
|
|
38
|
+
type InstalledMysqlEngine = {
|
|
39
|
+
engine: 'mysql'
|
|
40
|
+
version: string
|
|
41
|
+
path: string
|
|
42
|
+
source: 'system'
|
|
43
|
+
isMariaDB: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the actual PostgreSQL version from the binary
|
|
50
|
+
*/
|
|
51
|
+
async function getPostgresVersion(binPath: string): Promise<string | null> {
|
|
52
|
+
const postgresPath = join(binPath, 'bin', 'postgres')
|
|
53
|
+
if (!existsSync(postgresPath)) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await execAsync(`"${postgresPath}" --version`)
|
|
59
|
+
// Output: postgres (PostgreSQL) 17.7
|
|
60
|
+
const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
|
|
61
|
+
return match ? match[1] : null
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get installed PostgreSQL engines from ~/.spindb/bin/
|
|
69
|
+
*/
|
|
70
|
+
async function getInstalledPostgresEngines(): Promise<
|
|
71
|
+
InstalledPostgresEngine[]
|
|
72
|
+
> {
|
|
73
|
+
const binDir = paths.bin
|
|
74
|
+
|
|
75
|
+
if (!existsSync(binDir)) {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const entries = await readdir(binDir, { withFileTypes: true })
|
|
80
|
+
const engines: InstalledPostgresEngine[] = []
|
|
81
|
+
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
// Parse directory name: postgresql-17-darwin-arm64
|
|
85
|
+
const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
|
|
86
|
+
if (match && match[1] === 'postgresql') {
|
|
87
|
+
const [, , majorVersion, platform, arch] = match
|
|
88
|
+
const dirPath = join(binDir, entry.name)
|
|
89
|
+
|
|
90
|
+
// Get actual version from the binary
|
|
91
|
+
const actualVersion =
|
|
92
|
+
(await getPostgresVersion(dirPath)) || majorVersion
|
|
93
|
+
|
|
94
|
+
// Get directory size
|
|
95
|
+
let sizeBytes = 0
|
|
96
|
+
try {
|
|
97
|
+
const files = await readdir(dirPath, { recursive: true })
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
try {
|
|
100
|
+
const filePath = join(dirPath, file.toString())
|
|
101
|
+
const fileStat = await lstat(filePath)
|
|
102
|
+
if (fileStat.isFile()) {
|
|
103
|
+
sizeBytes += fileStat.size
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Skip files we can't stat
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Skip directories we can't read
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
engines.push({
|
|
114
|
+
engine: 'postgresql',
|
|
115
|
+
version: actualVersion,
|
|
116
|
+
platform,
|
|
117
|
+
arch,
|
|
118
|
+
path: dirPath,
|
|
119
|
+
sizeBytes,
|
|
120
|
+
source: 'downloaded',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sort by version descending
|
|
127
|
+
engines.sort((a, b) => compareVersions(b.version, a.version))
|
|
128
|
+
|
|
129
|
+
return engines
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Detect system-installed MySQL
|
|
134
|
+
*/
|
|
135
|
+
async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
|
|
136
|
+
const mysqldPath = await getMysqldPath()
|
|
137
|
+
if (!mysqldPath) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const version = await getMysqlVersion(mysqldPath)
|
|
142
|
+
if (!version) {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const mariadb = await isMariaDB()
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
engine: 'mysql',
|
|
150
|
+
version,
|
|
151
|
+
path: mysqldPath,
|
|
152
|
+
source: 'system',
|
|
153
|
+
isMariaDB: mariadb,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get all installed engines (PostgreSQL + MySQL)
|
|
159
|
+
*/
|
|
160
|
+
async function getInstalledEngines(): Promise<InstalledEngine[]> {
|
|
161
|
+
const engines: InstalledEngine[] = []
|
|
162
|
+
|
|
163
|
+
// Get PostgreSQL engines
|
|
164
|
+
const pgEngines = await getInstalledPostgresEngines()
|
|
165
|
+
engines.push(...pgEngines)
|
|
166
|
+
|
|
167
|
+
// Get MySQL engine
|
|
168
|
+
const mysqlEngine = await getInstalledMysqlEngine()
|
|
169
|
+
if (mysqlEngine) {
|
|
170
|
+
engines.push(mysqlEngine)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return engines
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function compareVersions(a: string, b: string): number {
|
|
177
|
+
const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
|
|
178
|
+
const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
181
|
+
const numA = partsA[i] || 0
|
|
182
|
+
const numB = partsB[i] || 0
|
|
183
|
+
if (numA !== numB) return numA - numB
|
|
184
|
+
}
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatBytes(bytes: number): string {
|
|
189
|
+
if (bytes === 0) return '0 B'
|
|
190
|
+
const k = 1024
|
|
191
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
192
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
193
|
+
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Engine icons
|
|
198
|
+
*/
|
|
199
|
+
const engineIcons: Record<string, string> = {
|
|
200
|
+
postgresql: '🐘',
|
|
201
|
+
mysql: '🐬',
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* List subcommand action
|
|
206
|
+
*/
|
|
207
|
+
async function listEngines(options: { json?: boolean }): Promise<void> {
|
|
208
|
+
const engines = await getInstalledEngines()
|
|
209
|
+
|
|
210
|
+
if (options.json) {
|
|
211
|
+
console.log(JSON.stringify(engines, null, 2))
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (engines.length === 0) {
|
|
216
|
+
console.log(info('No engines installed yet.'))
|
|
217
|
+
console.log(
|
|
218
|
+
chalk.gray(
|
|
219
|
+
' PostgreSQL engines are downloaded automatically when you create a container.',
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
console.log(
|
|
223
|
+
chalk.gray(
|
|
224
|
+
' MySQL requires system installation (brew install mysql or apt install mysql-server).',
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Separate PostgreSQL and MySQL
|
|
231
|
+
const pgEngines = engines.filter(
|
|
232
|
+
(e): e is InstalledPostgresEngine => e.engine === 'postgresql',
|
|
233
|
+
)
|
|
234
|
+
const mysqlEngine = engines.find(
|
|
235
|
+
(e): e is InstalledMysqlEngine => e.engine === 'mysql',
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// Calculate total size for PostgreSQL
|
|
239
|
+
const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
|
|
240
|
+
|
|
241
|
+
// Table header
|
|
242
|
+
console.log()
|
|
243
|
+
console.log(
|
|
244
|
+
chalk.gray(' ') +
|
|
245
|
+
chalk.bold.white('ENGINE'.padEnd(14)) +
|
|
246
|
+
chalk.bold.white('VERSION'.padEnd(12)) +
|
|
247
|
+
chalk.bold.white('SOURCE'.padEnd(18)) +
|
|
248
|
+
chalk.bold.white('SIZE'),
|
|
249
|
+
)
|
|
250
|
+
console.log(chalk.gray(' ' + '─'.repeat(55)))
|
|
251
|
+
|
|
252
|
+
// PostgreSQL rows
|
|
253
|
+
for (const engine of pgEngines) {
|
|
254
|
+
const icon = engineIcons[engine.engine] || '🗄️'
|
|
255
|
+
const platformInfo = `${engine.platform}-${engine.arch}`
|
|
256
|
+
|
|
257
|
+
console.log(
|
|
258
|
+
chalk.gray(' ') +
|
|
259
|
+
chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
|
|
260
|
+
chalk.yellow(engine.version.padEnd(12)) +
|
|
261
|
+
chalk.gray(platformInfo.padEnd(18)) +
|
|
262
|
+
chalk.white(formatBytes(engine.sizeBytes)),
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// MySQL row
|
|
267
|
+
if (mysqlEngine) {
|
|
268
|
+
const icon = engineIcons.mysql
|
|
269
|
+
const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
|
|
270
|
+
|
|
271
|
+
console.log(
|
|
272
|
+
chalk.gray(' ') +
|
|
273
|
+
chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
|
|
274
|
+
chalk.yellow(mysqlEngine.version.padEnd(12)) +
|
|
275
|
+
chalk.gray('system'.padEnd(18)) +
|
|
276
|
+
chalk.gray('(system-installed)'),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
console.log(chalk.gray(' ' + '─'.repeat(55)))
|
|
281
|
+
|
|
282
|
+
// Summary
|
|
283
|
+
console.log()
|
|
284
|
+
if (pgEngines.length > 0) {
|
|
285
|
+
console.log(
|
|
286
|
+
chalk.gray(
|
|
287
|
+
` PostgreSQL: ${pgEngines.length} version(s), ${formatBytes(totalPgSize)}`,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
if (mysqlEngine) {
|
|
292
|
+
console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
|
|
293
|
+
}
|
|
294
|
+
console.log()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Delete subcommand action
|
|
299
|
+
*/
|
|
300
|
+
async function deleteEngine(
|
|
301
|
+
engine: string | undefined,
|
|
302
|
+
version: string | undefined,
|
|
303
|
+
options: { yes?: boolean },
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
// Get PostgreSQL engines only (MySQL can't be deleted via spindb)
|
|
306
|
+
const pgEngines = await getInstalledPostgresEngines()
|
|
307
|
+
|
|
308
|
+
if (pgEngines.length === 0) {
|
|
309
|
+
console.log(warning('No deletable engines found.'))
|
|
310
|
+
console.log(
|
|
311
|
+
chalk.gray(
|
|
312
|
+
' MySQL is system-installed and cannot be deleted via spindb.',
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let engineName = engine
|
|
319
|
+
let engineVersion = version
|
|
320
|
+
|
|
321
|
+
// Interactive selection if not provided
|
|
322
|
+
if (!engineName || !engineVersion) {
|
|
323
|
+
const choices = pgEngines.map((e) => ({
|
|
324
|
+
name: `${engineIcons[e.engine]} ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
|
|
325
|
+
value: `${e.engine}:${e.version}:${e.path}`,
|
|
326
|
+
}))
|
|
327
|
+
|
|
328
|
+
const { selected } = await inquirer.prompt<{ selected: string }>([
|
|
329
|
+
{
|
|
330
|
+
type: 'list',
|
|
331
|
+
name: 'selected',
|
|
332
|
+
message: 'Select engine to delete:',
|
|
333
|
+
choices,
|
|
334
|
+
},
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
const [eng, ver] = selected.split(':')
|
|
338
|
+
engineName = eng
|
|
339
|
+
engineVersion = ver
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Find the engine
|
|
343
|
+
const targetEngine = pgEngines.find(
|
|
344
|
+
(e) => e.engine === engineName && e.version === engineVersion,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if (!targetEngine) {
|
|
348
|
+
console.error(error(`Engine "${engineName} ${engineVersion}" not found`))
|
|
349
|
+
process.exit(1)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Check if any containers are using this engine version
|
|
353
|
+
const containers = await containerManager.list()
|
|
354
|
+
const usingContainers = containers.filter(
|
|
355
|
+
(c) => c.engine === engineName && c.version === engineVersion,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if (usingContainers.length > 0) {
|
|
359
|
+
console.error(
|
|
360
|
+
error(
|
|
361
|
+
`Cannot delete: ${usingContainers.length} container(s) are using ${engineName} ${engineVersion}`,
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
console.log(
|
|
365
|
+
chalk.gray(
|
|
366
|
+
` Containers: ${usingContainers.map((c) => c.name).join(', ')}`,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
console.log()
|
|
370
|
+
console.log(chalk.gray(' Delete these containers first, then try again.'))
|
|
371
|
+
process.exit(1)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Confirm deletion
|
|
375
|
+
if (!options.yes) {
|
|
376
|
+
const confirmed = await promptConfirm(
|
|
377
|
+
`Delete ${engineName} ${engineVersion}? This cannot be undone.`,
|
|
378
|
+
false,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if (!confirmed) {
|
|
382
|
+
console.log(warning('Deletion cancelled'))
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Delete the engine
|
|
388
|
+
const spinner = createSpinner(`Deleting ${engineName} ${engineVersion}...`)
|
|
389
|
+
spinner.start()
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await rm(targetEngine.path, { recursive: true, force: true })
|
|
393
|
+
spinner.succeed(`Deleted ${engineName} ${engineVersion}`)
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const e = err as Error
|
|
396
|
+
spinner.fail(`Failed to delete: ${e.message}`)
|
|
397
|
+
process.exit(1)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Main engines command
|
|
402
|
+
export const enginesCommand = new Command('engines')
|
|
403
|
+
.description('Manage installed database engines')
|
|
404
|
+
.option('--json', 'Output as JSON')
|
|
405
|
+
.action(async (options: { json?: boolean }) => {
|
|
406
|
+
try {
|
|
407
|
+
await listEngines(options)
|
|
408
|
+
} catch (err) {
|
|
409
|
+
const e = err as Error
|
|
410
|
+
console.error(error(e.message))
|
|
411
|
+
process.exit(1)
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// Delete subcommand
|
|
416
|
+
enginesCommand
|
|
417
|
+
.command('delete [engine] [version]')
|
|
418
|
+
.description('Delete an installed engine version')
|
|
419
|
+
.option('-y, --yes', 'Skip confirmation')
|
|
420
|
+
.action(
|
|
421
|
+
async (
|
|
422
|
+
engine: string | undefined,
|
|
423
|
+
version: string | undefined,
|
|
424
|
+
options: { yes?: boolean },
|
|
425
|
+
) => {
|
|
426
|
+
try {
|
|
427
|
+
await deleteEngine(engine, version, options)
|
|
428
|
+
} catch (err) {
|
|
429
|
+
const e = err as Error
|
|
430
|
+
console.error(error(e.message))
|
|
431
|
+
process.exit(1)
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
)
|