spindb 0.22.0 → 0.24.0

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 (49) hide show
  1. package/README.md +43 -11
  2. package/cli/bin.ts +2 -0
  3. package/cli/commands/create.ts +108 -135
  4. package/cli/commands/delete.ts +27 -23
  5. package/cli/commands/doctor.ts +281 -4
  6. package/cli/commands/engines.ts +180 -1
  7. package/cli/commands/menu/backup-handlers.ts +181 -167
  8. package/cli/commands/menu/container-handlers.ts +126 -116
  9. package/cli/commands/menu/engine-handlers.ts +64 -19
  10. package/cli/commands/menu/index.ts +66 -26
  11. package/cli/commands/menu/shared.ts +3 -2
  12. package/cli/commands/menu/shell-handlers.ts +65 -8
  13. package/cli/commands/menu/sql-handlers.ts +37 -13
  14. package/cli/commands/menu/update-handlers.ts +3 -3
  15. package/cli/commands/start.ts +16 -21
  16. package/cli/constants.ts +39 -4
  17. package/cli/helpers.ts +141 -0
  18. package/cli/ui/prompts.ts +190 -30
  19. package/config/backup-formats.ts +34 -0
  20. package/config/engine-defaults.ts +26 -0
  21. package/config/engines.json +32 -0
  22. package/core/config-manager.ts +15 -0
  23. package/core/dependency-manager.ts +4 -0
  24. package/core/error-handler.ts +81 -0
  25. package/core/fs-error-utils.ts +6 -3
  26. package/core/port-manager.ts +5 -1
  27. package/core/spawn-utils.ts +2 -0
  28. package/core/test-cleanup.ts +108 -0
  29. package/core/version-migration.ts +335 -0
  30. package/engines/couchdb/api-client.ts +81 -0
  31. package/engines/couchdb/backup.ts +137 -0
  32. package/engines/couchdb/binary-manager.ts +84 -0
  33. package/engines/couchdb/binary-urls.ts +115 -0
  34. package/engines/couchdb/hostdb-releases.ts +23 -0
  35. package/engines/couchdb/index.ts +1147 -0
  36. package/engines/couchdb/restore.ts +289 -0
  37. package/engines/couchdb/version-maps.ts +78 -0
  38. package/engines/couchdb/version-validator.ts +111 -0
  39. package/engines/ferretdb/backup.ts +165 -0
  40. package/engines/ferretdb/binary-manager.ts +897 -0
  41. package/engines/ferretdb/binary-urls.ts +138 -0
  42. package/engines/ferretdb/index.ts +1108 -0
  43. package/engines/ferretdb/restore.ts +345 -0
  44. package/engines/ferretdb/version-maps.ts +124 -0
  45. package/engines/index.ts +34 -12
  46. package/engines/mongodb/hostdb-releases.ts +0 -1
  47. package/engines/mongodb/index.ts +10 -13
  48. package/package.json +2 -1
  49. package/types/index.ts +22 -0
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  **One CLI for all your local databases.**
9
9
 
10
- SpinDB is a universal database management tool that combines a package manager, a unified API, and native client tooling for 11 different database engines—all from a single command-line interface. No Docker, no VMs, no platform-specific installers. Just databases, running natively on your machine.
10
+ SpinDB is a universal database management tool that combines a package manager, a unified API, and native client tooling for 13 different database engines—all from a single command-line interface. No Docker, no VMs, no platform-specific installers. Just databases, running natively on your machine.
11
11
 
12
12
  ```bash
13
13
  npm install -g spindb
@@ -48,7 +48,7 @@ One consistent interface across SQL databases, document stores, key-value stores
48
48
 
49
49
  ```bash
50
50
  # Same commands work for ANY database
51
- spindb create mydb --engine [postgresql|mysql|mariadb|mongodb|redis|valkey|clickhouse|sqlite|duckdb|qdrant|meilisearch]
51
+ spindb create mydb --engine [postgresql|mysql|mariadb|mongodb|ferretdb|redis|valkey|clickhouse|sqlite|duckdb|qdrant|meilisearch|couchdb]
52
52
  spindb start mydb
53
53
  spindb connect mydb
54
54
  spindb backup mydb
@@ -70,7 +70,7 @@ spindb run mydb -c "SELECT * FROM system.tables" # ClickHouse
70
70
 
71
71
  ## Platform Coverage
72
72
 
73
- SpinDB works across **11 database engines** and **5 platform architectures** with a **single, consistent API**.
73
+ SpinDB works across **13 database engines** and **5 platform architectures** with a **single, consistent API**.
74
74
 
75
75
  | Database | macOS ARM64 | macOS Intel | Linux x64 | Linux ARM64 | Windows x64 |
76
76
  |----------|:-----------:|:-----------:|:---------:|:-----------:|:-----------:|
@@ -80,13 +80,15 @@ SpinDB works across **11 database engines** and **5 platform architectures** wit
80
80
  | 🪶 **SQLite** | ✅ | ✅ | ✅ | ✅ | ✅ |
81
81
  | 🦆 **DuckDB** | ✅ | ✅ | ✅ | ✅ | ✅ |
82
82
  | 🍃 **MongoDB** | ✅ | ✅ | ✅ | ✅ | ✅ |
83
+ | 🦔 **FerretDB** | ✅ | ✅ | ✅ | ✅ | ❌ |
83
84
  | 🔴 **Redis** | ✅ | ✅ | ✅ | ✅ | ✅ |
84
85
  | 🔷 **Valkey** | ✅ | ✅ | ✅ | ✅ | ✅ |
85
86
  | 🏠 **ClickHouse** | ✅ | ✅ | ✅ | ✅ | ❌ |
86
87
  | 🧭 **Qdrant** | ✅ | ✅ | ✅ | ✅ | ✅ |
87
88
  | 🔍 **Meilisearch** | ✅ | ✅ | ✅ | ✅ | ✅ |
89
+ | 🛋 **CouchDB** | ✅ | ✅ | ✅ | ✅ | ✅ |
88
90
 
89
- **54 combinations. One CLI. Zero configuration.**
91
+ **63 combinations. One CLI. Zero configuration.**
90
92
 
91
93
  ---
92
94
 
@@ -165,7 +167,7 @@ SpinDB runs databases as **native processes** with **isolated data directories**
165
167
  | Feature | SpinDB | Docker | DBngin | Postgres.app | XAMPP |
166
168
  |---------|--------|--------|--------|--------------|-------|
167
169
  | No Docker required | ✅ | ❌ | ✅ | ✅ | ✅ |
168
- | Multiple DB engines | ✅ 11 engines | ✅ Unlimited | ✅ 3 engines | ❌ PostgreSQL only | ⚠️ MySQL only |
170
+ | Multiple DB engines | ✅ 13 engines | ✅ Unlimited | ✅ 3 engines | ❌ PostgreSQL only | ⚠️ MySQL only |
169
171
  | CLI-first | ✅ | ✅ | ❌ GUI-first | ❌ GUI-first | ❌ GUI-first |
170
172
  | Multiple versions | ✅ | ✅ | ✅ | ✅ | ❌ |
171
173
  | Clone databases | ✅ | Manual | ✅ | ❌ | ❌ |
@@ -179,7 +181,7 @@ SpinDB runs databases as **native processes** with **isolated data directories**
179
181
 
180
182
  ## Supported Databases
181
183
 
182
- SpinDB supports **11 database engines** with **multiple versions** for each:
184
+ SpinDB supports **13 database engines** with **multiple versions** for each:
183
185
 
184
186
  | Engine | Type | Versions | Default Port | Query Language |
185
187
  |--------|------|----------|--------------|----------------|
@@ -189,15 +191,17 @@ SpinDB supports **11 database engines** with **multiple versions** for each:
189
191
  | 🪶 **SQLite** | Embedded (SQL) | 3 | N/A (file-based) | SQL |
190
192
  | 🦆 **DuckDB** | Embedded OLAP | 1.4.3 | N/A (file-based) | SQL |
191
193
  | 🍃 **MongoDB** | Document Store | 7.0, 8.0, 8.2 | 27017 | JavaScript (mongosh) |
194
+ | 🦔 **FerretDB** | Document Store | 2 | 27017 | JavaScript (mongosh) |
192
195
  | 🔴 **Redis** | Key-Value Store | 7, 8 | 6379 | Redis commands |
193
196
  | 🔷 **Valkey** | Key-Value Store | 8, 9 | 6379 | Redis commands |
194
197
  | 🏠 **ClickHouse** | Columnar OLAP | 25.12 | 9000 (TCP), 8123 (HTTP) | SQL (ClickHouse dialect) |
195
198
  | 🧭 **Qdrant** | Vector Search | 1 | 6333 (HTTP), 6334 (gRPC) | REST API |
196
199
  | 🔍 **Meilisearch** | Full-Text Search | 1 | 7700 | REST API |
200
+ | 🛋 **CouchDB** | Document Store | 3 | 5984 | REST API |
197
201
 
198
202
  ### Engine Categories
199
203
 
200
- **Server-Based Databases** (PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Valkey, ClickHouse, Qdrant, Meilisearch):
204
+ **Server-Based Databases** (PostgreSQL, MySQL, MariaDB, MongoDB, FerretDB, Redis, Valkey, ClickHouse, Qdrant, Meilisearch, CouchDB):
201
205
  - Start/stop server processes
202
206
  - Bind to localhost ports
203
207
  - Data stored in `~/.spindb/containers/{engine}/{name}/`
@@ -337,8 +341,10 @@ spindb config show # Show current config
337
341
  spindb config detect # Re-detect tool paths
338
342
  spindb config update-check on # Enable update notifications
339
343
 
340
- # System health
344
+ # Doctor
341
345
  spindb doctor # Interactive health check
346
+ spindb doctor --fix # Auto-fix all issues
347
+ spindb doctor --dry-run # Preview fixes without applying
342
348
  spindb doctor --json # JSON output
343
349
 
344
350
  # Version management
@@ -481,6 +487,29 @@ spindb connect logs
481
487
  **Query language:** JavaScript (via `mongosh`)
482
488
  **Tools:** `mongod`, `mongosh`, `mongodump`, `mongorestore` (included)
483
489
 
490
+ ### FerretDB 🦔
491
+
492
+ ```bash
493
+ # Create FerretDB database (MongoDB-compatible, PostgreSQL backend)
494
+ spindb create docs --engine ferretdb
495
+
496
+ # Same MongoDB queries work
497
+ spindb run docs -c "db.users.insertOne({name: 'Alice'})"
498
+ spindb run docs -c "db.users.find().pretty()"
499
+
500
+ # Connect with mongosh
501
+ spindb connect docs
502
+ ```
503
+
504
+ **Version:** 2 (2.7.0)
505
+ **Platforms:** macOS, Linux (no Windows support)
506
+ **Architecture:** FerretDB proxy + PostgreSQL with DocumentDB extension
507
+ **Query language:** JavaScript (via `mongosh`)
508
+ **Backups:** Uses `pg_dump` on embedded PostgreSQL backend
509
+ **Tools:** `ferretdb`, `mongosh` (for client connections), `pg_dump`/`pg_restore` (bundled with embedded PostgreSQL)
510
+
511
+ FerretDB is a MongoDB-compatible database that stores data in PostgreSQL. It's useful when you want MongoDB's API but PostgreSQL's reliability and SQL access to your data.
512
+
484
513
  ### Redis 🔴 & Valkey 🔷
485
514
 
486
515
  ```bash
@@ -568,11 +597,13 @@ SpinDB supports enhanced database shells with auto-completion, syntax highlighti
568
597
  | SQLite | `sqlite3` | `litecli` | `usql` |
569
598
  | DuckDB | `duckdb` | - | `usql` |
570
599
  | MongoDB | `mongosh` | - | - |
600
+ | FerretDB | `mongosh` | - | - |
571
601
  | Redis | `redis-cli` | `iredis` | - |
572
602
  | Valkey | `valkey-cli` | `iredis` (compatible) | - |
573
603
  | ClickHouse | `clickhouse-client` | - | `usql` |
574
604
  | Qdrant | REST API | - | - |
575
605
  | Meilisearch | REST API | - | - |
606
+ | CouchDB | REST API | - | - |
576
607
 
577
608
  Install and use in one command:
578
609
 
@@ -723,6 +754,7 @@ spindb restore mydb --from-url "postgresql://user:pass@prod-host:5432/production
723
754
  | ClickHouse | `clickhouse://` or `http://` | `clickhouse://default:pass@host:8123/db` |
724
755
  | Qdrant | `qdrant://` or `http://` | `http://host:6333?api_key=KEY` |
725
756
  | Meilisearch | `meilisearch://` or `http://` | `http://host:7700?api_key=KEY` |
757
+ | CouchDB | `couchdb://` or `http://` | `http://user:pass@host:5984/db` |
726
758
 
727
759
  ### Multi-Version Support
728
760
 
@@ -779,7 +811,6 @@ See [TODO.md](TODO.md) for the complete roadmap.
779
811
 
780
812
  ### v1.2 - Additional Engines
781
813
  - **CockroachDB** - Distributed PostgreSQL-compatible database
782
- - **FerretDB** - MongoDB-compatible database built on PostgreSQL
783
814
 
784
815
  ### v1.3 - Advanced Features
785
816
  - Container templates for common configurations
@@ -803,7 +834,8 @@ The following engines may be added based on community interest:
803
834
 
804
835
  - **Local only** - Databases bind to `127.0.0.1`. Remote connection support planned for v1.1.
805
836
  - **ClickHouse Windows** - Not supported (hostdb doesn't build for Windows).
806
- - **Qdrant & Meilisearch** - Use REST API instead of CLI shell. Access via HTTP at the configured port.
837
+ - **FerretDB Windows** - Not supported (postgresql-documentdb has startup issues on Windows).
838
+ - **Qdrant, Meilisearch & CouchDB** - Use REST API instead of CLI shell. Access via HTTP at the configured port.
807
839
 
808
840
  ---
809
841
 
@@ -878,7 +910,7 @@ See [FEATURE.md](FEATURE.md) for adding new database engines.
878
910
 
879
911
  SpinDB is powered by:
880
912
 
881
- - **[hostdb](https://github.com/robertjbass/hostdb)** - Pre-compiled database binaries for 11 engines across all major platforms. Makes Docker-free multi-version database support possible.
913
+ - **[hostdb](https://github.com/robertjbass/hostdb)** - Pre-compiled database binaries for 14 engines across all major platforms. Makes Docker-free multi-version database support possible.
882
914
 
883
915
  ---
884
916
 
package/cli/bin.ts CHANGED
@@ -4,5 +4,7 @@ import { run } from './index'
4
4
 
5
5
  run().catch((err) => {
6
6
  console.error(err)
7
+ console.error('')
8
+ console.error('If this error persists, try running: spindb doctor --fix')
7
9
  process.exit(1)
8
10
  })
@@ -13,14 +13,14 @@ import {
13
13
  promptConfirm,
14
14
  } from '../ui/prompts'
15
15
  import { createSpinner } from '../ui/spinner'
16
- import { header, uiError, connectionBox } from '../ui/theme'
16
+ import { header, connectionBox } from '../ui/theme'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
19
  import { getMissingDependencies } from '../../core/dependency-manager'
20
20
  import { platformService } from '../../core/platform-service'
21
21
  import { startWithRetry } from '../../core/start-with-retry'
22
22
  import { TransactionManager } from '../../core/transaction-manager'
23
- import { isValidDatabaseName } from '../../core/error-handler'
23
+ import { isValidDatabaseName, exitWithError } from '../../core/error-handler'
24
24
  import { resolve } from 'path'
25
25
  import { Engine } from '../../types'
26
26
  import type { BaseEngine } from '../../engines/base-engine'
@@ -49,8 +49,10 @@ async function createSqliteContainer(
49
49
  const missingDeps = await getMissingDependencies('sqlite')
50
50
  if (missingDeps.length > 0) {
51
51
  if (json) {
52
- console.log(JSON.stringify({ error: `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}` }))
53
- process.exit(1)
52
+ return exitWithError({
53
+ message: `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
54
+ json: true,
55
+ })
54
56
  }
55
57
  depsSpinner?.warn(
56
58
  `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
@@ -60,7 +62,7 @@ async function createSqliteContainer(
60
62
  'sqlite',
61
63
  )
62
64
  if (!installed) {
63
- process.exit(1)
65
+ return exitWithError({ message: 'Required tools not installed' })
64
66
  }
65
67
  } else {
66
68
  depsSpinner?.succeed('Required tools available')
@@ -69,8 +71,10 @@ async function createSqliteContainer(
69
71
  // Check if container already exists
70
72
  if (await containerManager.exists(containerName)) {
71
73
  if (json) {
72
- console.log(JSON.stringify({ error: `Container "${containerName}" already exists` }))
73
- process.exit(1)
74
+ return exitWithError({
75
+ message: `Container "${containerName}" already exists`,
76
+ json: true,
77
+ })
74
78
  }
75
79
  while (await containerManager.exists(containerName)) {
76
80
  console.log(chalk.yellow(` Container "${containerName}" already exists.`))
@@ -84,12 +88,10 @@ async function createSqliteContainer(
84
88
 
85
89
  // Check if file already exists
86
90
  if (existsSync(absolutePath)) {
87
- if (json) {
88
- console.log(JSON.stringify({ error: `File already exists: ${absolutePath}` }))
89
- } else {
90
- console.error(uiError(`File already exists: ${absolutePath}`))
91
- }
92
- process.exit(1)
91
+ return exitWithError({
92
+ message: `File already exists: ${absolutePath}`,
93
+ json,
94
+ })
93
95
  }
94
96
 
95
97
  const createSpinnerInstance = json ? null : createSpinner('Creating SQLite database...')
@@ -196,8 +198,10 @@ async function createDuckDBContainer(
196
198
  const missingDeps = await getMissingDependencies('duckdb')
197
199
  if (missingDeps.length > 0) {
198
200
  if (json) {
199
- console.log(JSON.stringify({ error: `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}` }))
200
- process.exit(1)
201
+ return exitWithError({
202
+ message: `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
203
+ json: true,
204
+ })
201
205
  }
202
206
  depsSpinner?.warn(
203
207
  `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
@@ -207,7 +211,7 @@ async function createDuckDBContainer(
207
211
  'duckdb',
208
212
  )
209
213
  if (!installed) {
210
- process.exit(1)
214
+ return exitWithError({ message: 'Required tools not installed' })
211
215
  }
212
216
  } else {
213
217
  depsSpinner?.succeed('Required tools available')
@@ -216,8 +220,10 @@ async function createDuckDBContainer(
216
220
  // Check if container already exists
217
221
  if (await containerManager.exists(containerName)) {
218
222
  if (json) {
219
- console.log(JSON.stringify({ error: `Container "${containerName}" already exists` }))
220
- process.exit(1)
223
+ return exitWithError({
224
+ message: `Container "${containerName}" already exists`,
225
+ json: true,
226
+ })
221
227
  }
222
228
  while (await containerManager.exists(containerName)) {
223
229
  console.log(chalk.yellow(` Container "${containerName}" already exists.`))
@@ -231,12 +237,10 @@ async function createDuckDBContainer(
231
237
 
232
238
  // Check if file already exists
233
239
  if (existsSync(absolutePath)) {
234
- if (json) {
235
- console.log(JSON.stringify({ error: `File already exists: ${absolutePath}` }))
236
- } else {
237
- console.error(uiError(`File already exists: ${absolutePath}`))
238
- }
239
- process.exit(1)
240
+ return exitWithError({
241
+ message: `File already exists: ${absolutePath}`,
242
+ json,
243
+ })
240
244
  }
241
245
 
242
246
  const createSpinnerInstance = json ? null : createSpinner('Creating DuckDB database...')
@@ -379,7 +383,7 @@ export const createCommand = new Command('create')
379
383
  .argument('[name]', 'Container name')
380
384
  .option(
381
385
  '-e, --engine <engine>',
382
- 'Database engine (postgresql, mysql, mariadb, sqlite, duckdb, mongodb, redis, valkey, clickhouse, qdrant, meilisearch)',
386
+ 'Database engine (postgresql, mysql, mariadb, sqlite, duckdb, mongodb, ferretdb, redis, valkey, clickhouse, qdrant, meilisearch)',
383
387
  )
384
388
  .option('--db-version <version>', 'Database version (e.g., 17, 8.0)')
385
389
  .option('-d, --database <database>', 'Database name')
@@ -431,22 +435,10 @@ export const createCommand = new Command('create')
431
435
  const locationInfo = detectLocationType(options.from)
432
436
 
433
437
  if (locationInfo.type === 'not_found') {
434
- if (options.json) {
435
- console.log(JSON.stringify({ error: `Location not found: ${options.from}` }))
436
- } else {
437
- console.error(uiError(`Location not found: ${options.from}`))
438
- console.log(
439
- chalk.gray(
440
- ' Provide a valid file path or connection string:',
441
- ),
442
- )
443
- console.log(
444
- chalk.gray(
445
- ' postgresql://, mysql://, mongodb://, redis://, sqlite://, duckdb://',
446
- ),
447
- )
448
- }
449
- process.exit(1)
438
+ return exitWithError({
439
+ message: `Location not found: ${options.from}. Provide a valid file path or connection string (postgresql://, mysql://, redis://, sqlite://, duckdb://)`,
440
+ json: options.json,
441
+ })
450
442
  }
451
443
 
452
444
  restoreLocation = options.from
@@ -464,16 +456,10 @@ export const createCommand = new Command('create')
464
456
  }
465
457
 
466
458
  if (options.start === false) {
467
- if (options.json) {
468
- console.log(JSON.stringify({ error: 'Cannot use --no-start with --from (restore requires running container)' }))
469
- } else {
470
- console.error(
471
- uiError(
472
- 'Cannot use --no-start with --from (restore requires running container)',
473
- ),
474
- )
475
- }
476
- process.exit(1)
459
+ return exitWithError({
460
+ message: 'Cannot use --no-start with --from (restore requires running container)',
461
+ json: options.json,
462
+ })
477
463
  }
478
464
  }
479
465
 
@@ -486,8 +472,7 @@ export const createCommand = new Command('create')
486
472
  if (!containerName) {
487
473
  // JSON mode requires container name argument
488
474
  if (options.json) {
489
- console.log(JSON.stringify({ error: 'Container name is required' }))
490
- process.exit(1)
475
+ return exitWithError({ message: 'Container name is required', json: true })
491
476
  }
492
477
 
493
478
  const answers = await promptCreateOptions()
@@ -504,35 +489,26 @@ export const createCommand = new Command('create')
504
489
  // Validate Redis/Valkey database is a pure integer string 0-15
505
490
  // Reject decimals ("1.5"), scientific notation ("1e2"), and trailing garbage ("5abc")
506
491
  if (!/^[0-9]+$/.test(database)) {
507
- const errorMsg = 'Redis/Valkey database must be an integer between 0 and 15'
508
- if (options.json) {
509
- console.log(JSON.stringify({ error: errorMsg }))
510
- } else {
511
- console.error(uiError(errorMsg))
512
- }
513
- process.exit(1)
492
+ return exitWithError({
493
+ message: 'Redis/Valkey database must be an integer between 0 and 15',
494
+ json: options.json,
495
+ })
514
496
  }
515
497
  const dbIndex = parseInt(database, 10)
516
498
  if (dbIndex < 0 || dbIndex > 15) {
517
- const errorMsg = 'Redis/Valkey database must be an integer between 0 and 15'
518
- if (options.json) {
519
- console.log(JSON.stringify({ error: errorMsg }))
520
- } else {
521
- console.error(uiError(errorMsg))
522
- }
523
- process.exit(1)
499
+ return exitWithError({
500
+ message: 'Redis/Valkey database must be an integer between 0 and 15',
501
+ json: options.json,
502
+ })
524
503
  }
525
504
  } else {
526
505
  database = database ?? containerName.replace(/-/g, '_')
527
506
  // Validate database name to prevent SQL injection
528
507
  if (!isValidDatabaseName(database)) {
529
- const errorMsg = 'Database name must start with a letter and contain only letters, numbers, and underscores'
530
- if (options.json) {
531
- console.log(JSON.stringify({ error: errorMsg }))
532
- } else {
533
- console.error(uiError(errorMsg))
534
- }
535
- process.exit(1)
508
+ return exitWithError({
509
+ message: 'Database name must start with a letter and contain only letters, numbers, and underscores',
510
+ json: options.json,
511
+ })
536
512
  }
537
513
  }
538
514
 
@@ -568,33 +544,28 @@ export const createCommand = new Command('create')
568
544
 
569
545
  // For server databases, validate --connect with --no-start
570
546
  if (options.connect && options.start === false) {
571
- const errorMsg = 'Cannot use --no-start with --connect (connection requires running container)'
572
- if (options.json) {
573
- console.log(JSON.stringify({ error: errorMsg }))
574
- } else {
575
- console.error(uiError(errorMsg))
576
- }
577
- process.exit(1)
547
+ return exitWithError({
548
+ message: 'Cannot use --no-start with --connect (connection requires running container)',
549
+ json: options.json,
550
+ })
578
551
  }
579
552
 
580
553
  // In JSON mode, require explicit --start or --no-start flag to avoid interactive prompts
581
554
  if (options.json && options.start === undefined && !restoreLocation && !options.connect) {
582
- const errorMsg = 'In JSON mode, you must specify --start or --no-start for server databases'
583
- console.log(JSON.stringify({ error: errorMsg }))
584
- process.exit(1)
555
+ return exitWithError({
556
+ message: 'In JSON mode, you must specify --start or --no-start for server databases',
557
+ json: true,
558
+ })
585
559
  }
586
560
 
587
561
  // Validate --max-connections if provided
588
562
  if (options.maxConnections) {
589
563
  const parsed = parseInt(options.maxConnections, 10)
590
564
  if (!Number.isFinite(parsed) || parsed <= 0) {
591
- const errorMsg = 'Invalid --max-connections value: must be a positive integer'
592
- if (options.json) {
593
- console.log(JSON.stringify({ error: errorMsg }))
594
- } else {
595
- console.error(uiError(errorMsg))
596
- }
597
- process.exit(1)
565
+ return exitWithError({
566
+ message: 'Invalid --max-connections value: must be a positive integer',
567
+ json: options.json,
568
+ })
598
569
  }
599
570
  }
600
571
 
@@ -606,12 +577,11 @@ export const createCommand = new Command('create')
606
577
  port = parseInt(options.port, 10)
607
578
  const available = await portManager.isPortAvailable(port)
608
579
  if (!available) {
609
- if (options.json) {
610
- console.log(JSON.stringify({ error: `Port ${port} is already in use` }))
611
- } else {
612
- portSpinner?.fail(`Port ${port} is already in use`)
613
- }
614
- process.exit(1)
580
+ portSpinner?.fail(`Port ${port} is already in use`)
581
+ return exitWithError({
582
+ message: `Port ${port} is already in use`,
583
+ json: options.json,
584
+ })
615
585
  }
616
586
  portSpinner?.succeed(`Using port ${port}`)
617
587
  } else {
@@ -661,10 +631,10 @@ export const createCommand = new Command('create')
661
631
  if (missingDeps.length > 0) {
662
632
  // In JSON mode, error out instead of prompting
663
633
  if (options.json) {
664
- console.log(JSON.stringify({
665
- error: `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
666
- }))
667
- process.exit(1)
634
+ return exitWithError({
635
+ message: `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
636
+ json: true,
637
+ })
668
638
  }
669
639
 
670
640
  depsSpinner?.warn(
@@ -677,17 +647,14 @@ export const createCommand = new Command('create')
677
647
  )
678
648
 
679
649
  if (!installed) {
680
- process.exit(1)
650
+ return exitWithError({ message: 'Required tools not installed' })
681
651
  }
682
652
 
683
653
  missingDeps = await getMissingDependencies(engine)
684
654
  if (missingDeps.length > 0) {
685
- console.error(
686
- uiError(
687
- `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
688
- ),
689
- )
690
- process.exit(1)
655
+ return exitWithError({
656
+ message: `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
657
+ })
691
658
  }
692
659
 
693
660
  console.log(chalk.green(' ✓ All required tools are now available'))
@@ -717,23 +684,23 @@ export const createCommand = new Command('create')
717
684
  )
718
685
  binarySpinner?.succeed(`${dbEngine.displayName} ${version} binaries ready`)
719
686
  } catch (error) {
687
+ binarySpinner?.fail(`${dbEngine.displayName} ${version} not available`)
720
688
  if (options.json) {
721
- console.log(JSON.stringify({
722
- error: `${dbEngine.displayName} ${version} not available`,
723
- }))
724
- process.exit(1)
689
+ return exitWithError({
690
+ message: `${dbEngine.displayName} ${version} not available`,
691
+ json: true,
692
+ })
725
693
  }
726
- binarySpinner?.fail(`${dbEngine.displayName} ${version} not available`)
727
694
  throw error
728
695
  }
729
696
  }
730
697
 
731
698
  if (await containerManager.exists(containerName)) {
732
699
  if (options.json) {
733
- console.log(JSON.stringify({
734
- error: `Container "${containerName}" already exists`,
735
- }))
736
- process.exit(1)
700
+ return exitWithError({
701
+ message: `Container "${containerName}" already exists`,
702
+ json: true,
703
+ })
737
704
  }
738
705
  while (await containerManager.exists(containerName)) {
739
706
  console.log(
@@ -798,8 +765,14 @@ export const createCommand = new Command('create')
798
765
  } else if (options.start === false) {
799
766
  shouldStart = false
800
767
  } else {
801
- console.log()
802
- shouldStart = await promptConfirm(`Start ${containerName} now?`, true)
768
+ // In non-interactive mode (no TTY), default to not starting
769
+ // This allows scripts/CI to run without --no-start flag
770
+ if (!process.stdin.isTTY) {
771
+ shouldStart = false
772
+ } else {
773
+ console.log()
774
+ shouldStart = await promptConfirm(`Start ${containerName} now?`, true)
775
+ }
803
776
  }
804
777
 
805
778
  const config = await containerManager.getConfig(containerName)
@@ -918,23 +891,29 @@ export const createCommand = new Command('create')
918
891
  e.message.includes('pg_dump not found') ||
919
892
  e.message.includes('ENOENT')
920
893
  ) {
894
+ // In JSON mode, don't prompt - just exit with error
895
+ if (options.json) {
896
+ return exitWithError({ message: 'pg_dump not installed', json: true })
897
+ }
921
898
  const installed = await promptInstallDependencies('pg_dump')
922
899
  if (!installed) {
923
- process.exit(1)
900
+ return exitWithError({ message: 'pg_dump not installed', json: options.json })
924
901
  }
925
902
  continue
926
903
  }
927
904
 
928
- console.log()
929
- console.error(uiError('pg_dump error:'))
930
- console.log(chalk.gray(` ${e.message}`))
931
- process.exit(1)
905
+ return exitWithError({
906
+ message: `pg_dump error: ${e.message}`,
907
+ json: options.json,
908
+ })
932
909
  }
933
910
  }
934
911
 
935
912
  if (!dumpSuccess) {
936
- console.error(uiError('Failed to create dump after retries'))
937
- process.exit(1)
913
+ return exitWithError({
914
+ message: 'Failed to create dump after retries',
915
+ json: options.json,
916
+ })
938
917
  }
939
918
  } else {
940
919
  backupPath = restoreLocation
@@ -1049,8 +1028,7 @@ export const createCommand = new Command('create')
1049
1028
 
1050
1029
  if (matchingPattern) {
1051
1030
  if (options.json) {
1052
- console.log(JSON.stringify({ error: e.message }))
1053
- process.exit(1)
1031
+ return exitWithError({ message: e.message, json: true })
1054
1032
  }
1055
1033
  const missingTool = matchingPattern.replace(' not found', '')
1056
1034
  const installed = await promptInstallDependencies(missingTool)
@@ -1059,15 +1037,10 @@ export const createCommand = new Command('create')
1059
1037
  chalk.yellow(' Please re-run your command to continue.'),
1060
1038
  )
1061
1039
  }
1062
- process.exit(1)
1040
+ return exitWithError({ message: 'Missing required tools', json: options.json })
1063
1041
  }
1064
1042
 
1065
- if (options.json) {
1066
- console.log(JSON.stringify({ error: e.message }))
1067
- } else {
1068
- console.error(uiError(e.message))
1069
- }
1070
- process.exit(1)
1043
+ return exitWithError({ message: e.message, json: options.json })
1071
1044
  } finally {
1072
1045
  if (tempDumpPath) {
1073
1046
  try {