spindb 0.5.4 → 0.5.5

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.
@@ -1,7 +1,9 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import { containerManager } from '../../core/container-manager'
4
- import { info, error } from '../ui/theme'
4
+ import { getEngine } from '../../engines'
5
+ import { info, error, formatBytes } from '../ui/theme'
6
+ import type { ContainerConfig } from '../../types'
5
7
 
6
8
  /**
7
9
  * Engine icons for display
@@ -11,6 +13,23 @@ const engineIcons: Record<string, string> = {
11
13
  mysql: '🐬',
12
14
  }
13
15
 
16
+ /**
17
+ * Get database size for a container (only if running)
18
+ */
19
+ async function getContainerSize(
20
+ container: ContainerConfig,
21
+ ): Promise<number | null> {
22
+ if (container.status !== 'running') {
23
+ return null
24
+ }
25
+ try {
26
+ const engine = getEngine(container.engine)
27
+ return await engine.getDatabaseSize(container)
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
14
33
  export const listCommand = new Command('list')
15
34
  .alias('ls')
16
35
  .description('List all containers')
@@ -20,7 +39,14 @@ export const listCommand = new Command('list')
20
39
  const containers = await containerManager.list()
21
40
 
22
41
  if (options.json) {
23
- console.log(JSON.stringify(containers, null, 2))
42
+ // Include sizes in JSON output
43
+ const containersWithSize = await Promise.all(
44
+ containers.map(async (container) => ({
45
+ ...container,
46
+ sizeBytes: await getContainerSize(container),
47
+ })),
48
+ )
49
+ console.log(JSON.stringify(containersWithSize, null, 2))
24
50
  return
25
51
  }
26
52
 
@@ -29,6 +55,9 @@ export const listCommand = new Command('list')
29
55
  return
30
56
  }
31
57
 
58
+ // Fetch sizes for running containers in parallel
59
+ const sizes = await Promise.all(containers.map(getContainerSize))
60
+
32
61
  // Table header
33
62
  console.log()
34
63
  console.log(
@@ -37,12 +66,16 @@ export const listCommand = new Command('list')
37
66
  chalk.bold.white('ENGINE'.padEnd(15)) +
38
67
  chalk.bold.white('VERSION'.padEnd(10)) +
39
68
  chalk.bold.white('PORT'.padEnd(8)) +
69
+ chalk.bold.white('SIZE'.padEnd(10)) +
40
70
  chalk.bold.white('STATUS'),
41
71
  )
42
- console.log(chalk.gray(' ' + '─'.repeat(63)))
72
+ console.log(chalk.gray(' ' + '─'.repeat(73)))
43
73
 
44
74
  // Table rows
45
- for (const container of containers) {
75
+ for (let i = 0; i < containers.length; i++) {
76
+ const container = containers[i]
77
+ const size = sizes[i]
78
+
46
79
  const statusDisplay =
47
80
  container.status === 'running'
48
81
  ? chalk.green('● running')
@@ -51,12 +84,17 @@ export const listCommand = new Command('list')
51
84
  const engineIcon = engineIcons[container.engine] || '▣'
52
85
  const engineDisplay = `${engineIcon} ${container.engine}`
53
86
 
87
+ // Format size: show value if running, dash if stopped
88
+ const sizeDisplay =
89
+ size !== null ? formatBytes(size) : chalk.gray('—')
90
+
54
91
  console.log(
55
92
  chalk.gray(' ') +
56
93
  chalk.cyan(container.name.padEnd(20)) +
57
94
  chalk.white(engineDisplay.padEnd(14)) +
58
95
  chalk.yellow(container.version.padEnd(10)) +
59
96
  chalk.green(String(container.port).padEnd(8)) +
97
+ chalk.magenta(sizeDisplay.padEnd(10)) +
60
98
  statusDisplay,
61
99
  )
62
100
  }
@@ -7,6 +7,9 @@ import {
7
7
  promptContainerSelect,
8
8
  promptContainerName,
9
9
  promptDatabaseName,
10
+ promptDatabaseSelect,
11
+ promptBackupFormat,
12
+ promptBackupFilename,
10
13
  promptCreateOptions,
11
14
  promptConfirm,
12
15
  promptInstallDependencies,
@@ -19,6 +22,7 @@ import {
19
22
  warning,
20
23
  info,
21
24
  connectionBox,
25
+ formatBytes,
22
26
  } from '../ui/theme'
23
27
  import { existsSync } from 'fs'
24
28
  import { readdir, rm, lstat } from 'fs/promises'
@@ -141,6 +145,13 @@ async function showMainMenu(): Promise<void> {
141
145
  value: 'restore',
142
146
  disabled: canRestore ? false : 'No running containers',
143
147
  },
148
+ {
149
+ name: canRestore
150
+ ? `${chalk.magenta('↑')} Backup database`
151
+ : chalk.gray('↑ Backup database'),
152
+ value: 'backup',
153
+ disabled: canRestore ? false : 'No running containers',
154
+ },
144
155
  {
145
156
  name: canClone
146
157
  ? `${chalk.cyan('⧉')} Clone a container`
@@ -185,6 +196,9 @@ async function showMainMenu(): Promise<void> {
185
196
  case 'restore':
186
197
  await handleRestore()
187
198
  break
199
+ case 'backup':
200
+ await handleBackup()
201
+ break
188
202
  case 'clone':
189
203
  await handleClone()
190
204
  break
@@ -397,6 +411,19 @@ async function handleList(): Promise<void> {
397
411
  return
398
412
  }
399
413
 
414
+ // Fetch sizes for running containers in parallel
415
+ const sizes = await Promise.all(
416
+ containers.map(async (container) => {
417
+ if (container.status !== 'running') return null
418
+ try {
419
+ const engine = getEngine(container.engine)
420
+ return await engine.getDatabaseSize(container)
421
+ } catch {
422
+ return null
423
+ }
424
+ }),
425
+ )
426
+
400
427
  // Table header
401
428
  console.log()
402
429
  console.log(
@@ -405,23 +432,30 @@ async function handleList(): Promise<void> {
405
432
  chalk.bold.white('ENGINE'.padEnd(12)) +
406
433
  chalk.bold.white('VERSION'.padEnd(10)) +
407
434
  chalk.bold.white('PORT'.padEnd(8)) +
435
+ chalk.bold.white('SIZE'.padEnd(10)) +
408
436
  chalk.bold.white('STATUS'),
409
437
  )
410
- console.log(chalk.gray(' ' + '─'.repeat(60)))
438
+ console.log(chalk.gray(' ' + '─'.repeat(70)))
411
439
 
412
440
  // Table rows
413
- for (const container of containers) {
441
+ for (let i = 0; i < containers.length; i++) {
442
+ const container = containers[i]
443
+ const size = sizes[i]
444
+
414
445
  const statusDisplay =
415
446
  container.status === 'running'
416
447
  ? chalk.green('● running')
417
448
  : chalk.gray('○ stopped')
418
449
 
450
+ const sizeDisplay = size !== null ? formatBytes(size) : chalk.gray('—')
451
+
419
452
  console.log(
420
453
  chalk.gray(' ') +
421
454
  chalk.cyan(container.name.padEnd(20)) +
422
455
  chalk.white(container.engine.padEnd(12)) +
423
456
  chalk.yellow(container.version.padEnd(10)) +
424
457
  chalk.green(String(container.port).padEnd(8)) +
458
+ chalk.magenta(sizeDisplay.padEnd(10)) +
425
459
  statusDisplay,
426
460
  )
427
461
  }
@@ -439,15 +473,19 @@ async function handleList(): Promise<void> {
439
473
  // Container selection with submenu
440
474
  console.log()
441
475
  const containerChoices = [
442
- ...containers.map((c) => ({
443
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine} ${c.version}, port ${c.port})`)} ${
444
- c.status === 'running'
445
- ? chalk.green('● running')
446
- : chalk.gray(' stopped')
447
- }`,
448
- value: c.name,
449
- short: c.name,
450
- })),
476
+ ...containers.map((c, i) => {
477
+ const size = sizes[i]
478
+ const sizeLabel = size !== null ? `, ${formatBytes(size)}` : ''
479
+ return {
480
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine} ${c.version}, port ${c.port}${sizeLabel})`)} ${
481
+ c.status === 'running'
482
+ ? chalk.green('● running')
483
+ : chalk.gray('○ stopped')
484
+ }`,
485
+ value: c.name,
486
+ short: c.name,
487
+ }
488
+ }),
451
489
  new inquirer.Separator(),
452
490
  { name: `${chalk.blue('←')} Back to main menu`, value: 'back' },
453
491
  ]
@@ -1521,6 +1559,148 @@ async function handleRestore(): Promise<void> {
1521
1559
  ])
1522
1560
  }
1523
1561
 
1562
+ /**
1563
+ * Generate a timestamp string for backup filenames
1564
+ */
1565
+ function generateBackupTimestamp(): string {
1566
+ const now = new Date()
1567
+ return now.toISOString().replace(/:/g, '').split('.')[0]
1568
+ }
1569
+
1570
+ /**
1571
+ * Get file extension for backup format
1572
+ */
1573
+ function getBackupExtension(format: 'sql' | 'dump', engine: string): string {
1574
+ if (format === 'sql') {
1575
+ return '.sql'
1576
+ }
1577
+ return engine === 'mysql' ? '.sql.gz' : '.dump'
1578
+ }
1579
+
1580
+ async function handleBackup(): Promise<void> {
1581
+ const containers = await containerManager.list()
1582
+ const running = containers.filter((c) => c.status === 'running')
1583
+
1584
+ if (running.length === 0) {
1585
+ console.log(warning('No running containers. Start a container first.'))
1586
+ await inquirer.prompt([
1587
+ {
1588
+ type: 'input',
1589
+ name: 'continue',
1590
+ message: chalk.gray('Press Enter to continue...'),
1591
+ },
1592
+ ])
1593
+ return
1594
+ }
1595
+
1596
+ // Select container
1597
+ const containerName = await promptContainerSelect(
1598
+ running,
1599
+ 'Select container to backup:',
1600
+ )
1601
+ if (!containerName) return
1602
+
1603
+ const config = await containerManager.getConfig(containerName)
1604
+ if (!config) {
1605
+ console.log(error(`Container "${containerName}" not found`))
1606
+ return
1607
+ }
1608
+
1609
+ const engine = getEngine(config.engine)
1610
+
1611
+ // Check for required tools
1612
+ const depsSpinner = createSpinner('Checking required tools...')
1613
+ depsSpinner.start()
1614
+
1615
+ let missingDeps = await getMissingDependencies(config.engine)
1616
+ if (missingDeps.length > 0) {
1617
+ depsSpinner.warn(
1618
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
1619
+ )
1620
+
1621
+ const installed = await promptInstallDependencies(
1622
+ missingDeps[0].binary,
1623
+ config.engine,
1624
+ )
1625
+
1626
+ if (!installed) {
1627
+ return
1628
+ }
1629
+
1630
+ missingDeps = await getMissingDependencies(config.engine)
1631
+ if (missingDeps.length > 0) {
1632
+ console.log(
1633
+ error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
1634
+ )
1635
+ return
1636
+ }
1637
+
1638
+ console.log(chalk.green(' ✓ All required tools are now available'))
1639
+ console.log()
1640
+ } else {
1641
+ depsSpinner.succeed('Required tools available')
1642
+ }
1643
+
1644
+ // Select database
1645
+ const databases = config.databases || [config.database]
1646
+ let databaseName: string
1647
+
1648
+ if (databases.length > 1) {
1649
+ databaseName = await promptDatabaseSelect(databases, 'Select database to backup:')
1650
+ } else {
1651
+ databaseName = databases[0]
1652
+ }
1653
+
1654
+ // Select format
1655
+ const format = await promptBackupFormat(config.engine)
1656
+
1657
+ // Get filename
1658
+ const defaultFilename = `${containerName}-${databaseName}-backup-${generateBackupTimestamp()}`
1659
+ const filename = await promptBackupFilename(defaultFilename)
1660
+
1661
+ // Build output path
1662
+ const extension = getBackupExtension(format, config.engine)
1663
+ const outputPath = join(process.cwd(), `${filename}${extension}`)
1664
+
1665
+ // Create backup
1666
+ const backupSpinner = createSpinner(
1667
+ `Creating ${format === 'sql' ? 'SQL' : 'dump'} backup of "${databaseName}"...`,
1668
+ )
1669
+ backupSpinner.start()
1670
+
1671
+ try {
1672
+ const result = await engine.backup(config, outputPath, {
1673
+ database: databaseName,
1674
+ format,
1675
+ })
1676
+
1677
+ backupSpinner.succeed('Backup created successfully')
1678
+
1679
+ console.log()
1680
+ console.log(success('Backup complete'))
1681
+ console.log()
1682
+ console.log(chalk.gray(' File:'), chalk.cyan(result.path))
1683
+ console.log(chalk.gray(' Size:'), chalk.white(formatBytes(result.size)))
1684
+ console.log(chalk.gray(' Format:'), chalk.white(result.format))
1685
+ console.log()
1686
+ } catch (err) {
1687
+ const e = err as Error
1688
+ backupSpinner.fail('Backup failed')
1689
+ console.log()
1690
+ console.log(error(e.message))
1691
+ console.log()
1692
+ }
1693
+
1694
+ // Wait for user to see the result
1695
+ await inquirer.prompt([
1696
+ {
1697
+ type: 'input',
1698
+ name: 'continue',
1699
+ message: chalk.gray('Press Enter to continue...'),
1700
+ },
1701
+ ])
1702
+ }
1703
+
1524
1704
  async function handleClone(): Promise<void> {
1525
1705
  const containers = await containerManager.list()
1526
1706
  const stopped = containers.filter((c) => c.status !== 'running')
@@ -1997,14 +2177,6 @@ function compareVersions(a: string, b: string): number {
1997
2177
  return 0
1998
2178
  }
1999
2179
 
2000
- function formatBytes(bytes: number): string {
2001
- if (bytes === 0) return '0 B'
2002
- const k = 1024
2003
- const sizes = ['B', 'KB', 'MB', 'GB']
2004
- const i = Math.floor(Math.log(bytes) / Math.log(k))
2005
- return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
2006
- }
2007
-
2008
2180
  async function handleEngines(): Promise<void> {
2009
2181
  console.clear()
2010
2182
  console.log(header('Installed Engines'))
@@ -271,6 +271,9 @@ export const restoreCommand = new Command('restore')
271
271
  await engine.createDatabase(config, databaseName)
272
272
  dbSpinner.succeed(`Database "${databaseName}" ready`)
273
273
 
274
+ // Add database to container's databases array
275
+ await containerManager.addDatabase(containerName, databaseName)
276
+
274
277
  // Restore backup
275
278
  const restoreSpinner = createSpinner('Restoring backup...')
276
279
  restoreSpinner.start()
package/cli/index.ts CHANGED
@@ -5,6 +5,7 @@ import { startCommand } from './commands/start'
5
5
  import { stopCommand } from './commands/stop'
6
6
  import { deleteCommand } from './commands/delete'
7
7
  import { restoreCommand } from './commands/restore'
8
+ import { backupCommand } from './commands/backup'
8
9
  import { connectCommand } from './commands/connect'
9
10
  import { cloneCommand } from './commands/clone'
10
11
  import { menuCommand } from './commands/menu'
@@ -27,6 +28,7 @@ export async function run(): Promise<void> {
27
28
  program.addCommand(stopCommand)
28
29
  program.addCommand(deleteCommand)
29
30
  program.addCommand(restoreCommand)
31
+ program.addCommand(backupCommand)
30
32
  program.addCommand(connectCommand)
31
33
  program.addCommand(cloneCommand)
32
34
  program.addCommand(menuCommand)
package/cli/ui/prompts.ts CHANGED
@@ -276,6 +276,93 @@ export async function promptDatabaseName(
276
276
  return database
277
277
  }
278
278
 
279
+ /**
280
+ * Prompt to select a database from a list of databases in a container
281
+ */
282
+ export async function promptDatabaseSelect(
283
+ databases: string[],
284
+ message: string = 'Select database:',
285
+ ): Promise<string> {
286
+ if (databases.length === 0) {
287
+ throw new Error('No databases available to select')
288
+ }
289
+
290
+ if (databases.length === 1) {
291
+ return databases[0]
292
+ }
293
+
294
+ const { database } = await inquirer.prompt<{ database: string }>([
295
+ {
296
+ type: 'list',
297
+ name: 'database',
298
+ message,
299
+ choices: databases.map((db, index) => ({
300
+ name: index === 0 ? `${db} ${chalk.gray('(primary)')}` : db,
301
+ value: db,
302
+ short: db,
303
+ })),
304
+ },
305
+ ])
306
+
307
+ return database
308
+ }
309
+
310
+ /**
311
+ * Prompt for backup format selection
312
+ */
313
+ export async function promptBackupFormat(
314
+ engine: string,
315
+ ): Promise<'sql' | 'dump'> {
316
+ const sqlDescription =
317
+ engine === 'mysql'
318
+ ? 'Plain SQL - human-readable, larger file'
319
+ : 'Plain SQL - human-readable, larger file'
320
+ const dumpDescription =
321
+ engine === 'mysql'
322
+ ? 'Compressed SQL (.sql.gz) - smaller file'
323
+ : 'Custom format - smaller file, faster restore'
324
+
325
+ const { format } = await inquirer.prompt<{ format: 'sql' | 'dump' }>([
326
+ {
327
+ type: 'list',
328
+ name: 'format',
329
+ message: 'Select backup format:',
330
+ choices: [
331
+ { name: `.sql ${chalk.gray(`- ${sqlDescription}`)}`, value: 'sql' },
332
+ { name: `.dump ${chalk.gray(`- ${dumpDescription}`)}`, value: 'dump' },
333
+ ],
334
+ default: 'sql',
335
+ },
336
+ ])
337
+
338
+ return format
339
+ }
340
+
341
+ /**
342
+ * Prompt for backup filename
343
+ */
344
+ export async function promptBackupFilename(
345
+ defaultName: string,
346
+ ): Promise<string> {
347
+ const { filename } = await inquirer.prompt<{ filename: string }>([
348
+ {
349
+ type: 'input',
350
+ name: 'filename',
351
+ message: 'Backup filename (without extension):',
352
+ default: defaultName,
353
+ validate: (input: string) => {
354
+ if (!input) return 'Filename is required'
355
+ if (!/^[a-zA-Z0-9_-]+$/.test(input)) {
356
+ return 'Filename must contain only letters, numbers, underscores, and hyphens'
357
+ }
358
+ return true
359
+ },
360
+ },
361
+ ])
362
+
363
+ return filename
364
+ }
365
+
279
366
  export type CreateOptions = {
280
367
  name: string
281
368
  engine: string
package/cli/ui/theme.ts CHANGED
@@ -155,3 +155,14 @@ export function connectionBox(
155
155
 
156
156
  return box(lines)
157
157
  }
158
+
159
+ /**
160
+ * Format bytes into human-readable format (B, KB, MB, GB)
161
+ */
162
+ export function formatBytes(bytes: number): string {
163
+ if (bytes === 0) return '0 B'
164
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
165
+ const i = Math.floor(Math.log(bytes) / Math.log(1024))
166
+ const value = bytes / Math.pow(1024, i)
167
+ return `${value.toFixed(1)} ${units[i]}`
168
+ }