spindb 0.9.2 → 0.10.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/spindb.svg)](https://www.npmjs.com/package/spindb)
4
4
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](LICENSE)
5
- [![Platform: macOS | Linux](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux-lightgrey.svg)](#supported-platforms)
5
+ [![Platform: macOS | Linux | Windows](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#supported-platforms)
6
6
 
7
7
  **Local databases without the Docker baggage.**
8
8
 
@@ -140,9 +140,11 @@ That's it. Your database is running on `localhost:5432`, and your data persists
140
140
  | Versions | 14, 15, 16, 17 |
141
141
  | Default port | 5432 |
142
142
  | Default user | `postgres` |
143
- | Binary source | [zonky.io](https://github.com/zonkyio/embedded-postgres-binaries) |
143
+ | Binary source | [zonky.io](https://github.com/zonkyio/embedded-postgres-binaries) (macOS/Linux), [EDB](https://www.enterprisedb.com/) (Windows) |
144
144
 
145
- SpinDB downloads PostgreSQL server binaries automatically. These are pre-compiled binaries from the zonky.io project, hosted on Maven Central. They're extracted from official PostgreSQL distributions and work on macOS and Linux (x64 and ARM64).
145
+ SpinDB downloads PostgreSQL server binaries automatically:
146
+ - **macOS/Linux:** Pre-compiled binaries from the zonky.io project, hosted on Maven Central
147
+ - **Windows:** Official binaries from EnterpriseDB (EDB)
146
148
 
147
149
  **Why download binaries instead of using system PostgreSQL?** You might want PostgreSQL 14 for one project and 17 for another. SpinDB lets you run different versions side-by-side without conflicts.
148
150
 
@@ -170,6 +172,12 @@ brew install mysql
170
172
  # Ubuntu/Debian
171
173
  sudo apt install mysql-server
172
174
 
175
+ # Windows (Chocolatey)
176
+ choco install mysql
177
+
178
+ # Windows (winget)
179
+ winget install Oracle.MySQL
180
+
173
181
  # Check if SpinDB can find MySQL
174
182
  spindb deps check --engine mysql
175
183
  ```
@@ -561,11 +569,11 @@ When you stop a container:
561
569
 
562
570
  ### Binary Sources
563
571
 
564
- **PostgreSQL:** Server binaries are downloaded from [zonky.io/embedded-postgres-binaries](https://github.com/zonkyio/embedded-postgres-binaries), a project that packages official PostgreSQL releases for embedding in applications. The binaries are hosted on Maven Central and support:
565
- - macOS (Apple Silicon and Intel)
566
- - Linux (x64 and ARM64)
572
+ **PostgreSQL:** Server binaries are downloaded automatically:
573
+ - **macOS/Linux:** From [zonky.io/embedded-postgres-binaries](https://github.com/zonkyio/embedded-postgres-binaries), hosted on Maven Central
574
+ - **Windows:** From [EnterpriseDB (EDB)](https://www.enterprisedb.com/download-postgresql-binaries), official PostgreSQL distributions
567
575
 
568
- **MySQL:** Uses your system's MySQL installation. SpinDB detects binaries from Homebrew, apt, pacman, or custom paths.
576
+ **MySQL:** Uses your system's MySQL installation. SpinDB detects binaries from Homebrew (macOS), apt/pacman (Linux), or Chocolatey/winget/Scoop (Windows).
569
577
 
570
578
  ---
571
579
 
@@ -577,15 +585,14 @@ When you stop a container:
577
585
  | macOS | Intel (x64) | ✅ Supported |
578
586
  | Linux | x64 | ✅ Supported |
579
587
  | Linux | ARM64 | ✅ Supported |
580
- | Windows | Any | Not supported |
588
+ | Windows | x64 | Supported |
581
589
 
582
- **Why no Windows?** The zonky.io project doesn't provide Windows binaries for PostgreSQL. Windows support would require a different binary source and significant testing.
590
+ Windows uses EnterpriseDB (EDB) official binaries for PostgreSQL. MySQL and SQLite require system installations via Chocolatey, winget, or Scoop.
583
591
 
584
592
  ---
585
593
 
586
594
  ## Limitations
587
595
 
588
- - **No Windows support** - zonky.io doesn't provide Windows PostgreSQL binaries
589
596
  - **Client tools required** - `psql` and `mysql` must be installed separately for some operations
590
597
  - **Local only** - Databases bind to `127.0.0.1`; remote connections planned for v1.1
591
598
  - **MySQL requires system install** - Unlike PostgreSQL, we don't download MySQL binaries
@@ -653,16 +660,15 @@ rm -rf ~/.spindb
653
660
 
654
661
  ## Contributing
655
662
 
656
- See [CLAUDE.md](CLAUDE.md) for development setup and architecture documentation.
663
+ Note: This repo currently assumes `pnpm` for running tests. `npm test` will shell out to `pnpm` and fail if `pnpm` isn't installed.
657
664
 
658
- ### Running Tests
665
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and distribution info.
659
666
 
660
- ```bash
661
- pnpm test # All tests
662
- pnpm test:unit # Unit tests only
663
- pnpm test:pg # PostgreSQL integration
664
- pnpm test:mysql # MySQL integration
665
- ```
667
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for project architecture and comprehensive CLI command examples.
668
+
669
+ See [CLAUDE.md](CLAUDE.md) for AI-assisted development context.
670
+
671
+ See [ENGINES.md](ENGINES.md) for detailed engine documentation (backup formats, planned engines, etc.).
666
672
 
667
673
  ---
668
674
 
@@ -0,0 +1,108 @@
1
+ import { Command } from 'commander'
2
+ import { existsSync } from 'fs'
3
+ import { resolve, basename } from 'path'
4
+ import chalk from 'chalk'
5
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
6
+ import { containerManager } from '../../core/container-manager'
7
+ import { deriveContainerName } from '../../engines/sqlite/scanner'
8
+ import { uiSuccess, uiError } from '../ui/theme'
9
+
10
+ export const attachCommand = new Command('attach')
11
+ .description('Register an existing SQLite database with SpinDB')
12
+ .argument('<path>', 'Path to SQLite database file')
13
+ .option('-n, --name <name>', 'Container name (defaults to filename)')
14
+ .option('--json', 'Output as JSON')
15
+ .action(
16
+ async (
17
+ path: string,
18
+ options: { name?: string; json?: boolean },
19
+ ): Promise<void> => {
20
+ try {
21
+ const absolutePath = resolve(path)
22
+
23
+ // Verify file exists
24
+ if (!existsSync(absolutePath)) {
25
+ if (options.json) {
26
+ console.log(
27
+ JSON.stringify({ success: false, error: 'File not found' }),
28
+ )
29
+ } else {
30
+ console.error(uiError(`File not found: ${absolutePath}`))
31
+ }
32
+ process.exit(1)
33
+ }
34
+
35
+ // Check if already registered
36
+ if (await sqliteRegistry.isPathRegistered(absolutePath)) {
37
+ const entry = await sqliteRegistry.getByPath(absolutePath)
38
+ if (options.json) {
39
+ console.log(
40
+ JSON.stringify({
41
+ success: false,
42
+ error: 'Already registered',
43
+ existingName: entry?.name,
44
+ }),
45
+ )
46
+ } else {
47
+ console.error(
48
+ uiError(`File is already registered as "${entry?.name}"`),
49
+ )
50
+ }
51
+ process.exit(1)
52
+ }
53
+
54
+ // Determine container name
55
+ const containerName =
56
+ options.name || deriveContainerName(basename(absolutePath))
57
+
58
+ // Check if container name exists
59
+ if (await containerManager.exists(containerName)) {
60
+ if (options.json) {
61
+ console.log(
62
+ JSON.stringify({
63
+ success: false,
64
+ error: 'Container name already exists',
65
+ }),
66
+ )
67
+ } else {
68
+ console.error(uiError(`Container "${containerName}" already exists`))
69
+ }
70
+ process.exit(1)
71
+ }
72
+
73
+ // Register the file
74
+ await sqliteRegistry.add({
75
+ name: containerName,
76
+ filePath: absolutePath,
77
+ created: new Date().toISOString(),
78
+ })
79
+
80
+ if (options.json) {
81
+ console.log(
82
+ JSON.stringify({
83
+ success: true,
84
+ name: containerName,
85
+ filePath: absolutePath,
86
+ }),
87
+ )
88
+ } else {
89
+ console.log(
90
+ uiSuccess(
91
+ `Registered "${basename(absolutePath)}" as "${containerName}"`,
92
+ ),
93
+ )
94
+ console.log()
95
+ console.log(chalk.gray(' Connect with:'))
96
+ console.log(chalk.cyan(` spindb connect ${containerName}`))
97
+ }
98
+ } catch (error) {
99
+ const e = error as Error
100
+ if (options.json) {
101
+ console.log(JSON.stringify({ success: false, error: e.message }))
102
+ } else {
103
+ console.error(uiError(e.message))
104
+ }
105
+ process.exit(1)
106
+ }
107
+ },
108
+ )
@@ -100,6 +100,12 @@ async function createSqliteContainer(
100
100
  restoreSpinner.succeed('Backup restored successfully')
101
101
  } catch (error) {
102
102
  restoreSpinner.fail('Failed to restore backup')
103
+ // Clean up the created container on restore failure
104
+ try {
105
+ await containerManager.delete(containerName, { force: true })
106
+ } catch {
107
+ // Ignore cleanup errors - still throw the original restore error
108
+ }
103
109
  throw error
104
110
  }
105
111
  }
@@ -282,6 +288,7 @@ export const createCommand = new Command('create')
282
288
  console.log()
283
289
 
284
290
  const dbEngine = getEngine(engine)
291
+ const isPostgreSQL = engine === Engine.PostgreSQL
285
292
 
286
293
  // SQLite has a simplified flow (no port, no start/stop)
287
294
  if (engine === Engine.SQLite) {
@@ -316,6 +323,60 @@ export const createCommand = new Command('create')
316
323
  }
317
324
  }
318
325
 
326
+ const portSpinner = createSpinner('Finding available port...')
327
+ portSpinner.start()
328
+
329
+ let port: number
330
+ if (options.port) {
331
+ port = parseInt(options.port, 10)
332
+ const available = await portManager.isPortAvailable(port)
333
+ if (!available) {
334
+ portSpinner.fail(`Port ${port} is already in use`)
335
+ process.exit(1)
336
+ }
337
+ portSpinner.succeed(`Using port ${port}`)
338
+ } else {
339
+ const { port: foundPort, isDefault } =
340
+ await portManager.findAvailablePort({
341
+ preferredPort: engineDefaults.defaultPort,
342
+ portRange: engineDefaults.portRange,
343
+ })
344
+ port = foundPort
345
+ if (isDefault) {
346
+ portSpinner.succeed(`Using default port ${port}`)
347
+ } else {
348
+ portSpinner.warn(
349
+ `Default port ${engineDefaults.defaultPort} is in use, using port ${port}`,
350
+ )
351
+ }
352
+ }
353
+
354
+ // For PostgreSQL, download binaries FIRST - they include client tools (psql, pg_dump, etc.)
355
+ // This avoids requiring a separate system installation of client tools
356
+ if (isPostgreSQL) {
357
+ const binarySpinner = createSpinner(
358
+ `Checking ${dbEngine.displayName} ${version} binaries...`,
359
+ )
360
+ binarySpinner.start()
361
+
362
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
363
+ if (isInstalled) {
364
+ binarySpinner.succeed(
365
+ `${dbEngine.displayName} ${version} binaries ready (cached)`,
366
+ )
367
+ } else {
368
+ binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
369
+ await dbEngine.ensureBinaries(version, ({ message }) => {
370
+ binarySpinner.text = message
371
+ })
372
+ binarySpinner.succeed(
373
+ `${dbEngine.displayName} ${version} binaries downloaded`,
374
+ )
375
+ }
376
+ }
377
+
378
+ // Check dependencies (all engines need this)
379
+ // For PostgreSQL, this runs AFTER binary download so client tools are available
319
380
  const depsSpinner = createSpinner('Checking required tools...')
320
381
  depsSpinner.start()
321
382
 
@@ -350,54 +411,29 @@ export const createCommand = new Command('create')
350
411
  depsSpinner.succeed('Required tools available')
351
412
  }
352
413
 
353
- const portSpinner = createSpinner('Finding available port...')
354
- portSpinner.start()
414
+ // For MySQL (and other non-PostgreSQL server DBs), download binaries after dep check
415
+ if (!isPostgreSQL) {
416
+ const binarySpinner = createSpinner(
417
+ `Checking ${dbEngine.displayName} ${version} binaries...`,
418
+ )
419
+ binarySpinner.start()
355
420
 
356
- let port: number
357
- if (options.port) {
358
- port = parseInt(options.port, 10)
359
- const available = await portManager.isPortAvailable(port)
360
- if (!available) {
361
- portSpinner.fail(`Port ${port} is already in use`)
362
- process.exit(1)
363
- }
364
- portSpinner.succeed(`Using port ${port}`)
365
- } else {
366
- const { port: foundPort, isDefault } =
367
- await portManager.findAvailablePort({
368
- preferredPort: engineDefaults.defaultPort,
369
- portRange: engineDefaults.portRange,
370
- })
371
- port = foundPort
372
- if (isDefault) {
373
- portSpinner.succeed(`Using default port ${port}`)
421
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
422
+ if (isInstalled) {
423
+ binarySpinner.succeed(
424
+ `${dbEngine.displayName} ${version} binaries ready (cached)`,
425
+ )
374
426
  } else {
375
- portSpinner.warn(
376
- `Default port ${engineDefaults.defaultPort} is in use, using port ${port}`,
427
+ binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
428
+ await dbEngine.ensureBinaries(version, ({ message }) => {
429
+ binarySpinner.text = message
430
+ })
431
+ binarySpinner.succeed(
432
+ `${dbEngine.displayName} ${version} binaries downloaded`,
377
433
  )
378
434
  }
379
435
  }
380
436
 
381
- const binarySpinner = createSpinner(
382
- `Checking ${dbEngine.displayName} ${version} binaries...`,
383
- )
384
- binarySpinner.start()
385
-
386
- const isInstalled = await dbEngine.isBinaryInstalled(version)
387
- if (isInstalled) {
388
- binarySpinner.succeed(
389
- `${dbEngine.displayName} ${version} binaries ready (cached)`,
390
- )
391
- } else {
392
- binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
393
- await dbEngine.ensureBinaries(version, ({ message }) => {
394
- binarySpinner.text = message
395
- })
396
- binarySpinner.succeed(
397
- `${dbEngine.displayName} ${version} binaries downloaded`,
398
- )
399
- }
400
-
401
437
  while (await containerManager.exists(containerName)) {
402
438
  console.log(
403
439
  chalk.yellow(` Container "${containerName}" already exists.`),
@@ -0,0 +1,100 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
4
+ import { containerManager } from '../../core/container-manager'
5
+ import { promptConfirm } from '../ui/prompts'
6
+ import { uiSuccess, uiError, uiWarning } from '../ui/theme'
7
+ import { Engine } from '../../types'
8
+
9
+ export const detachCommand = new Command('detach')
10
+ .description('Unregister a SQLite database from SpinDB (keeps file on disk)')
11
+ .argument('<name>', 'Container name')
12
+ .option('-f, --force', 'Skip confirmation prompt')
13
+ .option('--json', 'Output as JSON')
14
+ .action(
15
+ async (
16
+ name: string,
17
+ options: { force?: boolean; json?: boolean },
18
+ ): Promise<void> => {
19
+ try {
20
+ // Get container config
21
+ const config = await containerManager.getConfig(name)
22
+
23
+ if (!config) {
24
+ if (options.json) {
25
+ console.log(
26
+ JSON.stringify({ success: false, error: 'Container not found' }),
27
+ )
28
+ } else {
29
+ console.error(uiError(`Container "${name}" not found`))
30
+ }
31
+ process.exit(1)
32
+ }
33
+
34
+ // Verify it's a SQLite container
35
+ if (config.engine !== Engine.SQLite) {
36
+ if (options.json) {
37
+ console.log(
38
+ JSON.stringify({
39
+ success: false,
40
+ error:
41
+ 'Not a SQLite container. Use "spindb delete" for server databases.',
42
+ }),
43
+ )
44
+ } else {
45
+ console.error(uiError(`"${name}" is not a SQLite container`))
46
+ console.log(
47
+ chalk.gray(
48
+ ' Use "spindb delete" for server databases (PostgreSQL, MySQL)',
49
+ ),
50
+ )
51
+ }
52
+ process.exit(1)
53
+ }
54
+
55
+ // Confirm unless --force
56
+ if (!options.force && !options.json) {
57
+ const confirmed = await promptConfirm(
58
+ `Detach "${name}" from SpinDB? (file will be kept on disk)`,
59
+ true,
60
+ )
61
+ if (!confirmed) {
62
+ console.log(uiWarning('Cancelled'))
63
+ return
64
+ }
65
+ }
66
+
67
+ const entry = await sqliteRegistry.get(name)
68
+ const filePath = entry?.filePath
69
+
70
+ // Remove from registry only (not the file)
71
+ await sqliteRegistry.remove(name)
72
+
73
+ if (options.json) {
74
+ console.log(
75
+ JSON.stringify({
76
+ success: true,
77
+ name,
78
+ filePath,
79
+ }),
80
+ )
81
+ } else {
82
+ console.log(uiSuccess(`Detached "${name}" from SpinDB`))
83
+ if (filePath) {
84
+ console.log(chalk.gray(` File remains at: ${filePath}`))
85
+ }
86
+ console.log()
87
+ console.log(chalk.gray(' Re-attach with:'))
88
+ console.log(chalk.cyan(` spindb attach ${filePath || '<path>'}`))
89
+ }
90
+ } catch (error) {
91
+ const e = error as Error
92
+ if (options.json) {
93
+ console.log(JSON.stringify({ success: false, error: e.message }))
94
+ } else {
95
+ console.error(uiError(e.message))
96
+ }
97
+ process.exit(1)
98
+ }
99
+ },
100
+ )
@@ -141,8 +141,9 @@ async function checkContainers(): Promise<HealthCheckResult> {
141
141
  async function checkSqliteRegistry(): Promise<HealthCheckResult> {
142
142
  try {
143
143
  const entries = await sqliteRegistry.list()
144
+ const ignoredFolders = await sqliteRegistry.listIgnoredFolders()
144
145
 
145
- if (entries.length === 0) {
146
+ if (entries.length === 0 && ignoredFolders.length === 0) {
146
147
  return {
147
148
  name: 'SQLite Registry',
148
149
  status: 'ok',
@@ -153,11 +154,18 @@ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
153
154
  const orphans = await sqliteRegistry.findOrphans()
154
155
 
155
156
  if (orphans.length > 0) {
157
+ const details = [
158
+ ...orphans.map((o) => `"${o.name}" → ${o.filePath}`),
159
+ ...(ignoredFolders.length > 0
160
+ ? [`${ignoredFolders.length} folder(s) ignored`]
161
+ : []),
162
+ ]
163
+
156
164
  return {
157
165
  name: 'SQLite Registry',
158
166
  status: 'warning',
159
167
  message: `${orphans.length} orphaned entr${orphans.length === 1 ? 'y' : 'ies'} found`,
160
- details: orphans.map((o) => `"${o.name}" → ${o.filePath}`),
168
+ details,
161
169
  action: {
162
170
  label: 'Remove orphaned entries from registry',
163
171
  handler: async () => {
@@ -168,10 +176,16 @@ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
168
176
  }
169
177
  }
170
178
 
179
+ const details = [`${entries.length} database(s) registered, all files exist`]
180
+ if (ignoredFolders.length > 0) {
181
+ details.push(`${ignoredFolders.length} folder(s) ignored`)
182
+ }
183
+
171
184
  return {
172
185
  name: 'SQLite Registry',
173
186
  status: 'ok',
174
187
  message: `${entries.length} database(s) registered, all files exist`,
188
+ details: ignoredFolders.length > 0 ? details : undefined,
175
189
  }
176
190
  } catch (error) {
177
191
  return {
@@ -521,6 +521,11 @@ export const editCommand = new Command('edit')
521
521
  spinner.start()
522
522
 
523
523
  try {
524
+ // Track if we need to delete source file after registry update
525
+ // (for cross-device moves where rename doesn't work)
526
+ let needsSourceCleanup = false
527
+ const originalPath = config.database
528
+
524
529
  // Try rename first (fast, same filesystem)
525
530
  try {
526
531
  renameSync(config.database, newPath)
@@ -531,8 +536,8 @@ export const editCommand = new Command('edit')
531
536
  try {
532
537
  // Copy file preserving mode/permissions
533
538
  copyFileSync(config.database, newPath)
534
- // Only delete source after successful copy
535
- unlinkSync(config.database)
539
+ // Don't delete source yet - wait for registry update to succeed
540
+ needsSourceCleanup = true
536
541
  } catch (copyErr) {
537
542
  // Clean up partial target on failure
538
543
  if (existsSync(newPath)) {
@@ -555,6 +560,11 @@ export const editCommand = new Command('edit')
555
560
  })
556
561
  await sqliteRegistry.update(containerName, { filePath: newPath })
557
562
 
563
+ // Now safe to delete source file for cross-device moves
564
+ if (needsSourceCleanup && existsSync(originalPath)) {
565
+ unlinkSync(originalPath)
566
+ }
567
+
558
568
  spinner.succeed(`Database relocated to ${newPath}`)
559
569
  } catch (error) {
560
570
  spinner.fail('Failed to relocate database')
@@ -3,6 +3,10 @@ import chalk from 'chalk'
3
3
  import { rm } from 'fs/promises'
4
4
  import inquirer from 'inquirer'
5
5
  import { containerManager } from '../../core/container-manager'
6
+ import { getEngine } from '../../engines'
7
+ import { binaryManager } from '../../core/binary-manager'
8
+ import { paths } from '../../config/paths'
9
+ import { platformService } from '../../core/platform-service'
6
10
  import { promptConfirm } from '../ui/prompts'
7
11
  import { createSpinner } from '../ui/spinner'
8
12
  import { uiError, uiWarning, uiInfo, formatBytes } from '../ui/theme'
@@ -14,6 +18,7 @@ import {
14
18
  type InstalledMysqlEngine,
15
19
  type InstalledSqliteEngine,
16
20
  } from '../helpers'
21
+ import { Engine } from '../../types'
17
22
 
18
23
  /**
19
24
  * Pad string to width, accounting for emoji taking 2 display columns
@@ -279,3 +284,59 @@ enginesCommand
279
284
  }
280
285
  },
281
286
  )
287
+
288
+ // Download subcommand
289
+ enginesCommand
290
+ .command('download <engine> <version>')
291
+ .description('Download engine binaries (PostgreSQL only)')
292
+ .action(async (engineName: string, version: string) => {
293
+ try {
294
+ // Validate engine name
295
+ const validEngines = ['postgresql', 'pg', 'postgres']
296
+ if (!validEngines.includes(engineName.toLowerCase())) {
297
+ console.error(
298
+ uiError(
299
+ `Only PostgreSQL binaries can be downloaded. MySQL and SQLite use system installations.`,
300
+ ),
301
+ )
302
+ process.exit(1)
303
+ }
304
+
305
+ const engine = getEngine(Engine.PostgreSQL)
306
+
307
+ // Check if already installed
308
+ const isInstalled = await engine.isBinaryInstalled(version)
309
+ if (isInstalled) {
310
+ console.log(
311
+ uiInfo(`PostgreSQL ${version} binaries are already installed.`),
312
+ )
313
+ return
314
+ }
315
+
316
+ const spinner = createSpinner(
317
+ `Downloading PostgreSQL ${version} binaries...`,
318
+ )
319
+ spinner.start()
320
+
321
+ await engine.ensureBinaries(version, ({ message }) => {
322
+ spinner.text = message
323
+ })
324
+
325
+ spinner.succeed(`PostgreSQL ${version} binaries downloaded`)
326
+
327
+ // Show the path for reference
328
+ const { platform, arch } = platformService.getPlatformInfo()
329
+ const fullVersion = binaryManager.getFullVersion(version)
330
+ const binPath = paths.getBinaryPath({
331
+ engine: 'postgresql',
332
+ version: fullVersion,
333
+ platform,
334
+ arch,
335
+ })
336
+ console.log(chalk.gray(` Location: ${binPath}`))
337
+ } catch (error) {
338
+ const e = error as Error
339
+ console.error(uiError(e.message))
340
+ process.exit(1)
341
+ }
342
+ })