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.
Files changed (36) hide show
  1. package/README.md +137 -8
  2. package/cli/commands/connect.ts +8 -4
  3. package/cli/commands/create.ts +106 -67
  4. package/cli/commands/deps.ts +19 -4
  5. package/cli/commands/edit.ts +245 -0
  6. package/cli/commands/engines.ts +434 -0
  7. package/cli/commands/info.ts +279 -0
  8. package/cli/commands/menu.ts +408 -153
  9. package/cli/commands/restore.ts +10 -24
  10. package/cli/commands/start.ts +25 -20
  11. package/cli/commands/url.ts +79 -0
  12. package/cli/index.ts +9 -3
  13. package/cli/ui/prompts.ts +8 -6
  14. package/config/engine-defaults.ts +24 -1
  15. package/config/os-dependencies.ts +59 -113
  16. package/config/paths.ts +7 -36
  17. package/core/binary-manager.ts +19 -6
  18. package/core/config-manager.ts +17 -5
  19. package/core/dependency-manager.ts +9 -15
  20. package/core/error-handler.ts +336 -0
  21. package/core/platform-service.ts +634 -0
  22. package/core/port-manager.ts +11 -3
  23. package/core/process-manager.ts +12 -2
  24. package/core/start-with-retry.ts +167 -0
  25. package/core/transaction-manager.ts +170 -0
  26. package/engines/mysql/binary-detection.ts +177 -100
  27. package/engines/mysql/index.ts +240 -131
  28. package/engines/mysql/restore.ts +257 -0
  29. package/engines/mysql/version-validator.ts +373 -0
  30. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  31. package/engines/postgresql/binary-urls.ts +5 -3
  32. package/engines/postgresql/index.ts +4 -3
  33. package/engines/postgresql/restore.ts +54 -5
  34. package/engines/postgresql/version-validator.ts +262 -0
  35. package/package.json +6 -2
  36. package/cli/commands/postgres-tools.ts +0 -216
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SpinDB
2
2
 
3
- Spin up local PostgreSQL and MySQL databases without Docker. A lightweight alternative to DBngin.
3
+ Spin up local PostgreSQL and MySQL databases without Docker. A lightweight alternative to DBngin and Postgres.app.
4
4
 
5
5
  ## Features
6
6
 
@@ -45,12 +45,17 @@ spindb connect mydb
45
45
  | `spindb` | Open interactive menu |
46
46
  | `spindb create [name]` | Create a new database container |
47
47
  | `spindb list` | List all containers |
48
+ | `spindb info [name]` | Show container details (or all containers) |
48
49
  | `spindb start [name]` | Start a container |
49
50
  | `spindb stop [name]` | Stop a container |
50
51
  | `spindb connect [name]` | Connect with psql/mysql shell |
52
+ | `spindb url [name]` | Output connection string |
53
+ | `spindb edit [name]` | Edit container properties (rename, port) |
51
54
  | `spindb restore [name] [backup]` | Restore a backup file |
52
55
  | `spindb clone [source] [target]` | Clone a container |
53
56
  | `spindb delete [name]` | Delete a container |
57
+ | `spindb engines` | List installed database engines |
58
+ | `spindb engines delete` | Delete an installed engine version |
54
59
  | `spindb config show` | Show configuration |
55
60
  | `spindb config detect` | Auto-detect database tools |
56
61
  | `spindb deps check` | Check status of client tools |
@@ -60,16 +65,27 @@ spindb connect mydb
60
65
 
61
66
  ### PostgreSQL 🐘
62
67
 
63
- - Downloads binaries from [zonky.io](https://github.com/zonkyio/embedded-postgres-binaries)
68
+ - Downloads server binaries from [zonky.io embedded-postgres-binaries](https://github.com/zonkyio/embedded-postgres-binaries)
64
69
  - Versions: 14, 15, 16, 17
65
70
  - Requires system client tools (psql, pg_dump, pg_restore) for some operations
66
71
 
72
+ **Why zonky.io?** Zonky.io provides pre-compiled PostgreSQL server binaries for multiple platforms (macOS, Linux) and architectures (x64, ARM64) hosted on Maven Central. This allows SpinDB to download and run PostgreSQL without requiring a full system installation. The binaries are extracted from official PostgreSQL distributions and repackaged for easy embedding in applications.
73
+
67
74
  ### MySQL 🐬
68
75
 
69
76
  - Uses system-installed MySQL (via Homebrew, apt, etc.)
70
77
  - Version determined by system installation
71
78
  - Requires: mysqld, mysql, mysqldump, mysqladmin
72
79
 
80
+ **Linux Note:** On Linux systems, MariaDB is commonly used as a drop-in replacement for MySQL. SpinDB fully supports MariaDB and will automatically detect it. When MariaDB is installed, the `mysql`, `mysqld`, and `mysqldump` commands work the same way. Install with:
81
+ ```bash
82
+ # Ubuntu/Debian
83
+ sudo apt install mariadb-server
84
+
85
+ # Arch
86
+ sudo pacman -S mariadb
87
+ ```
88
+
73
89
  ## How It Works
74
90
 
75
91
  Data is stored in `~/.spindb/`:
@@ -109,12 +125,14 @@ spindb deps install --engine postgresql
109
125
  spindb deps install --engine mysql
110
126
  ```
111
127
 
128
+ **Note:** On Linux, package managers (apt, pacman, dnf) require `sudo` privileges. You may be prompted for your password when installing dependencies.
129
+
112
130
  ### Manual Installation
113
131
 
114
132
  #### PostgreSQL
115
133
 
116
134
  ```bash
117
- # macOS (Homebrew)
135
+ # macOS (Homebrew) - use the latest PostgreSQL version (currently 17)
118
136
  brew install postgresql@17
119
137
  brew link --overwrite postgresql@17
120
138
 
@@ -194,15 +212,59 @@ mysql -u root -h 127.0.0.1 -P 3306 mydb
194
212
 
195
213
  ### Manage installed engines
196
214
 
197
- The Engines menu shows installed PostgreSQL versions with disk usage:
215
+ View installed engines with disk usage (PostgreSQL) and system detection (MySQL):
216
+
217
+ ```bash
218
+ spindb engines
219
+ ```
198
220
 
199
221
  ```
200
- ENGINE VERSION PLATFORM SIZE
222
+ ENGINE VERSION SOURCE SIZE
201
223
  ────────────────────────────────────────────────────────
202
- postgresql 17 darwin-arm64 45.2 MB
203
- postgresql 16 darwin-arm64 44.8 MB
224
+ 🐘 postgresql 17.7 darwin-arm64 45.2 MB
225
+ 🐘 postgresql 16.8 darwin-arm64 44.8 MB
226
+ 🐬 mysql 8.0.35 system (system-installed)
204
227
  ────────────────────────────────────────────────────────
205
- 2 version(s) 90.0 MB
228
+
229
+ PostgreSQL: 2 version(s), 90.0 MB
230
+ MySQL: system-installed at /opt/homebrew/bin/mysqld
231
+ ```
232
+
233
+ Delete unused PostgreSQL versions to free disk space:
234
+
235
+ ```bash
236
+ spindb engines delete postgresql 16
237
+ ```
238
+
239
+ ### Container info and connection strings
240
+
241
+ ```bash
242
+ # View all container details
243
+ spindb info
244
+
245
+ # View specific container
246
+ spindb info mydb
247
+
248
+ # Get connection string for scripting
249
+ spindb url mydb
250
+ export DATABASE_URL=$(spindb url mydb)
251
+ psql $(spindb url mydb)
252
+
253
+ # Copy connection string to clipboard
254
+ spindb url mydb --copy
255
+ ```
256
+
257
+ ### Edit containers
258
+
259
+ ```bash
260
+ # Rename a container (must be stopped)
261
+ spindb edit mydb --name newname
262
+
263
+ # Change port
264
+ spindb edit mydb --port 5433
265
+
266
+ # Interactive mode
267
+ spindb edit mydb
206
268
  ```
207
269
 
208
270
  ## Running Tests
@@ -251,6 +313,73 @@ cat ~/.spindb/containers/mysql/mydb/mysql.log
251
313
  rm -rf ~/.spindb
252
314
  ```
253
315
 
316
+ ## Project Structure
317
+
318
+ ```
319
+ spindb/
320
+ ├── bin.ts # Entry point (#!/usr/bin/env tsx)
321
+ ├── cli/
322
+ │ ├── index.ts # Commander setup, routes to commands
323
+ │ ├── commands/ # CLI commands
324
+ │ │ ├── menu.ts # Interactive arrow-key menu
325
+ │ │ ├── create.ts # Create container command
326
+ │ │ ├── delete.ts # Delete container command
327
+ │ │ └── ... # Other commands
328
+ │ └── ui/
329
+ │ ├── prompts.ts # Inquirer prompts
330
+ │ ├── spinner.ts # Ora spinner helpers
331
+ │ └── theme.ts # Chalk color theme
332
+ ├── core/
333
+ │ ├── binary-manager.ts # Downloads PostgreSQL from zonky.io
334
+ │ ├── config-manager.ts # Manages ~/.spindb/config.json
335
+ │ ├── container-manager.ts # CRUD for containers
336
+ │ ├── port-manager.ts # Port availability checking
337
+ │ ├── process-manager.ts # Process start/stop wrapper
338
+ │ ├── dependency-manager.ts # Client tool detection
339
+ │ ├── error-handler.ts # Centralized error handling
340
+ │ └── transaction-manager.ts # Rollback support for operations
341
+ ├── config/
342
+ │ ├── paths.ts # ~/.spindb/ path definitions
343
+ │ ├── defaults.ts # Default values, platform mappings
344
+ │ └── os-dependencies.ts # OS-specific dependency definitions
345
+ ├── engines/
346
+ │ ├── base-engine.ts # Abstract base class
347
+ │ ├── index.ts # Engine registry
348
+ │ ├── postgresql/
349
+ │ │ ├── index.ts # PostgreSQL engine implementation
350
+ │ │ ├── binary-urls.ts # Zonky.io URL builder
351
+ │ │ ├── restore.ts # Backup detection and restore
352
+ │ │ └── version-validator.ts # Version compatibility checks
353
+ │ └── mysql/
354
+ │ ├── index.ts # MySQL engine implementation
355
+ │ ├── binary-detection.ts # MySQL binary path detection
356
+ │ ├── restore.ts # Backup detection and restore
357
+ │ └── version-validator.ts # Version compatibility checks
358
+ ├── types/
359
+ │ └── index.ts # TypeScript interfaces
360
+ └── tests/
361
+ ├── unit/ # Unit tests
362
+ ├── integration/ # Integration tests
363
+ └── fixtures/ # Test data
364
+ ├── postgresql/
365
+ │ └── seeds/
366
+ └── mysql/
367
+ └── seeds/
368
+ ```
369
+
370
+ ## Contributing
371
+
372
+ ### Version Updates
373
+
374
+ SpinDB uses versioned PostgreSQL packages from Homebrew (e.g., `postgresql@17`). When new major versions are released:
375
+
376
+ 1. Check [PostgreSQL releases](https://www.postgresql.org/docs/release/) and [Homebrew formulae](https://formulae.brew.sh/formula/postgresql)
377
+ 2. Update `config/engine-defaults.ts`:
378
+ - Change `latestVersion` to the new version
379
+ - Add the new version to `supportedVersions`
380
+
381
+ See `CLAUDE.md` for detailed maintenance instructions.
382
+
254
383
  ## License
255
384
 
256
385
  MIT
@@ -55,7 +55,8 @@ export const connectCommand = new Command('connect')
55
55
  const engineDefaults = getEngineDefaults(engineName)
56
56
 
57
57
  // Default database: container's database or superuser
58
- const database = options.database ?? config.database ?? engineDefaults.superuser
58
+ const database =
59
+ options.database ?? config.database ?? engineDefaults.superuser
59
60
 
60
61
  // Check if running
61
62
  const running = await processManager.isRunning(containerName, {
@@ -83,9 +84,12 @@ export const connectCommand = new Command('connect')
83
84
  // MySQL: mysql -h 127.0.0.1 -P port -u root database
84
85
  clientCmd = 'mysql'
85
86
  clientArgs = [
86
- '-h', '127.0.0.1',
87
- '-P', String(config.port),
88
- '-u', engineDefaults.superuser,
87
+ '-h',
88
+ '127.0.0.1',
89
+ '-P',
90
+ String(config.port),
91
+ '-u',
92
+ engineDefaults.superuser,
89
93
  database,
90
94
  ]
91
95
  } else {
@@ -16,9 +16,10 @@ import { createSpinner } from '../ui/spinner'
16
16
  import { header, error, connectionBox } from '../ui/theme'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
- import { spawn } from 'child_process'
20
- import { platform } from 'os'
21
19
  import { getMissingDependencies } from '../../core/dependency-manager'
20
+ import { platformService } from '../../core/platform-service'
21
+ import { startWithRetry } from '../../core/start-with-retry'
22
+ import { TransactionManager } from '../../core/transaction-manager'
22
23
  import type { EngineName } from '../../types'
23
24
 
24
25
  /**
@@ -244,28 +245,50 @@ export const createCommand = new Command('create')
244
245
  containerName = await promptContainerName()
245
246
  }
246
247
 
248
+ // Create transaction manager for rollback support
249
+ const tx = new TransactionManager()
250
+
247
251
  // Create container
248
252
  const createSpinnerInstance = createSpinner('Creating container...')
249
253
  createSpinnerInstance.start()
250
254
 
251
- await containerManager.create(containerName, {
252
- engine: dbEngine.name as EngineName,
253
- version,
254
- port,
255
- database,
256
- })
255
+ try {
256
+ await containerManager.create(containerName, {
257
+ engine: dbEngine.name as EngineName,
258
+ version,
259
+ port,
260
+ database,
261
+ })
262
+
263
+ // Register rollback action for container deletion
264
+ tx.addRollback({
265
+ description: `Delete container "${containerName}"`,
266
+ execute: async () => {
267
+ await containerManager.delete(containerName, { force: true })
268
+ },
269
+ })
257
270
 
258
- createSpinnerInstance.succeed('Container created')
271
+ createSpinnerInstance.succeed('Container created')
272
+ } catch (err) {
273
+ createSpinnerInstance.fail('Failed to create container')
274
+ throw err
275
+ }
259
276
 
260
277
  // Initialize database cluster
261
278
  const initSpinner = createSpinner('Initializing database cluster...')
262
279
  initSpinner.start()
263
280
 
264
- await dbEngine.initDataDir(containerName, version, {
265
- superuser: engineDefaults.superuser,
266
- })
267
-
268
- initSpinner.succeed('Database cluster initialized')
281
+ try {
282
+ await dbEngine.initDataDir(containerName, version, {
283
+ superuser: engineDefaults.superuser,
284
+ })
285
+ // Note: initDataDir is covered by the container delete rollback
286
+ initSpinner.succeed('Database cluster initialized')
287
+ } catch (err) {
288
+ initSpinner.fail('Failed to initialize database cluster')
289
+ await tx.rollback()
290
+ throw err
291
+ }
269
292
 
270
293
  // Determine if we should start the container
271
294
  // If --from is specified, we must start to restore
@@ -281,10 +304,7 @@ export const createCommand = new Command('create')
281
304
  } else {
282
305
  // Ask the user
283
306
  console.log()
284
- shouldStart = await promptConfirm(
285
- `Start ${containerName} now?`,
286
- true,
287
- )
307
+ shouldStart = await promptConfirm(`Start ${containerName} now?`, true)
288
308
  }
289
309
 
290
310
  // Get container config for starting and restoration
@@ -292,34 +312,63 @@ export const createCommand = new Command('create')
292
312
 
293
313
  // Start container if requested
294
314
  if (shouldStart && config) {
295
- // Check port availability before starting
296
- const portAvailable = await portManager.isPortAvailable(config.port)
297
- if (!portAvailable) {
298
- // Find a new available port
299
- const { port: newPort } = await portManager.findAvailablePort({
300
- portRange: engineDefaults.portRange,
301
- })
302
- console.log(
303
- chalk.yellow(
304
- ` ⚠ Port ${config.port} is in use, switching to port ${newPort}`,
305
- ),
306
- )
307
- config.port = newPort
308
- port = newPort
309
- await containerManager.updateConfig(containerName, { port: newPort })
310
- }
311
-
312
315
  const startSpinner = createSpinner(
313
316
  `Starting ${dbEngine.displayName}...`,
314
317
  )
315
318
  startSpinner.start()
316
319
 
317
- await dbEngine.start(config)
318
- await containerManager.updateConfig(containerName, {
319
- status: 'running',
320
- })
320
+ try {
321
+ // Use startWithRetry to handle port race conditions
322
+ const result = await startWithRetry({
323
+ engine: dbEngine,
324
+ config,
325
+ onPortChange: (oldPort, newPort) => {
326
+ startSpinner.text = `Port ${oldPort} was in use, retrying with port ${newPort}...`
327
+ port = newPort
328
+ },
329
+ })
321
330
 
322
- startSpinner.succeed(`${dbEngine.displayName} started`)
331
+ if (!result.success) {
332
+ startSpinner.fail(`Failed to start ${dbEngine.displayName}`)
333
+ await tx.rollback()
334
+ if (result.error) {
335
+ throw result.error
336
+ }
337
+ throw new Error('Failed to start container')
338
+ }
339
+
340
+ // Register rollback action for stopping the container
341
+ tx.addRollback({
342
+ description: `Stop container "${containerName}"`,
343
+ execute: async () => {
344
+ try {
345
+ await dbEngine.stop(config)
346
+ } catch {
347
+ // Ignore stop errors during rollback
348
+ }
349
+ },
350
+ })
351
+
352
+ await containerManager.updateConfig(containerName, {
353
+ status: 'running',
354
+ })
355
+
356
+ if (result.retriesUsed > 0) {
357
+ startSpinner.warn(
358
+ `${dbEngine.displayName} started on port ${result.finalPort} (original port was in use)`,
359
+ )
360
+ } else {
361
+ startSpinner.succeed(`${dbEngine.displayName} started`)
362
+ }
363
+ } catch (err) {
364
+ if (!startSpinner.isSpinning) {
365
+ // Error was already handled above
366
+ } else {
367
+ startSpinner.fail(`Failed to start ${dbEngine.displayName}`)
368
+ }
369
+ await tx.rollback()
370
+ throw err
371
+ }
323
372
 
324
373
  // Create the user's database (if different from default)
325
374
  const defaultDb = engineDefaults.superuser // postgres or root
@@ -329,9 +378,14 @@ export const createCommand = new Command('create')
329
378
  )
330
379
  dbSpinner.start()
331
380
 
332
- await dbEngine.createDatabase(config, database)
333
-
334
- dbSpinner.succeed(`Database "${database}" created`)
381
+ try {
382
+ await dbEngine.createDatabase(config, database)
383
+ dbSpinner.succeed(`Database "${database}" created`)
384
+ } catch (err) {
385
+ dbSpinner.fail(`Failed to create database "${database}"`)
386
+ await tx.rollback()
387
+ throw err
388
+ }
335
389
  }
336
390
  }
337
391
 
@@ -431,13 +485,18 @@ export const createCommand = new Command('create')
431
485
  }
432
486
  }
433
487
 
488
+ // Commit the transaction - all operations succeeded
489
+ tx.commit()
490
+
434
491
  // Show success message
435
492
  const finalConfig = await containerManager.getConfig(containerName)
436
493
  if (finalConfig) {
437
494
  const connectionString = dbEngine.getConnectionString(finalConfig)
438
495
 
439
496
  console.log()
440
- console.log(connectionBox(containerName, connectionString, finalConfig.port))
497
+ console.log(
498
+ connectionBox(containerName, connectionString, finalConfig.port),
499
+ )
441
500
  console.log()
442
501
 
443
502
  if (shouldStart) {
@@ -445,30 +504,10 @@ export const createCommand = new Command('create')
445
504
  console.log(chalk.cyan(` spindb connect ${containerName}`))
446
505
 
447
506
  // Copy connection string to clipboard
448
- try {
449
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
450
- const args =
451
- platform() === 'darwin' ? [] : ['-selection', 'clipboard']
452
-
453
- await new Promise<void>((resolve, reject) => {
454
- const proc = spawn(cmd, args, {
455
- stdio: ['pipe', 'inherit', 'inherit'],
456
- })
457
- proc.stdin?.write(connectionString)
458
- proc.stdin?.end()
459
- proc.on('close', (code) => {
460
- if (code === 0) resolve()
461
- else
462
- reject(
463
- new Error(`Clipboard command exited with code ${code}`),
464
- )
465
- })
466
- proc.on('error', reject)
467
- })
468
-
507
+ const copied =
508
+ await platformService.copyToClipboard(connectionString)
509
+ if (copied) {
469
510
  console.log(chalk.gray(' Connection string copied to clipboard'))
470
- } catch {
471
- // Ignore clipboard errors
472
511
  }
473
512
  } else {
474
513
  console.log(chalk.gray(' Start the container:'))
@@ -188,7 +188,11 @@ depsCommand
188
188
 
189
189
  if (succeeded.length > 0) {
190
190
  console.log()
191
- console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
191
+ console.log(
192
+ success(
193
+ `Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`,
194
+ ),
195
+ )
192
196
  }
193
197
  } else if (options.engine) {
194
198
  // Install dependencies for specific engine
@@ -254,7 +258,11 @@ depsCommand
254
258
 
255
259
  if (succeeded.length > 0) {
256
260
  console.log()
257
- console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
261
+ console.log(
262
+ success(
263
+ `Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`,
264
+ ),
265
+ )
258
266
  }
259
267
  } else {
260
268
  // Default: install PostgreSQL dependencies (most common use case)
@@ -278,7 +286,10 @@ depsCommand
278
286
  const spinner = createSpinner('Installing PostgreSQL dependencies...')
279
287
  spinner.start()
280
288
 
281
- const results = await installEngineDependencies('postgresql', packageManager)
289
+ const results = await installEngineDependencies(
290
+ 'postgresql',
291
+ packageManager,
292
+ )
282
293
 
283
294
  const succeeded = results.filter((r) => r.success)
284
295
  const failed = results.filter((r) => !r.success)
@@ -295,7 +306,11 @@ depsCommand
295
306
 
296
307
  if (succeeded.length > 0) {
297
308
  console.log()
298
- console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
309
+ console.log(
310
+ success(
311
+ `Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`,
312
+ ),
313
+ )
299
314
  }
300
315
  }
301
316
  })