spindb 0.28.1 → 0.30.1

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.
Files changed (45) hide show
  1. package/README.md +32 -0
  2. package/cli/commands/create.ts +92 -26
  3. package/cli/commands/export.ts +362 -0
  4. package/cli/commands/menu/container-handlers.ts +276 -0
  5. package/cli/commands/self-update.ts +18 -6
  6. package/cli/commands/sqlite.ts +1 -3
  7. package/cli/index.ts +2 -0
  8. package/cli/ui/prompts.ts +5 -5
  9. package/core/credential-generator.ts +93 -0
  10. package/core/docker-exporter.ts +895 -0
  11. package/core/tls-generator.ts +116 -0
  12. package/core/update-manager.ts +25 -15
  13. package/engines/clickhouse/README.md +231 -0
  14. package/engines/cockroachdb/README.md +170 -0
  15. package/engines/couchdb/README.md +257 -0
  16. package/engines/duckdb/README.md +154 -0
  17. package/engines/ferretdb/README.md +220 -0
  18. package/engines/mariadb/README.md +141 -0
  19. package/engines/mariadb/backup.ts +2 -4
  20. package/engines/meilisearch/README.md +255 -0
  21. package/engines/mongodb/README.md +162 -0
  22. package/engines/mongodb/backup.ts +2 -2
  23. package/engines/mongodb/cli-utils.ts +107 -14
  24. package/engines/mongodb/index.ts +2 -1
  25. package/engines/mongodb/restore.ts +13 -6
  26. package/engines/mysql/README.md +142 -0
  27. package/engines/mysql/backup.ts +66 -9
  28. package/engines/mysql/index.ts +1 -0
  29. package/engines/mysql/restore.ts +56 -12
  30. package/engines/postgresql/README.md +158 -0
  31. package/engines/postgresql/backup.ts +70 -14
  32. package/engines/postgresql/index.ts +1 -0
  33. package/engines/postgresql/restore.ts +129 -18
  34. package/engines/qdrant/README.md +222 -0
  35. package/engines/qdrant/cli-utils.ts +2 -4
  36. package/engines/questdb/README.md +334 -0
  37. package/engines/questdb/index.ts +1 -2
  38. package/engines/redis/README.md +173 -0
  39. package/engines/redis/cli-utils.ts +2 -4
  40. package/engines/sqlite/README.md +162 -0
  41. package/engines/surrealdb/README.md +218 -0
  42. package/engines/surrealdb/index.ts +1 -2
  43. package/engines/valkey/README.md +219 -0
  44. package/engines/valkey/cli-utils.ts +2 -4
  45. package/package.json +3 -3
package/README.md CHANGED
@@ -279,6 +279,7 @@ spindb create mydb --engine mongodb # MongoDB
279
279
  spindb create mydb --engine mysql --db-version 8.0 # MySQL 8.0
280
280
  spindb create mydb --port 5433 # Custom port
281
281
  spindb create mydb --start --connect # Create, start, and connect
282
+ spindb create mydb --force # Overwrite existing container
282
283
 
283
284
  # Start/stop databases
284
285
  spindb start mydb
@@ -354,6 +355,37 @@ spindb pull mydb --from-env PROD_URL --dry-run
354
355
  spindb pull mydb --from-env PROD_URL --post-script ./sync-credentials.ts
355
356
  ```
356
357
 
358
+ ### Export to Docker
359
+
360
+ Generate a Docker-ready package from any SpinDB container:
361
+
362
+ ```bash
363
+ # Export to Docker (generates Dockerfile, docker-compose.yml, etc.)
364
+ spindb export docker mydb
365
+
366
+ # Custom output directory
367
+ spindb export docker mydb -o ./deploy
368
+
369
+ # Override port (default: engine's standard port, e.g., 5432 for PostgreSQL)
370
+ spindb export docker mydb -p 5433
371
+
372
+ # Skip database backup or TLS certificates
373
+ spindb export docker mydb --no-data
374
+ spindb export docker mydb --no-tls
375
+
376
+ # JSON output for scripting
377
+ spindb export docker mydb --json --force
378
+ ```
379
+
380
+ Generated files:
381
+ - `Dockerfile` - Ubuntu 22.04 + Node.js 22 + SpinDB
382
+ - `docker-compose.yml` - Container orchestration
383
+ - `.env` - Auto-generated credentials
384
+ - `certs/` - TLS certificates (self-signed)
385
+ - `data/` - Database backup
386
+ - `entrypoint.sh` - Startup script
387
+ - `README.md` - Instructions
388
+
357
389
  ### Container Management
358
390
 
359
391
  ```bash
@@ -37,10 +37,17 @@ async function createSqliteContainer(
37
37
  path?: string
38
38
  from?: string | null
39
39
  connect?: boolean
40
+ force?: boolean
40
41
  json?: boolean
41
42
  },
42
43
  ): Promise<void> {
43
- const { path: filePath, from: restoreLocation, connect, json } = options
44
+ const {
45
+ path: filePath,
46
+ from: restoreLocation,
47
+ connect,
48
+ force,
49
+ json,
50
+ } = options
44
51
 
45
52
  // Check dependencies
46
53
  const depsSpinner = json ? null : createSpinner('Checking required tools...')
@@ -70,17 +77,26 @@ async function createSqliteContainer(
70
77
 
71
78
  // Check if container already exists
72
79
  if (await containerManager.exists(containerName)) {
73
- if (json) {
80
+ if (force) {
81
+ // Delete existing container with force
82
+ if (!json) {
83
+ console.log(
84
+ chalk.yellow(` Removing existing container "${containerName}"...`),
85
+ )
86
+ }
87
+ await containerManager.delete(containerName, { force: true })
88
+ } else if (json) {
74
89
  return exitWithError({
75
- message: `Container "${containerName}" already exists`,
90
+ message: `Container "${containerName}" already exists. Use --force to overwrite.`,
76
91
  json: true,
77
92
  })
78
- }
79
- while (await containerManager.exists(containerName)) {
80
- console.log(
81
- chalk.yellow(` Container "${containerName}" already exists.`),
82
- )
83
- containerName = await promptContainerName()
93
+ } else {
94
+ while (await containerManager.exists(containerName)) {
95
+ console.log(
96
+ chalk.yellow(` Container "${containerName}" already exists.`),
97
+ )
98
+ containerName = await promptContainerName()
99
+ }
84
100
  }
85
101
  }
86
102
 
@@ -190,10 +206,17 @@ async function createDuckDBContainer(
190
206
  path?: string
191
207
  from?: string | null
192
208
  connect?: boolean
209
+ force?: boolean
193
210
  json?: boolean
194
211
  },
195
212
  ): Promise<void> {
196
- const { path: filePath, from: restoreLocation, connect, json } = options
213
+ const {
214
+ path: filePath,
215
+ from: restoreLocation,
216
+ connect,
217
+ force,
218
+ json,
219
+ } = options
197
220
 
198
221
  // Check dependencies
199
222
  const depsSpinner = json ? null : createSpinner('Checking required tools...')
@@ -223,17 +246,26 @@ async function createDuckDBContainer(
223
246
 
224
247
  // Check if container already exists
225
248
  if (await containerManager.exists(containerName)) {
226
- if (json) {
249
+ if (force) {
250
+ // Delete existing container with force
251
+ if (!json) {
252
+ console.log(
253
+ chalk.yellow(` Removing existing container "${containerName}"...`),
254
+ )
255
+ }
256
+ await containerManager.delete(containerName, { force: true })
257
+ } else if (json) {
227
258
  return exitWithError({
228
- message: `Container "${containerName}" already exists`,
259
+ message: `Container "${containerName}" already exists. Use --force to overwrite.`,
229
260
  json: true,
230
261
  })
231
- }
232
- while (await containerManager.exists(containerName)) {
233
- console.log(
234
- chalk.yellow(` Container "${containerName}" already exists.`),
235
- )
236
- containerName = await promptContainerName()
262
+ } else {
263
+ while (await containerManager.exists(containerName)) {
264
+ console.log(
265
+ chalk.yellow(` Container "${containerName}" already exists.`),
266
+ )
267
+ containerName = await promptContainerName()
268
+ }
237
269
  }
238
270
  }
239
271
 
@@ -404,6 +436,10 @@ export const createCommand = new Command('create')
404
436
  '--max-connections <number>',
405
437
  'Maximum number of database connections (default: 200)',
406
438
  )
439
+ .option(
440
+ '-f, --force',
441
+ 'Overwrite existing container without prompting (deletes existing data)',
442
+ )
407
443
  .option('--start', 'Start the container after creation (skip prompt)')
408
444
  .option('--no-start', 'Do not start the container after creation')
409
445
  .option('--connect', 'Open a shell connection after creation')
@@ -422,6 +458,7 @@ export const createCommand = new Command('create')
422
458
  port?: string
423
459
  path?: string
424
460
  maxConnections?: string
461
+ force?: boolean
425
462
  start?: boolean
426
463
  connect?: boolean
427
464
  from?: string
@@ -541,6 +578,7 @@ export const createCommand = new Command('create')
541
578
  path: options.path,
542
579
  from: restoreLocation,
543
580
  connect: options.connect,
581
+ force: options.force,
544
582
  json: options.json,
545
583
  })
546
584
  return
@@ -552,6 +590,7 @@ export const createCommand = new Command('create')
552
590
  path: options.path,
553
591
  from: restoreLocation,
554
592
  connect: options.connect,
593
+ force: options.force,
555
594
  json: options.json,
556
595
  })
557
596
  return
@@ -733,17 +772,44 @@ export const createCommand = new Command('create')
733
772
  }
734
773
 
735
774
  if (await containerManager.exists(containerName)) {
736
- if (options.json) {
775
+ if (options.force) {
776
+ // Stop the container if it's running, then delete it
777
+ const existingConfig =
778
+ await containerManager.getConfig(containerName)
779
+ if (existingConfig?.status === 'running') {
780
+ if (!options.json) {
781
+ console.log(
782
+ chalk.yellow(
783
+ ` Stopping existing container "${containerName}"...`,
784
+ ),
785
+ )
786
+ }
787
+ try {
788
+ await dbEngine.stop(existingConfig)
789
+ } catch {
790
+ // Ignore stop errors - container may already be stopped
791
+ }
792
+ }
793
+ if (!options.json) {
794
+ console.log(
795
+ chalk.yellow(
796
+ ` Removing existing container "${containerName}"...`,
797
+ ),
798
+ )
799
+ }
800
+ await containerManager.delete(containerName, { force: true })
801
+ } else if (options.json) {
737
802
  return exitWithError({
738
- message: `Container "${containerName}" already exists`,
803
+ message: `Container "${containerName}" already exists. Use --force to overwrite.`,
739
804
  json: true,
740
805
  })
741
- }
742
- while (await containerManager.exists(containerName)) {
743
- console.log(
744
- chalk.yellow(` Container "${containerName}" already exists.`),
745
- )
746
- containerName = await promptContainerName()
806
+ } else {
807
+ while (await containerManager.exists(containerName)) {
808
+ console.log(
809
+ chalk.yellow(` Container "${containerName}" already exists.`),
810
+ )
811
+ containerName = await promptContainerName()
812
+ }
747
813
  }
748
814
  }
749
815
 
@@ -0,0 +1,362 @@
1
+ import { Command } from 'commander'
2
+ import { join, resolve } 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 { platformService } from '../../core/platform-service'
8
+ import { exportToDocker, getExportBackupPath } from '../../core/docker-exporter'
9
+ import { promptContainerSelect, promptConfirm } from '../ui/prompts'
10
+ import { createSpinner } from '../ui/spinner'
11
+ import { uiSuccess, uiError, uiWarning, box, formatBytes } from '../ui/theme'
12
+ import { isFileBasedEngine } from '../../types'
13
+ import { getDefaultFormat } from '../../config/backup-formats'
14
+ import { getEngineDefaults } from '../../config/engine-defaults'
15
+ import { paths } from '../../config/paths'
16
+ import { stat, rm, mkdir } from 'fs/promises'
17
+ import { existsSync } from 'fs'
18
+ import inquirer from 'inquirer'
19
+
20
+ export const exportCommand = new Command('export')
21
+ .description('Export container to various formats')
22
+ .addCommand(
23
+ new Command('docker')
24
+ .description('Export container to Docker-ready package')
25
+ .argument('[container]', 'Container name')
26
+ .option(
27
+ '-o, --output <dir>',
28
+ 'Output directory (default: ~/.spindb/containers/{engine}/{name}/docker)',
29
+ )
30
+ .option('-p, --port <number>', 'Override external port', parseInt)
31
+ .option('--no-data', 'Skip including database backup')
32
+ .option('--no-tls', 'Skip TLS certificate generation')
33
+ .option('-f, --force', 'Overwrite existing output directory')
34
+ .option('-c, --copy', 'Copy password to clipboard')
35
+ .option('-j, --json', 'Output result as JSON')
36
+ .action(
37
+ async (
38
+ containerArg: string | undefined,
39
+ options: {
40
+ output?: string
41
+ port?: number
42
+ data?: boolean
43
+ tls?: boolean
44
+ force?: boolean
45
+ copy?: boolean
46
+ json?: boolean
47
+ },
48
+ ) => {
49
+ try {
50
+ let containerName = containerArg
51
+ // Track if we're in interactive mode (no container arg = user will select)
52
+ const isInteractive = !containerArg && !options.json
53
+
54
+ // Select container if not provided
55
+ if (!containerName) {
56
+ if (options.json) {
57
+ console.log(
58
+ JSON.stringify({ error: 'Container name is required' }),
59
+ )
60
+ process.exit(1)
61
+ }
62
+
63
+ const containers = await containerManager.list()
64
+
65
+ if (containers.length === 0) {
66
+ console.log(
67
+ uiWarning(
68
+ 'No containers found. Create one with: spindb create',
69
+ ),
70
+ )
71
+ return
72
+ }
73
+
74
+ const selected = await promptContainerSelect(
75
+ containers,
76
+ 'Select container to export:',
77
+ )
78
+ if (!selected) return
79
+ containerName = selected
80
+ }
81
+
82
+ // Get container config
83
+ const config = await containerManager.getConfig(containerName)
84
+ if (!config) {
85
+ if (options.json) {
86
+ console.log(
87
+ JSON.stringify({
88
+ error: `Container "${containerName}" not found`,
89
+ }),
90
+ )
91
+ } else {
92
+ console.error(uiError(`Container "${containerName}" not found`))
93
+ }
94
+ process.exit(1)
95
+ }
96
+
97
+ const { engine: engineName, version, database, port } = config
98
+ const engine = getEngine(engineName)
99
+ const engineDefaultPort = getEngineDefaults(engineName).defaultPort
100
+
101
+ // Default output directory: ~/.spindb/containers/{engine}/{name}/docker
102
+ const defaultOutputDir = join(
103
+ paths.getContainerPath(containerName, { engine: engineName }),
104
+ 'docker',
105
+ )
106
+ const outputDir = options.output
107
+ ? resolve(options.output)
108
+ : defaultOutputDir
109
+ const includeData = options.data !== false
110
+ const skipTLS = options.tls === false
111
+
112
+ // Determine target port:
113
+ // 1. If user explicitly passed -p, use that
114
+ // 2. If local port matches engine default, use it
115
+ // 3. If interactive mode (no container arg) and ports differ, prompt user
116
+ // 4. CLI mode or JSON mode: default to engine's standard port
117
+ let targetPort: number
118
+ if (options.port !== undefined) {
119
+ // User explicitly specified a port
120
+ targetPort = options.port
121
+ } else if (port === engineDefaultPort) {
122
+ // Local port matches engine default, no decision needed
123
+ targetPort = engineDefaultPort
124
+ } else if (isInteractive) {
125
+ // Interactive mode only: prompt user to choose between local and default port
126
+ console.log()
127
+ console.log(
128
+ chalk.yellow(
129
+ `Local container uses port ${chalk.cyan(String(port))}, but ${engine.displayName}'s standard port is ${chalk.cyan(String(engineDefaultPort))}.`,
130
+ ),
131
+ )
132
+ const { selectedPort } = await inquirer.prompt<{
133
+ selectedPort: number
134
+ }>([
135
+ {
136
+ type: 'list',
137
+ name: 'selectedPort',
138
+ message: 'Which port should the Docker container use?',
139
+ choices: [
140
+ {
141
+ name: `${engineDefaultPort} ${chalk.gray('(standard port - recommended)')}`,
142
+ value: engineDefaultPort,
143
+ },
144
+ {
145
+ name: `${port} ${chalk.gray('(same as local container)')}`,
146
+ value: port,
147
+ },
148
+ ],
149
+ default: engineDefaultPort,
150
+ },
151
+ ])
152
+ targetPort = selectedPort
153
+ } else {
154
+ // CLI mode or JSON mode: default to standard port
155
+ targetPort = engineDefaultPort
156
+ }
157
+
158
+ // Check if output directory already exists
159
+ if (existsSync(outputDir)) {
160
+ let shouldOverwrite = options.force
161
+
162
+ if (!shouldOverwrite && !options.json) {
163
+ // Interactive prompt to confirm overwrite
164
+ console.log()
165
+ console.log(
166
+ uiWarning(`Output directory already exists: ${outputDir}`),
167
+ )
168
+ shouldOverwrite = await promptConfirm(
169
+ 'Do you want to overwrite it?',
170
+ false, // Default to No for safety
171
+ )
172
+ }
173
+
174
+ if (shouldOverwrite) {
175
+ // Remove existing directory
176
+ await rm(outputDir, { recursive: true, force: true })
177
+ } else {
178
+ if (options.json) {
179
+ console.log(
180
+ JSON.stringify({
181
+ error: `Output directory already exists: ${outputDir}`,
182
+ }),
183
+ )
184
+ } else {
185
+ console.log(
186
+ uiError(
187
+ 'Export cancelled. Use --force to overwrite or --output to specify a different path.',
188
+ ),
189
+ )
190
+ }
191
+ process.exit(1)
192
+ }
193
+ }
194
+
195
+ // For server-based engines with data, check if container is running
196
+ let backupPath: string | undefined
197
+ if (includeData && !isFileBasedEngine(engineName)) {
198
+ const running = await processManager.isRunning(containerName, {
199
+ engine: engineName,
200
+ })
201
+
202
+ if (!running) {
203
+ if (options.json) {
204
+ console.log(
205
+ JSON.stringify({
206
+ error: `Container "${containerName}" is not running. Start it first to export with data.`,
207
+ }),
208
+ )
209
+ } else {
210
+ console.error(
211
+ uiError(
212
+ `Container "${containerName}" is not running.\nStart it first with: spindb start ${containerName}`,
213
+ ),
214
+ )
215
+ }
216
+ process.exit(1)
217
+ }
218
+ }
219
+
220
+ if (!options.json) {
221
+ console.log()
222
+ console.log(
223
+ chalk.bold(
224
+ `Exporting ${chalk.cyan(containerName)} to Docker...`,
225
+ ),
226
+ )
227
+ console.log()
228
+ }
229
+
230
+ // Step 1: Create backup if including data
231
+ if (includeData) {
232
+ const backupSpinner = options.json
233
+ ? null
234
+ : createSpinner('Creating database backup...')
235
+ backupSpinner?.start()
236
+
237
+ try {
238
+ // Create a temporary backup
239
+ const tempBackupPath = getExportBackupPath(
240
+ outputDir,
241
+ containerName,
242
+ database,
243
+ engineName,
244
+ )
245
+
246
+ // Create parent directory for backup
247
+ await mkdir(join(outputDir, 'data'), { recursive: true })
248
+
249
+ // Create backup using engine's backup method
250
+ const format = getDefaultFormat(engineName)
251
+ const result = await engine.backup(config, tempBackupPath, {
252
+ database,
253
+ format,
254
+ })
255
+
256
+ backupPath = result.path
257
+
258
+ const backupStat = await stat(result.path)
259
+ backupSpinner?.succeed(
260
+ `Backup created (${formatBytes(backupStat.size)})`,
261
+ )
262
+ } catch (error) {
263
+ const e = error as Error
264
+ backupSpinner?.fail('Backup failed')
265
+
266
+ if (options.json) {
267
+ console.log(JSON.stringify({ error: e.message }))
268
+ } else {
269
+ console.error(uiError(e.message))
270
+ }
271
+ process.exit(1)
272
+ }
273
+ }
274
+
275
+ // Step 2: Generate Docker artifacts
276
+ const exportSpinner = options.json
277
+ ? null
278
+ : createSpinner('Generating Docker artifacts...')
279
+ exportSpinner?.start()
280
+
281
+ const result = await exportToDocker(config, {
282
+ outputDir,
283
+ port: targetPort,
284
+ includeData,
285
+ backupPath,
286
+ skipTLS,
287
+ })
288
+
289
+ exportSpinner?.succeed('Docker artifacts generated')
290
+
291
+ // Copy password to clipboard if requested
292
+ if (options.copy) {
293
+ const copied = await platformService.copyToClipboard(
294
+ result.credentials.password,
295
+ )
296
+ if (copied && !options.json) {
297
+ console.log(uiSuccess('Password copied to clipboard'))
298
+ }
299
+ }
300
+
301
+ // Output results
302
+ if (options.json) {
303
+ console.log(
304
+ JSON.stringify({
305
+ success: true,
306
+ outputDir: result.outputDir,
307
+ engine: result.engine,
308
+ version: result.version,
309
+ port: result.port,
310
+ database: result.database,
311
+ username: result.credentials.username,
312
+ password: result.credentials.password,
313
+ files: result.files,
314
+ }),
315
+ )
316
+ } else {
317
+ console.log()
318
+ console.log(
319
+ uiSuccess(`Exported ${chalk.cyan(containerName)} to Docker`),
320
+ )
321
+ console.log()
322
+
323
+ // Display summary box
324
+ const lines = [
325
+ `${chalk.bold(engine.displayName)} ${version}`,
326
+ `Port: ${chalk.green(String(targetPort))}`,
327
+ `Database: ${chalk.cyan(database)}`,
328
+ '',
329
+ chalk.bold('Generated Credentials'),
330
+ chalk.gray('────────────────────────'),
331
+ `Username: ${chalk.white(result.credentials.username)}`,
332
+ `Password: ${chalk.white(result.credentials.password)}`,
333
+ chalk.gray('────────────────────────'),
334
+ '',
335
+ chalk.yellow('Save these credentials now - stored in .env'),
336
+ ]
337
+
338
+ console.log(box(lines))
339
+ console.log()
340
+ console.log(chalk.gray(' Output:'), chalk.cyan(result.outputDir))
341
+ console.log()
342
+ console.log(chalk.bold(' To run:'))
343
+ console.log(
344
+ chalk.cyan(
345
+ ` cd "${result.outputDir}" && docker compose up -d`,
346
+ ),
347
+ )
348
+ console.log()
349
+ }
350
+ } catch (error) {
351
+ const e = error as Error
352
+
353
+ if (options.json) {
354
+ console.log(JSON.stringify({ error: e.message }))
355
+ } else {
356
+ console.error(uiError(e.message))
357
+ }
358
+ process.exit(1)
359
+ }
360
+ },
361
+ ),
362
+ )