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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Spin up local PostgreSQL and MySQL databases without Docker. A lightweight alter
10
10
  - **Interactive menu** - Arrow-key navigation for all operations
11
11
  - **Auto port management** - Automatically finds available ports
12
12
  - **Clone containers** - Duplicate databases with all data
13
- - **Backup restore** - Restore pg_dump/mysqldump backups
13
+ - **Backup & restore** - Create and restore pg_dump/mysqldump backups
14
14
  - **Custom database names** - Specify database name separate from container name
15
15
  - **Engine management** - View installed PostgreSQL versions and free up disk space
16
16
  - **Dynamic version selection** - Fetches available PostgreSQL versions from Maven Central
@@ -51,6 +51,7 @@ spindb connect mydb
51
51
  | `spindb connect [name]` | Connect with psql/mysql shell (`--pgcli`/`--mycli` for enhanced) |
52
52
  | `spindb url [name]` | Output connection string |
53
53
  | `spindb edit [name]` | Edit container properties (rename, port) |
54
+ | `spindb backup [name]` | Create a database backup |
54
55
  | `spindb restore [name] [backup]` | Restore a backup file |
55
56
  | `spindb clone [source] [target]` | Clone a container |
56
57
  | `spindb delete [name]` | Delete a container |
@@ -304,6 +305,31 @@ psql $(spindb url mydb)
304
305
  spindb url mydb --copy
305
306
  ```
306
307
 
308
+ ### Backup databases
309
+
310
+ ```bash
311
+ # Backup with default settings (SQL format, auto-generated filename)
312
+ spindb backup mydb
313
+
314
+ # Backup specific database in container
315
+ spindb backup mydb --database my_app_db
316
+
317
+ # Custom filename and output directory
318
+ spindb backup mydb --name my-backup --output ./backups/
319
+
320
+ # Choose format: sql (plain text) or dump (compressed/binary)
321
+ spindb backup mydb --format sql # Plain SQL (.sql)
322
+ spindb backup mydb --format dump # PostgreSQL custom format (.dump) / MySQL gzipped (.sql.gz)
323
+
324
+ # Shorthand flags
325
+ spindb backup mydb --sql # Same as --format sql
326
+ spindb backup mydb --dump # Same as --format dump
327
+ ```
328
+
329
+ **Backup formats:**
330
+ - **SQL format** (`.sql`) - Plain text, universal, can be read/edited
331
+ - **Dump format** - PostgreSQL uses custom format (`.dump`), MySQL uses gzipped SQL (`.sql.gz`)
332
+
307
333
  ### Edit containers
308
334
 
309
335
  ```bash
@@ -0,0 +1,263 @@
1
+ import { Command } from 'commander'
2
+ import { join } from 'path'
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 {
8
+ promptContainerSelect,
9
+ promptDatabaseSelect,
10
+ promptBackupFormat,
11
+ promptBackupFilename,
12
+ promptInstallDependencies,
13
+ } from '../ui/prompts'
14
+ import { createSpinner } from '../ui/spinner'
15
+ import { success, error, warning, formatBytes } from '../ui/theme'
16
+ import { getMissingDependencies } from '../../core/dependency-manager'
17
+
18
+ /**
19
+ * Generate a timestamp string for backup filenames
20
+ * Format: YYYY-MM-DDTHHMMSS (ISO 8601 without colons for filesystem compatibility)
21
+ */
22
+ function generateTimestamp(): string {
23
+ const now = new Date()
24
+ return now.toISOString().replace(/:/g, '').split('.')[0]
25
+ }
26
+
27
+ /**
28
+ * Generate default backup filename
29
+ */
30
+ function generateDefaultFilename(
31
+ containerName: string,
32
+ database: string,
33
+ ): string {
34
+ const timestamp = generateTimestamp()
35
+ return `${containerName}-${database}-backup-${timestamp}`
36
+ }
37
+
38
+ /**
39
+ * Get file extension for backup format
40
+ */
41
+ function getExtension(format: 'sql' | 'dump', engine: string): string {
42
+ if (format === 'sql') {
43
+ return '.sql'
44
+ }
45
+ // MySQL dump is gzipped SQL, PostgreSQL dump is custom format
46
+ return engine === 'mysql' ? '.sql.gz' : '.dump'
47
+ }
48
+
49
+ export const backupCommand = new Command('backup')
50
+ .description('Create a backup of a database')
51
+ .argument('[container]', 'Container name')
52
+ .option('-d, --database <name>', 'Database to backup')
53
+ .option('-n, --name <name>', 'Custom backup filename (without extension)')
54
+ .option('-o, --output <path>', 'Output directory (defaults to current directory)')
55
+ .option('--format <format>', 'Output format: sql or dump')
56
+ .option('--sql', 'Output as plain SQL (shorthand for --format sql)')
57
+ .option('--dump', 'Output as dump format (shorthand for --format dump)')
58
+ .action(
59
+ async (
60
+ containerArg: string | undefined,
61
+ options: {
62
+ database?: string
63
+ name?: string
64
+ output?: string
65
+ format?: string
66
+ sql?: boolean
67
+ dump?: boolean
68
+ },
69
+ ) => {
70
+ try {
71
+ let containerName = containerArg
72
+
73
+ // Interactive selection if no container provided
74
+ if (!containerName) {
75
+ const containers = await containerManager.list()
76
+ const running = containers.filter((c) => c.status === 'running')
77
+
78
+ if (running.length === 0) {
79
+ if (containers.length === 0) {
80
+ console.log(
81
+ warning('No containers found. Create one with: spindb create'),
82
+ )
83
+ } else {
84
+ console.log(
85
+ warning(
86
+ 'No running containers. Start one first with: spindb start',
87
+ ),
88
+ )
89
+ }
90
+ return
91
+ }
92
+
93
+ const selected = await promptContainerSelect(
94
+ running,
95
+ 'Select container to backup:',
96
+ )
97
+ if (!selected) return
98
+ containerName = selected
99
+ }
100
+
101
+ // Get container config
102
+ const config = await containerManager.getConfig(containerName)
103
+ if (!config) {
104
+ console.error(error(`Container "${containerName}" not found`))
105
+ process.exit(1)
106
+ }
107
+
108
+ const { engine: engineName } = config
109
+
110
+ // Check if running
111
+ const running = await processManager.isRunning(containerName, {
112
+ engine: engineName,
113
+ })
114
+ if (!running) {
115
+ console.error(
116
+ error(
117
+ `Container "${containerName}" is not running. Start it first.`,
118
+ ),
119
+ )
120
+ process.exit(1)
121
+ }
122
+
123
+ // Get engine
124
+ const engine = getEngine(engineName)
125
+
126
+ // Check for required client tools
127
+ const depsSpinner = createSpinner('Checking required tools...')
128
+ depsSpinner.start()
129
+
130
+ let missingDeps = await getMissingDependencies(config.engine)
131
+ if (missingDeps.length > 0) {
132
+ depsSpinner.warn(
133
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
134
+ )
135
+
136
+ // Offer to install
137
+ const installed = await promptInstallDependencies(
138
+ missingDeps[0].binary,
139
+ config.engine,
140
+ )
141
+
142
+ if (!installed) {
143
+ process.exit(1)
144
+ }
145
+
146
+ // Verify installation worked
147
+ missingDeps = await getMissingDependencies(config.engine)
148
+ if (missingDeps.length > 0) {
149
+ console.error(
150
+ error(
151
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
152
+ ),
153
+ )
154
+ process.exit(1)
155
+ }
156
+
157
+ console.log(chalk.green(' ✓ All required tools are now available'))
158
+ console.log()
159
+ } else {
160
+ depsSpinner.succeed('Required tools available')
161
+ }
162
+
163
+ // Determine which database to backup
164
+ let databaseName = options.database
165
+
166
+ if (!databaseName) {
167
+ // Get list of databases in container
168
+ const databases = config.databases || [config.database]
169
+
170
+ if (databases.length > 1) {
171
+ // Interactive mode: prompt for database selection
172
+ databaseName = await promptDatabaseSelect(
173
+ databases,
174
+ 'Select database to backup:',
175
+ )
176
+ } else {
177
+ // Single database: use it
178
+ databaseName = databases[0]
179
+ }
180
+ }
181
+
182
+ // Determine format
183
+ let format: 'sql' | 'dump' = 'sql' // Default to SQL
184
+
185
+ if (options.sql) {
186
+ format = 'sql'
187
+ } else if (options.dump) {
188
+ format = 'dump'
189
+ } else if (options.format) {
190
+ if (options.format !== 'sql' && options.format !== 'dump') {
191
+ console.error(error('Format must be "sql" or "dump"'))
192
+ process.exit(1)
193
+ }
194
+ format = options.format as 'sql' | 'dump'
195
+ } else if (!containerArg) {
196
+ // Interactive mode: prompt for format
197
+ format = await promptBackupFormat(engineName)
198
+ }
199
+
200
+ // Determine filename
201
+ const defaultFilename = generateDefaultFilename(containerName, databaseName)
202
+ let filename = options.name || defaultFilename
203
+
204
+ // In interactive mode with no name provided, optionally prompt for custom name
205
+ if (!containerArg && !options.name) {
206
+ filename = await promptBackupFilename(defaultFilename)
207
+ }
208
+
209
+ // Build full output path
210
+ const extension = getExtension(format, engineName)
211
+ const outputDir = options.output || process.cwd()
212
+ const outputPath = join(outputDir, `${filename}${extension}`)
213
+
214
+ // Create backup
215
+ const backupSpinner = createSpinner(
216
+ `Creating ${format === 'sql' ? 'SQL' : 'dump'} backup of "${databaseName}"...`,
217
+ )
218
+ backupSpinner.start()
219
+
220
+ const result = await engine.backup(config, outputPath, {
221
+ database: databaseName,
222
+ format,
223
+ })
224
+
225
+ backupSpinner.succeed('Backup created successfully')
226
+
227
+ // Show result
228
+ console.log()
229
+ console.log(success('Backup complete'))
230
+ console.log()
231
+ console.log(chalk.gray(' File:'), chalk.cyan(result.path))
232
+ console.log(chalk.gray(' Size:'), chalk.white(formatBytes(result.size)))
233
+ console.log(chalk.gray(' Format:'), chalk.white(result.format))
234
+ console.log()
235
+ } catch (err) {
236
+ const e = err as Error
237
+
238
+ // Check if this is a missing tool error
239
+ const missingToolPatterns = [
240
+ 'pg_dump not found',
241
+ 'mysqldump not found',
242
+ ]
243
+
244
+ const matchingPattern = missingToolPatterns.find((p) =>
245
+ e.message.includes(p),
246
+ )
247
+
248
+ if (matchingPattern) {
249
+ const missingTool = matchingPattern.replace(' not found', '')
250
+ const installed = await promptInstallDependencies(missingTool)
251
+ if (installed) {
252
+ console.log(
253
+ chalk.yellow(' Please re-run your command to continue.'),
254
+ )
255
+ }
256
+ process.exit(1)
257
+ }
258
+
259
+ console.error(error(e.message))
260
+ process.exit(1)
261
+ }
262
+ },
263
+ )
@@ -1,17 +1,43 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import { existsSync } from 'fs'
4
- import { configManager } from '../../core/config-manager'
4
+ import {
5
+ configManager,
6
+ POSTGRESQL_TOOLS,
7
+ MYSQL_TOOLS,
8
+ ENHANCED_SHELLS,
9
+ ALL_TOOLS,
10
+ } from '../../core/config-manager'
5
11
  import { error, success, header } from '../ui/theme'
6
12
  import { createSpinner } from '../ui/spinner'
7
13
  import type { BinaryTool } from '../../types'
8
14
 
9
- const VALID_TOOLS: BinaryTool[] = [
10
- 'psql',
11
- 'pg_dump',
12
- 'pg_restore',
13
- 'pg_basebackup',
14
- ]
15
+ /**
16
+ * Helper to display a tool's config
17
+ */
18
+ function displayToolConfig(
19
+ tool: BinaryTool,
20
+ binaryConfig: { path: string; version?: string; source: string } | undefined,
21
+ ): void {
22
+ if (binaryConfig) {
23
+ const sourceLabel =
24
+ binaryConfig.source === 'system'
25
+ ? chalk.blue('system')
26
+ : binaryConfig.source === 'custom'
27
+ ? chalk.yellow('custom')
28
+ : chalk.green('bundled')
29
+ const versionLabel = binaryConfig.version
30
+ ? chalk.gray(` (v${binaryConfig.version})`)
31
+ : ''
32
+ console.log(
33
+ ` ${chalk.cyan(tool.padEnd(15))} ${binaryConfig.path}${versionLabel} [${sourceLabel}]`,
34
+ )
35
+ } else {
36
+ console.log(
37
+ ` ${chalk.cyan(tool.padEnd(15))} ${chalk.gray('not detected')}`,
38
+ )
39
+ }
40
+ }
15
41
 
16
42
  export const configCommand = new Command('config')
17
43
  .description('Manage spindb configuration')
@@ -26,37 +52,36 @@ export const configCommand = new Command('config')
26
52
  console.log(header('SpinDB Configuration'))
27
53
  console.log()
28
54
 
29
- console.log(chalk.bold(' Binary Paths:'))
30
- console.log(chalk.gray(' ' + '─'.repeat(50)))
31
-
32
- for (const tool of VALID_TOOLS) {
33
- const binaryConfig = config.binaries[tool]
34
- if (binaryConfig) {
35
- const sourceLabel =
36
- binaryConfig.source === 'system'
37
- ? chalk.blue('system')
38
- : binaryConfig.source === 'custom'
39
- ? chalk.yellow('custom')
40
- : chalk.green('bundled')
41
- const versionLabel = binaryConfig.version
42
- ? chalk.gray(` (v${binaryConfig.version})`)
43
- : ''
44
- console.log(
45
- ` ${chalk.cyan(tool.padEnd(15))} ${binaryConfig.path}${versionLabel} [${sourceLabel}]`,
46
- )
47
- } else {
48
- console.log(
49
- ` ${chalk.cyan(tool.padEnd(15))} ${chalk.gray('not configured')}`,
50
- )
51
- }
55
+ // PostgreSQL tools
56
+ console.log(chalk.bold(' 🐘 PostgreSQL Tools:'))
57
+ console.log(chalk.gray(' ' + '─'.repeat(60)))
58
+ for (const tool of POSTGRESQL_TOOLS) {
59
+ displayToolConfig(tool, config.binaries[tool])
52
60
  }
61
+ console.log()
62
+
63
+ // MySQL tools
64
+ console.log(chalk.bold(' 🐬 MySQL Tools:'))
65
+ console.log(chalk.gray(' ' + '─'.repeat(60)))
66
+ for (const tool of MYSQL_TOOLS) {
67
+ displayToolConfig(tool, config.binaries[tool])
68
+ }
69
+ console.log()
53
70
 
71
+ // Enhanced shells
72
+ console.log(chalk.bold(' ✨ Enhanced Shells (optional):'))
73
+ console.log(chalk.gray(' ' + '─'.repeat(60)))
74
+ for (const tool of ENHANCED_SHELLS) {
75
+ displayToolConfig(tool, config.binaries[tool])
76
+ }
54
77
  console.log()
55
78
 
56
79
  if (config.updatedAt) {
80
+ const isStale = await configManager.isStale()
81
+ const staleWarning = isStale ? chalk.yellow(' (stale - run config detect to refresh)') : ''
57
82
  console.log(
58
83
  chalk.gray(
59
- ` Last updated: ${new Date(config.updatedAt).toLocaleString()}`,
84
+ ` Last updated: ${new Date(config.updatedAt).toLocaleString()}${staleWarning}`,
60
85
  ),
61
86
  )
62
87
  console.log()
@@ -70,55 +95,108 @@ export const configCommand = new Command('config')
70
95
  )
71
96
  .addCommand(
72
97
  new Command('detect')
73
- .description('Auto-detect PostgreSQL client tools on your system')
98
+ .description('Auto-detect all database tools on your system')
74
99
  .action(async () => {
75
100
  try {
76
101
  console.log()
77
- console.log(header('Detecting PostgreSQL Tools'))
102
+ console.log(header('Detecting Database Tools'))
78
103
  console.log()
79
104
 
80
- const spinner = createSpinner(
81
- 'Searching for PostgreSQL client tools...',
82
- )
105
+ const spinner = createSpinner('Searching for database tools...')
83
106
  spinner.start()
84
107
 
85
108
  // Clear existing configs to force re-detection
86
109
  await configManager.clearAllBinaries()
87
110
 
88
- const { found, missing } = await configManager.initialize()
111
+ const result = await configManager.initialize()
89
112
 
90
113
  spinner.succeed('Detection complete')
91
114
  console.log()
92
115
 
93
- if (found.length > 0) {
94
- console.log(chalk.bold(' Found:'))
95
- for (const tool of found) {
96
- const config = await configManager.getBinaryConfig(tool)
97
- if (config) {
98
- const versionLabel = config.version
99
- ? chalk.gray(` (v${config.version})`)
100
- : ''
116
+ // Helper to display category results
117
+ async function displayCategory(
118
+ title: string,
119
+ icon: string,
120
+ found: BinaryTool[],
121
+ missing: BinaryTool[],
122
+ ): Promise<void> {
123
+ console.log(chalk.bold(` ${icon} ${title}:`))
124
+
125
+ if (found.length > 0) {
126
+ for (const tool of found) {
127
+ const config = await configManager.getBinaryConfig(tool)
128
+ if (config) {
129
+ const versionLabel = config.version
130
+ ? chalk.gray(` (v${config.version})`)
131
+ : ''
132
+ console.log(
133
+ ` ${chalk.green('✓')} ${chalk.cyan(tool.padEnd(15))} ${config.path}${versionLabel}`,
134
+ )
135
+ }
136
+ }
137
+ }
138
+
139
+ if (missing.length > 0) {
140
+ for (const tool of missing) {
101
141
  console.log(
102
- ` ${chalk.green('')} ${chalk.cyan(tool.padEnd(15))} ${config.path}${versionLabel}`,
142
+ ` ${chalk.gray('')} ${chalk.gray(tool.padEnd(15))} not found`,
103
143
  )
104
144
  }
105
145
  }
146
+
106
147
  console.log()
107
148
  }
108
149
 
109
- if (missing.length > 0) {
110
- console.log(chalk.bold(' Not found:'))
111
- for (const tool of missing) {
112
- console.log(` ${chalk.red('✗')} ${chalk.cyan(tool)}`)
150
+ await displayCategory(
151
+ 'PostgreSQL Tools',
152
+ '🐘',
153
+ result.postgresql.found,
154
+ result.postgresql.missing,
155
+ )
156
+ await displayCategory(
157
+ 'MySQL Tools',
158
+ '🐬',
159
+ result.mysql.found,
160
+ result.mysql.missing,
161
+ )
162
+ await displayCategory(
163
+ 'Enhanced Shells (optional)',
164
+ '✨',
165
+ result.enhanced.found,
166
+ result.enhanced.missing,
167
+ )
168
+
169
+ // Show install hints for missing required tools
170
+ if (
171
+ result.postgresql.missing.length > 0 ||
172
+ result.mysql.missing.length > 0
173
+ ) {
174
+ console.log(chalk.gray(' Install missing tools:'))
175
+ if (result.postgresql.missing.length > 0) {
176
+ console.log(
177
+ chalk.gray(' PostgreSQL: brew install postgresql@17'),
178
+ )
179
+ }
180
+ if (result.mysql.missing.length > 0) {
181
+ console.log(chalk.gray(' MySQL: brew install mysql'))
113
182
  }
114
183
  console.log()
115
- console.log(chalk.gray(' Install missing tools:'))
116
- console.log(
117
- chalk.gray(
118
- ' macOS: brew install libpq && brew link --force libpq',
119
- ),
120
- )
121
- console.log(chalk.gray(' Ubuntu: apt install postgresql-client'))
184
+ }
185
+
186
+ // Show enhanced shell hints
187
+ if (result.enhanced.missing.length > 0) {
188
+ console.log(chalk.gray(' Optional enhanced shells:'))
189
+ if (result.enhanced.missing.includes('pgcli')) {
190
+ console.log(chalk.gray(' pgcli: brew install pgcli'))
191
+ }
192
+ if (result.enhanced.missing.includes('mycli')) {
193
+ console.log(chalk.gray(' mycli: brew install mycli'))
194
+ }
195
+ if (result.enhanced.missing.includes('usql')) {
196
+ console.log(
197
+ chalk.gray(' usql: brew tap xo/xo && brew install usql'),
198
+ )
199
+ }
122
200
  console.log()
123
201
  }
124
202
  } catch (err) {
@@ -131,14 +209,14 @@ export const configCommand = new Command('config')
131
209
  .addCommand(
132
210
  new Command('set')
133
211
  .description('Set a custom binary path')
134
- .argument('<tool>', `Tool name (${VALID_TOOLS.join(', ')})`)
212
+ .argument('<tool>', 'Tool name (psql, mysql, pgcli, etc.)')
135
213
  .argument('<path>', 'Path to the binary')
136
214
  .action(async (tool: string, path: string) => {
137
215
  try {
138
216
  // Validate tool name
139
- if (!VALID_TOOLS.includes(tool as BinaryTool)) {
217
+ if (!ALL_TOOLS.includes(tool as BinaryTool)) {
140
218
  console.error(error(`Invalid tool: ${tool}`))
141
- console.log(chalk.gray(` Valid tools: ${VALID_TOOLS.join(', ')}`))
219
+ console.log(chalk.gray(` Valid tools: ${ALL_TOOLS.join(', ')}`))
142
220
  process.exit(1)
143
221
  }
144
222
 
@@ -164,13 +242,13 @@ export const configCommand = new Command('config')
164
242
  .addCommand(
165
243
  new Command('unset')
166
244
  .description('Remove a custom binary path')
167
- .argument('<tool>', `Tool name (${VALID_TOOLS.join(', ')})`)
245
+ .argument('<tool>', 'Tool name (psql, mysql, pgcli, etc.)')
168
246
  .action(async (tool: string) => {
169
247
  try {
170
248
  // Validate tool name
171
- if (!VALID_TOOLS.includes(tool as BinaryTool)) {
249
+ if (!ALL_TOOLS.includes(tool as BinaryTool)) {
172
250
  console.error(error(`Invalid tool: ${tool}`))
173
- console.log(chalk.gray(` Valid tools: ${VALID_TOOLS.join(', ')}`))
251
+ console.log(chalk.gray(` Valid tools: ${ALL_TOOLS.join(', ')}`))
174
252
  process.exit(1)
175
253
  }
176
254
 
@@ -186,13 +264,13 @@ export const configCommand = new Command('config')
186
264
  .addCommand(
187
265
  new Command('path')
188
266
  .description('Show the path for a specific tool')
189
- .argument('<tool>', `Tool name (${VALID_TOOLS.join(', ')})`)
267
+ .argument('<tool>', 'Tool name (psql, mysql, pgcli, etc.)')
190
268
  .action(async (tool: string) => {
191
269
  try {
192
270
  // Validate tool name
193
- if (!VALID_TOOLS.includes(tool as BinaryTool)) {
271
+ if (!ALL_TOOLS.includes(tool as BinaryTool)) {
194
272
  console.error(error(`Invalid tool: ${tool}`))
195
- console.log(chalk.gray(` Valid tools: ${VALID_TOOLS.join(', ')}`))
273
+ console.log(chalk.gray(` Valid tools: ${ALL_TOOLS.join(', ')}`))
196
274
  process.exit(1)
197
275
  }
198
276
 
@@ -10,7 +10,7 @@ import { paths } from '../../config/paths'
10
10
  import { containerManager } from '../../core/container-manager'
11
11
  import { promptConfirm } from '../ui/prompts'
12
12
  import { createSpinner } from '../ui/spinner'
13
- import { error, warning, info } from '../ui/theme'
13
+ import { error, warning, info, formatBytes } from '../ui/theme'
14
14
  import {
15
15
  getMysqldPath,
16
16
  getMysqlVersion,
@@ -185,14 +185,6 @@ function compareVersions(a: string, b: string): number {
185
185
  return 0
186
186
  }
187
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
188
  /**
197
189
  * Engine icons
198
190
  */