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.
@@ -0,0 +1,382 @@
1
+ import chalk from 'chalk'
2
+ import inquirer from 'inquirer'
3
+ import { rm } from 'fs/promises'
4
+ import { containerManager } from '../../../core/container-manager'
5
+ import { processManager } from '../../../core/process-manager'
6
+ import { createSpinner } from '../../ui/spinner'
7
+ import { header, error, warning, info, formatBytes } from '../../ui/theme'
8
+ import { promptConfirm } from '../../ui/prompts'
9
+ import { getEngineIcon, ENGINE_ICONS } from '../../constants'
10
+ import {
11
+ getInstalledEngines,
12
+ type InstalledPostgresEngine,
13
+ type InstalledMysqlEngine,
14
+ } from '../../helpers'
15
+ import {
16
+ getMysqlVersion,
17
+ getMysqlInstallInfo,
18
+ } from '../../../engines/mysql/binary-detection'
19
+ import { type MenuChoice } from './shared'
20
+
21
+ export async function handleEngines(): Promise<void> {
22
+ console.clear()
23
+ console.log(header('Installed Engines'))
24
+ console.log()
25
+
26
+ const engines = await getInstalledEngines()
27
+
28
+ if (engines.length === 0) {
29
+ console.log(info('No engines installed yet.'))
30
+ console.log(
31
+ chalk.gray(
32
+ ' PostgreSQL engines are downloaded automatically when you create a container.',
33
+ ),
34
+ )
35
+ console.log(
36
+ chalk.gray(
37
+ ' MySQL requires system installation (brew install mysql or apt install mysql-server).',
38
+ ),
39
+ )
40
+ return
41
+ }
42
+
43
+ // Separate PostgreSQL and MySQL
44
+ const pgEngines = engines.filter(
45
+ (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
46
+ )
47
+ const mysqlEngine = engines.find(
48
+ (e): e is InstalledMysqlEngine => e.engine === 'mysql',
49
+ )
50
+
51
+ // Calculate total size for PostgreSQL
52
+ const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
53
+
54
+ // Table header
55
+ console.log()
56
+ console.log(
57
+ chalk.gray(' ') +
58
+ chalk.bold.white('ENGINE'.padEnd(14)) +
59
+ chalk.bold.white('VERSION'.padEnd(12)) +
60
+ chalk.bold.white('SOURCE'.padEnd(18)) +
61
+ chalk.bold.white('SIZE'),
62
+ )
63
+ console.log(chalk.gray(' ' + '─'.repeat(55)))
64
+
65
+ // PostgreSQL rows
66
+ for (const engine of pgEngines) {
67
+ const icon = getEngineIcon(engine.engine)
68
+ const platformInfo = `${engine.platform}-${engine.arch}`
69
+
70
+ console.log(
71
+ chalk.gray(' ') +
72
+ chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
73
+ chalk.yellow(engine.version.padEnd(12)) +
74
+ chalk.gray(platformInfo.padEnd(18)) +
75
+ chalk.white(formatBytes(engine.sizeBytes)),
76
+ )
77
+ }
78
+
79
+ // MySQL row
80
+ if (mysqlEngine) {
81
+ const icon = ENGINE_ICONS.mysql
82
+ const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
83
+
84
+ console.log(
85
+ chalk.gray(' ') +
86
+ chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
87
+ chalk.yellow(mysqlEngine.version.padEnd(12)) +
88
+ chalk.gray('system'.padEnd(18)) +
89
+ chalk.gray('(system-installed)'),
90
+ )
91
+ }
92
+
93
+ console.log(chalk.gray(' ' + '─'.repeat(55)))
94
+
95
+ // Summary
96
+ console.log()
97
+ if (pgEngines.length > 0) {
98
+ console.log(
99
+ chalk.gray(
100
+ ` PostgreSQL: ${pgEngines.length} version(s), ${formatBytes(totalPgSize)}`,
101
+ ),
102
+ )
103
+ }
104
+ if (mysqlEngine) {
105
+ console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
106
+ }
107
+ console.log()
108
+
109
+ // Menu options - only allow deletion of PostgreSQL engines
110
+ const choices: MenuChoice[] = []
111
+
112
+ for (const e of pgEngines) {
113
+ choices.push({
114
+ name: `${chalk.red('✕')} Delete ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
115
+ value: `delete:${e.path}:${e.engine}:${e.version}`,
116
+ })
117
+ }
118
+
119
+ // MySQL info option (not disabled, shows info icon)
120
+ if (mysqlEngine) {
121
+ const displayName = mysqlEngine.isMariaDB ? 'MariaDB' : 'MySQL'
122
+ choices.push({
123
+ name: `${chalk.blue('ℹ')} ${displayName} ${mysqlEngine.version} ${chalk.gray('(system-installed)')}`,
124
+ value: `mysql-info:${mysqlEngine.path}`,
125
+ })
126
+ }
127
+
128
+ choices.push(new inquirer.Separator())
129
+ choices.push({ name: `${chalk.blue('←')} Back to main menu`, value: 'back' })
130
+
131
+ const { action } = await inquirer.prompt<{ action: string }>([
132
+ {
133
+ type: 'list',
134
+ name: 'action',
135
+ message: 'Manage engines:',
136
+ choices,
137
+ pageSize: 15,
138
+ },
139
+ ])
140
+
141
+ if (action === 'back') {
142
+ return
143
+ }
144
+
145
+ if (action.startsWith('delete:')) {
146
+ const [, enginePath, engineName, engineVersion] = action.split(':')
147
+ await handleDeleteEngine(enginePath, engineName, engineVersion)
148
+ // Return to engines menu
149
+ await handleEngines()
150
+ }
151
+
152
+ if (action.startsWith('mysql-info:')) {
153
+ const mysqldPath = action.replace('mysql-info:', '')
154
+ await handleMysqlInfo(mysqldPath)
155
+ // Return to engines menu
156
+ await handleEngines()
157
+ }
158
+ }
159
+
160
+ async function handleDeleteEngine(
161
+ enginePath: string,
162
+ engineName: string,
163
+ engineVersion: string,
164
+ ): Promise<void> {
165
+ // Check if any container is using this engine version
166
+ const containers = await containerManager.list()
167
+ const usingContainers = containers.filter(
168
+ (c) => c.engine === engineName && c.version === engineVersion,
169
+ )
170
+
171
+ if (usingContainers.length > 0) {
172
+ console.log()
173
+ console.log(
174
+ error(
175
+ `Cannot delete: ${usingContainers.length} container(s) are using ${engineName} ${engineVersion}`,
176
+ ),
177
+ )
178
+ console.log(
179
+ chalk.gray(
180
+ ` Containers: ${usingContainers.map((c) => c.name).join(', ')}`,
181
+ ),
182
+ )
183
+ console.log()
184
+ await inquirer.prompt([
185
+ {
186
+ type: 'input',
187
+ name: 'continue',
188
+ message: chalk.gray('Press Enter to continue...'),
189
+ },
190
+ ])
191
+ return
192
+ }
193
+
194
+ const confirmed = await promptConfirm(
195
+ `Delete ${engineName} ${engineVersion}? This cannot be undone.`,
196
+ false,
197
+ )
198
+
199
+ if (!confirmed) {
200
+ console.log(warning('Deletion cancelled'))
201
+ return
202
+ }
203
+
204
+ const spinner = createSpinner(`Deleting ${engineName} ${engineVersion}...`)
205
+ spinner.start()
206
+
207
+ try {
208
+ await rm(enginePath, { recursive: true, force: true })
209
+ spinner.succeed(`Deleted ${engineName} ${engineVersion}`)
210
+ } catch (err) {
211
+ const e = err as Error
212
+ spinner.fail(`Failed to delete: ${e.message}`)
213
+ }
214
+ }
215
+
216
+ async function handleMysqlInfo(mysqldPath: string): Promise<void> {
217
+ console.clear()
218
+
219
+ // Get install info
220
+ const installInfo = await getMysqlInstallInfo(mysqldPath)
221
+ const displayName = installInfo.isMariaDB ? 'MariaDB' : 'MySQL'
222
+
223
+ // Get version
224
+ const version = await getMysqlVersion(mysqldPath)
225
+
226
+ console.log(header(`${displayName} Information`))
227
+ console.log()
228
+
229
+ // Check for containers using MySQL
230
+ const containers = await containerManager.list()
231
+ const mysqlContainers = containers.filter((c) => c.engine === 'mysql')
232
+
233
+ // Track running containers for uninstall instructions
234
+ const runningContainers: string[] = []
235
+
236
+ if (mysqlContainers.length > 0) {
237
+ console.log(
238
+ warning(
239
+ `${mysqlContainers.length} container(s) are using ${displayName}:`,
240
+ ),
241
+ )
242
+ console.log()
243
+ for (const c of mysqlContainers) {
244
+ const isRunning = await processManager.isRunning(c.name, {
245
+ engine: c.engine,
246
+ })
247
+ if (isRunning) {
248
+ runningContainers.push(c.name)
249
+ }
250
+ const status = isRunning
251
+ ? chalk.green('● running')
252
+ : chalk.gray('○ stopped')
253
+ console.log(chalk.gray(` • ${c.name} ${status}`))
254
+ }
255
+ console.log()
256
+ console.log(
257
+ chalk.yellow(
258
+ ' Uninstalling will break these containers. Delete them first.',
259
+ ),
260
+ )
261
+ console.log()
262
+ }
263
+
264
+ // Show installation details
265
+ console.log(chalk.white(' Installation Details:'))
266
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
267
+ console.log(
268
+ chalk.gray(' ') +
269
+ chalk.white('Version:'.padEnd(18)) +
270
+ chalk.yellow(version || 'unknown'),
271
+ )
272
+ console.log(
273
+ chalk.gray(' ') +
274
+ chalk.white('Binary Path:'.padEnd(18)) +
275
+ chalk.gray(mysqldPath),
276
+ )
277
+ console.log(
278
+ chalk.gray(' ') +
279
+ chalk.white('Package Manager:'.padEnd(18)) +
280
+ chalk.cyan(installInfo.packageManager),
281
+ )
282
+ console.log(
283
+ chalk.gray(' ') +
284
+ chalk.white('Package Name:'.padEnd(18)) +
285
+ chalk.cyan(installInfo.packageName),
286
+ )
287
+ console.log()
288
+
289
+ // Uninstall instructions
290
+ console.log(chalk.white(' To uninstall:'))
291
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
292
+
293
+ let stepNum = 1
294
+
295
+ // Step: Stop running containers first
296
+ if (runningContainers.length > 0) {
297
+ console.log(chalk.gray(` # ${stepNum}. Stop running SpinDB containers`))
298
+ console.log(chalk.cyan(' spindb stop <container-name>'))
299
+ console.log()
300
+ stepNum++
301
+ }
302
+
303
+ // Step: Delete SpinDB containers
304
+ if (mysqlContainers.length > 0) {
305
+ console.log(chalk.gray(` # ${stepNum}. Delete SpinDB containers`))
306
+ console.log(chalk.cyan(' spindb delete <container-name>'))
307
+ console.log()
308
+ stepNum++
309
+ }
310
+
311
+ if (installInfo.packageManager === 'homebrew') {
312
+ console.log(
313
+ chalk.gray(
314
+ ` # ${stepNum}. Stop Homebrew service (if running separately)`,
315
+ ),
316
+ )
317
+ console.log(chalk.cyan(` brew services stop ${installInfo.packageName}`))
318
+ console.log()
319
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
320
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
321
+ } else if (installInfo.packageManager === 'apt') {
322
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
323
+ console.log(
324
+ chalk.cyan(
325
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
326
+ ),
327
+ )
328
+ console.log()
329
+ console.log(chalk.gray(` # ${stepNum + 1}. Disable auto-start on boot`))
330
+ console.log(
331
+ chalk.cyan(
332
+ ` sudo systemctl disable ${installInfo.isMariaDB ? 'mariadb' : 'mysql'}`,
333
+ ),
334
+ )
335
+ console.log()
336
+ console.log(chalk.gray(` # ${stepNum + 2}. Uninstall the package`))
337
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
338
+ console.log()
339
+ console.log(chalk.gray(` # ${stepNum + 3}. Remove data files (optional)`))
340
+ console.log(
341
+ chalk.cyan(' sudo apt purge mysql-server mysql-client mysql-common'),
342
+ )
343
+ console.log(chalk.cyan(' sudo rm -rf /var/lib/mysql /etc/mysql'))
344
+ } else if (
345
+ installInfo.packageManager === 'yum' ||
346
+ installInfo.packageManager === 'dnf'
347
+ ) {
348
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
349
+ console.log(
350
+ chalk.cyan(
351
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
352
+ ),
353
+ )
354
+ console.log()
355
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
356
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
357
+ } else if (installInfo.packageManager === 'pacman') {
358
+ console.log(chalk.gray(` # ${stepNum}. Stop the system service`))
359
+ console.log(
360
+ chalk.cyan(
361
+ ` sudo systemctl stop ${installInfo.isMariaDB ? 'mariadb' : 'mysqld'}`,
362
+ ),
363
+ )
364
+ console.log()
365
+ console.log(chalk.gray(` # ${stepNum + 1}. Uninstall the package`))
366
+ console.log(chalk.cyan(` ${installInfo.uninstallCommand}`))
367
+ } else {
368
+ console.log(chalk.gray(' Use your system package manager to uninstall.'))
369
+ console.log(chalk.gray(` The binary is located at: ${mysqldPath}`))
370
+ }
371
+
372
+ console.log()
373
+
374
+ // Wait for user
375
+ await inquirer.prompt([
376
+ {
377
+ type: 'input',
378
+ name: 'continue',
379
+ message: chalk.gray('Press Enter to go back...'),
380
+ },
381
+ ])
382
+ }
@@ -0,0 +1,184 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import { containerManager } from '../../../core/container-manager'
5
+ import { promptInstallDependencies } from '../../ui/prompts'
6
+ import { header, error } from '../../ui/theme'
7
+ import { getInstalledEngines } from '../../helpers'
8
+ import { type MenuChoice } from './shared'
9
+ import {
10
+ handleCreate,
11
+ handleList,
12
+ handleStart,
13
+ handleStop,
14
+ } from './container-handlers'
15
+ import {
16
+ handleBackup,
17
+ handleRestore,
18
+ handleClone,
19
+ } from './backup-handlers'
20
+ import { handleEngines } from './engine-handlers'
21
+ import { handleCheckUpdate } from './update-handlers'
22
+
23
+ async function showMainMenu(): Promise<void> {
24
+ console.clear()
25
+ console.log(header('SpinDB - Local Database Manager'))
26
+ console.log()
27
+
28
+ const containers = await containerManager.list()
29
+ const running = containers.filter((c) => c.status === 'running').length
30
+ const stopped = containers.filter((c) => c.status !== 'running').length
31
+
32
+ console.log(
33
+ chalk.gray(
34
+ ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
35
+ ),
36
+ )
37
+ console.log()
38
+
39
+ const canStart = stopped > 0
40
+ const canStop = running > 0
41
+ const canRestore = running > 0
42
+ const canClone = containers.length > 0
43
+
44
+ // Check if any engines are installed
45
+ const engines = await getInstalledEngines()
46
+ const hasEngines = engines.length > 0
47
+
48
+ // If containers exist, show List first; otherwise show Create first
49
+ const hasContainers = containers.length > 0
50
+
51
+ const choices: MenuChoice[] = [
52
+ ...(hasContainers
53
+ ? [
54
+ { name: `${chalk.cyan('◉')} Containers`, value: 'list' },
55
+ { name: `${chalk.green('+')} Create new container`, value: 'create' },
56
+ ]
57
+ : [
58
+ { name: `${chalk.green('+')} Create new container`, value: 'create' },
59
+ { name: `${chalk.cyan('◉')} Containers`, value: 'list' },
60
+ ]),
61
+ {
62
+ name: canStart
63
+ ? `${chalk.green('▶')} Start a container`
64
+ : chalk.gray('▶ Start a container'),
65
+ value: 'start',
66
+ disabled: canStart ? false : 'No stopped containers',
67
+ },
68
+ {
69
+ name: canStop
70
+ ? `${chalk.red('■')} Stop a container`
71
+ : chalk.gray('■ Stop a container'),
72
+ value: 'stop',
73
+ disabled: canStop ? false : 'No running containers',
74
+ },
75
+ {
76
+ name: canRestore
77
+ ? `${chalk.magenta('↓')} Restore backup`
78
+ : chalk.gray('↓ Restore backup'),
79
+ value: 'restore',
80
+ disabled: canRestore ? false : 'No running containers',
81
+ },
82
+ {
83
+ name: canRestore
84
+ ? `${chalk.magenta('↑')} Backup database`
85
+ : chalk.gray('↑ Backup database'),
86
+ value: 'backup',
87
+ disabled: canRestore ? false : 'No running containers',
88
+ },
89
+ {
90
+ name: canClone
91
+ ? `${chalk.cyan('⧉')} Clone a container`
92
+ : chalk.gray('⧉ Clone a container'),
93
+ value: 'clone',
94
+ disabled: canClone ? false : 'No containers',
95
+ },
96
+ {
97
+ name: hasEngines
98
+ ? `${chalk.yellow('⚙')} List installed engines`
99
+ : chalk.gray('⚙ List installed engines'),
100
+ value: 'engines',
101
+ disabled: hasEngines ? false : 'No engines installed',
102
+ },
103
+ new inquirer.Separator(),
104
+ { name: `${chalk.cyan('↑')} Check for updates`, value: 'check-update' },
105
+ { name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
106
+ ]
107
+
108
+ const { action } = await inquirer.prompt<{ action: string }>([
109
+ {
110
+ type: 'list',
111
+ name: 'action',
112
+ message: 'What would you like to do?',
113
+ choices,
114
+ pageSize: 12,
115
+ },
116
+ ])
117
+
118
+ switch (action) {
119
+ case 'create':
120
+ await handleCreate()
121
+ break
122
+ case 'list':
123
+ await handleList(showMainMenu)
124
+ break
125
+ case 'start':
126
+ await handleStart()
127
+ break
128
+ case 'stop':
129
+ await handleStop()
130
+ break
131
+ case 'restore':
132
+ await handleRestore()
133
+ break
134
+ case 'backup':
135
+ await handleBackup()
136
+ break
137
+ case 'clone':
138
+ await handleClone()
139
+ break
140
+ case 'engines':
141
+ await handleEngines()
142
+ break
143
+ case 'check-update':
144
+ await handleCheckUpdate()
145
+ break
146
+ case 'exit':
147
+ console.log(chalk.gray('\n Goodbye!\n'))
148
+ process.exit(0)
149
+ }
150
+
151
+ // Return to menu after action
152
+ await showMainMenu()
153
+ }
154
+
155
+ export const menuCommand = new Command('menu')
156
+ .description('Interactive menu for managing containers')
157
+ .action(async () => {
158
+ try {
159
+ await showMainMenu()
160
+ } catch (err) {
161
+ const e = err as Error
162
+
163
+ // Check if this is a missing tool error
164
+ if (
165
+ e.message.includes('pg_restore not found') ||
166
+ e.message.includes('psql not found') ||
167
+ e.message.includes('pg_dump not found')
168
+ ) {
169
+ const missingTool = e.message.includes('pg_restore')
170
+ ? 'pg_restore'
171
+ : e.message.includes('pg_dump')
172
+ ? 'pg_dump'
173
+ : 'psql'
174
+ const installed = await promptInstallDependencies(missingTool)
175
+ if (installed) {
176
+ console.log(chalk.yellow(' Please re-run spindb to continue.'))
177
+ }
178
+ process.exit(1)
179
+ }
180
+
181
+ console.error(error(e.message))
182
+ process.exit(1)
183
+ }
184
+ })
@@ -0,0 +1,26 @@
1
+ import chalk from 'chalk'
2
+ import inquirer from 'inquirer'
3
+
4
+ /**
5
+ * Menu choice type for inquirer list prompts
6
+ */
7
+ export type MenuChoice =
8
+ | {
9
+ name: string
10
+ value: string
11
+ disabled?: boolean | string
12
+ }
13
+ | inquirer.Separator
14
+
15
+ /**
16
+ * Helper to pause and wait for user to press Enter
17
+ */
18
+ export async function pressEnterToContinue(): Promise<void> {
19
+ await inquirer.prompt([
20
+ {
21
+ type: 'input',
22
+ name: 'continue',
23
+ message: chalk.gray('Press Enter to continue...'),
24
+ },
25
+ ])
26
+ }