spindb 0.13.4 → 0.14.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.
@@ -3,6 +3,7 @@ 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 { processManager } from '../../core/process-manager'
6
7
  import { getEngine } from '../../engines'
7
8
  import { binaryManager } from '../../core/binary-manager'
8
9
  import { paths } from '../../config/paths'
@@ -281,30 +282,46 @@ async function deleteEngine(
281
282
  process.exit(1)
282
283
  }
283
284
 
284
- // Check if any containers are using this engine version
285
+ // Check if any containers are using this engine version (for warning only)
285
286
  const containers = await containerManager.list()
286
287
  const usingContainers = containers.filter(
287
288
  (c) => c.engine === engineName && c.version === engineVersion,
288
289
  )
289
290
 
290
- if (usingContainers.length > 0) {
291
- console.error(
292
- uiError(
293
- `Cannot delete: ${usingContainers.length} container(s) are using ${engineName} ${engineVersion}`,
294
- ),
295
- )
296
- console.log(
297
- chalk.gray(
298
- ` Containers: ${usingContainers.map((c) => c.name).join(', ')}`,
299
- ),
300
- )
301
- console.log()
302
- console.log(chalk.gray(' Delete these containers first, then try again.'))
303
- process.exit(1)
304
- }
291
+ // Check for running containers using this engine
292
+ const runningContainers = usingContainers.filter((c) => c.status === 'running')
305
293
 
306
- // Confirm deletion
294
+ // Confirm deletion (warn about containers)
307
295
  if (!options.yes) {
296
+ if (usingContainers.length > 0) {
297
+ const runningCount = runningContainers.length
298
+ const stoppedCount = usingContainers.length - runningCount
299
+
300
+ if (runningCount > 0) {
301
+ console.log(
302
+ uiWarning(
303
+ `${runningCount} running container(s) will be stopped: ${runningContainers.map((c) => c.name).join(', ')}`,
304
+ ),
305
+ )
306
+ }
307
+ if (stoppedCount > 0) {
308
+ const stoppedContainers = usingContainers.filter(
309
+ (c) => c.status !== 'running',
310
+ )
311
+ console.log(
312
+ chalk.gray(
313
+ ` ${stoppedCount} stopped container(s) will be orphaned: ${stoppedContainers.map((c) => c.name).join(', ')}`,
314
+ ),
315
+ )
316
+ }
317
+ console.log(
318
+ chalk.gray(
319
+ ' You can re-download the engine later to use these containers.',
320
+ ),
321
+ )
322
+ console.log()
323
+ }
324
+
308
325
  const confirmed = await promptConfirm(
309
326
  `Delete ${engineName} ${engineVersion}? This cannot be undone.`,
310
327
  false,
@@ -316,6 +333,74 @@ async function deleteEngine(
316
333
  }
317
334
  }
318
335
 
336
+ // Stop any running containers first (while we still have the binary)
337
+ if (runningContainers.length > 0) {
338
+ const stopSpinner = createSpinner(
339
+ `Stopping ${runningContainers.length} running container(s)...`,
340
+ )
341
+ stopSpinner.start()
342
+
343
+ const engine = getEngine(Engine.PostgreSQL)
344
+ const failedToStop: string[] = []
345
+
346
+ for (const container of runningContainers) {
347
+ stopSpinner.text = `Stopping ${container.name}...`
348
+ try {
349
+ await engine.stop(container)
350
+ await containerManager.updateConfig(container.name, {
351
+ status: 'stopped',
352
+ })
353
+ } catch (error) {
354
+ // Log the original failure before attempting fallback
355
+ const err = error as Error
356
+ console.error(
357
+ chalk.gray(
358
+ ` Failed to stop ${container.name} via engine.stop: ${err.message}`,
359
+ ),
360
+ )
361
+ // Try fallback kill
362
+ const killed = await processManager.killProcess(container.name, {
363
+ engine: container.engine,
364
+ })
365
+ if (killed) {
366
+ await containerManager.updateConfig(container.name, {
367
+ status: 'stopped',
368
+ })
369
+ } else {
370
+ failedToStop.push(container.name)
371
+ }
372
+ }
373
+ }
374
+
375
+ if (failedToStop.length > 0) {
376
+ stopSpinner.warn(
377
+ `Could not stop ${failedToStop.length} container(s): ${failedToStop.join(', ')}`,
378
+ )
379
+ console.log(
380
+ chalk.yellow(
381
+ ' These containers may still be running. Deleting the engine could leave them in a broken state.',
382
+ ),
383
+ )
384
+
385
+ if (!options.yes) {
386
+ const continueAnyway = await promptConfirm(
387
+ 'Continue with engine deletion anyway?',
388
+ false,
389
+ )
390
+ if (!continueAnyway) {
391
+ console.log(uiWarning('Deletion cancelled'))
392
+ return
393
+ }
394
+ } else {
395
+ console.log(chalk.yellow(' Proceeding with deletion (--yes specified)'))
396
+ }
397
+ } else {
398
+ stopSpinner.succeed(
399
+ `Stopped ${runningContainers.length} container(s)`,
400
+ )
401
+ }
402
+ }
403
+
319
404
  // Delete the engine
320
405
  const spinner = createSpinner(`Deleting ${engineName} ${engineVersion}...`)
321
406
  spinner.start()
@@ -5,9 +5,10 @@ import { processManager } from '../../core/process-manager'
5
5
  import { startWithRetry } from '../../core/start-with-retry'
6
6
  import { getEngine } from '../../engines'
7
7
  import { getEngineDefaults } from '../../config/defaults'
8
- import { promptContainerSelect } from '../ui/prompts'
8
+ import { promptContainerSelect, promptConfirm } from '../ui/prompts'
9
9
  import { createSpinner } from '../ui/spinner'
10
10
  import { uiError, uiWarning } from '../ui/theme'
11
+ import { Engine } from '../../types'
11
12
 
12
13
  export const startCommand = new Command('start')
13
14
  .description('Start a container')
@@ -61,6 +62,51 @@ export const startCommand = new Command('start')
61
62
  const engineDefaults = getEngineDefaults(engineName)
62
63
  const engine = getEngine(engineName)
63
64
 
65
+ // For PostgreSQL, check if the engine binary is installed
66
+ if (engineName === Engine.PostgreSQL) {
67
+ const isInstalled = await engine.isBinaryInstalled(config.version)
68
+ if (!isInstalled) {
69
+ console.log(
70
+ uiWarning(
71
+ `PostgreSQL ${config.version} engine is not installed (required by "${containerName}")`,
72
+ ),
73
+ )
74
+ const confirmed = await promptConfirm(
75
+ `Download PostgreSQL ${config.version} now?`,
76
+ true,
77
+ )
78
+ if (!confirmed) {
79
+ console.log(
80
+ chalk.gray(
81
+ ` Run "spindb engines download postgresql ${config.version}" to download manually.`,
82
+ ),
83
+ )
84
+ return
85
+ }
86
+
87
+ const downloadSpinner = createSpinner(
88
+ `Downloading PostgreSQL ${config.version}...`,
89
+ )
90
+ downloadSpinner.start()
91
+
92
+ try {
93
+ await engine.ensureBinaries(config.version, ({ stage, message }) => {
94
+ if (stage === 'cached') {
95
+ downloadSpinner.text = `PostgreSQL ${config.version} ready`
96
+ } else {
97
+ downloadSpinner.text = message
98
+ }
99
+ })
100
+ downloadSpinner.succeed(`PostgreSQL ${config.version} downloaded`)
101
+ } catch (downloadError) {
102
+ downloadSpinner.fail(
103
+ `Failed to download PostgreSQL ${config.version} for "${containerName}"`,
104
+ )
105
+ throw downloadError
106
+ }
107
+ }
108
+ }
109
+
64
110
  const spinner = createSpinner(`Starting ${containerName}...`)
65
111
  spinner.start()
66
112
 
@@ -1,10 +1,12 @@
1
1
  import { Command } from 'commander'
2
+ import chalk from 'chalk'
2
3
  import { containerManager } from '../../core/container-manager'
3
4
  import { processManager } from '../../core/process-manager'
4
5
  import { getEngine } from '../../engines'
5
6
  import { promptContainerSelect } from '../ui/prompts'
6
7
  import { createSpinner } from '../ui/spinner'
7
8
  import { uiSuccess, uiError, uiWarning } from '../ui/theme'
9
+ import { Engine } from '../../types'
8
10
 
9
11
  export const stopCommand = new Command('stop')
10
12
  .description('Stop a container')
@@ -35,7 +37,46 @@ export const stopCommand = new Command('stop')
35
37
  spinner?.start()
36
38
 
37
39
  const engine = getEngine(container.engine)
38
- await engine.stop(container)
40
+
41
+ // For PostgreSQL, check if engine binary is installed
42
+ let usedFallback = false
43
+ let stopFailed = false
44
+ if (container.engine === Engine.PostgreSQL) {
45
+ const isInstalled = await engine.isBinaryInstalled(container.version)
46
+ if (!isInstalled) {
47
+ if (spinner) {
48
+ spinner.text = `Stopping ${container.name} (engine missing, using fallback)...`
49
+ }
50
+ const killed = await processManager.killProcess(container.name, {
51
+ engine: container.engine,
52
+ })
53
+ if (!killed) {
54
+ spinner?.fail(`Failed to stop "${container.name}"`)
55
+ console.log(
56
+ chalk.gray(
57
+ ` The PostgreSQL ${container.version} engine is not installed.`,
58
+ ),
59
+ )
60
+ console.log(
61
+ chalk.gray(
62
+ ` Run "spindb engines download postgresql ${container.version.split('.')[0]}" to reinstall.`,
63
+ ),
64
+ )
65
+ stopFailed = true
66
+ } else {
67
+ usedFallback = true
68
+ }
69
+ }
70
+ }
71
+
72
+ if (stopFailed) {
73
+ continue
74
+ }
75
+
76
+ if (!usedFallback) {
77
+ await engine.stop(container)
78
+ }
79
+
39
80
  await containerManager.updateConfig(container.name, {
40
81
  status: 'stopped',
41
82
  })
@@ -98,7 +139,40 @@ export const stopCommand = new Command('stop')
98
139
  : createSpinner(`Stopping ${containerName}...`)
99
140
  spinner?.start()
100
141
 
101
- await engine.stop(config)
142
+ // For PostgreSQL, check if engine binary is installed
143
+ // If not, use fallback process kill
144
+ let usedFallback = false
145
+ if (config.engine === Engine.PostgreSQL) {
146
+ const isInstalled = await engine.isBinaryInstalled(config.version)
147
+ if (!isInstalled) {
148
+ if (spinner) {
149
+ spinner.text = `Stopping ${containerName} (engine missing, using fallback)...`
150
+ }
151
+ const killed = await processManager.killProcess(containerName, {
152
+ engine: config.engine,
153
+ })
154
+ if (!killed) {
155
+ spinner?.fail(`Failed to stop "${containerName}"`)
156
+ console.log(
157
+ chalk.gray(
158
+ ` The PostgreSQL ${config.version} engine is not installed.`,
159
+ ),
160
+ )
161
+ console.log(
162
+ chalk.gray(
163
+ ` Run "spindb engines download postgresql ${config.version.split('.')[0]}" to reinstall.`,
164
+ ),
165
+ )
166
+ process.exit(1)
167
+ }
168
+ usedFallback = true
169
+ }
170
+ }
171
+
172
+ if (!usedFallback) {
173
+ await engine.stop(config)
174
+ }
175
+
102
176
  await containerManager.updateConfig(containerName, {
103
177
  status: 'stopped',
104
178
  })
@@ -45,12 +45,12 @@ export const defaults: Defaults = {
45
45
  engine: Engine.PostgreSQL,
46
46
  superuser: pgDefaults.superuser,
47
47
  platformMappings: {
48
- 'darwin-arm64': 'darwin-arm64v8',
49
- 'darwin-x64': 'darwin-amd64',
50
- 'linux-arm64': 'linux-arm64v8',
51
- 'linux-x64': 'linux-amd64',
52
- // Windows uses EDB binaries instead of zonky.io
53
- // EDB naming convention: windows-x64
54
- 'win32-x64': 'windows-x64',
48
+ // hostdb uses standard platform naming (no transformation needed)
49
+ // These mappings are kept for backwards compatibility but are 1:1
50
+ 'darwin-arm64': 'darwin-arm64',
51
+ 'darwin-x64': 'darwin-x64',
52
+ 'linux-arm64': 'linux-arm64',
53
+ 'linux-x64': 'linux-x64',
54
+ 'win32-x64': 'win32-x64',
55
55
  },
56
56
  }
@@ -2,11 +2,11 @@ import { createWriteStream, existsSync, createReadStream } from 'fs'
2
2
  import { mkdir, readdir, rm, chmod, rename, cp } from 'fs/promises'
3
3
  import { join } from 'path'
4
4
  import { pipeline } from 'stream/promises'
5
- import { exec } from 'child_process'
5
+ import { exec, spawn } from 'child_process'
6
6
  import { promisify } from 'util'
7
7
  import unzipper from 'unzipper'
8
8
  import { paths } from '../config/paths'
9
- import { defaults } from '../config/defaults'
9
+ import { getBinaryUrl } from '../engines/postgresql/binary-urls'
10
10
  import { getEDBBinaryUrl } from '../engines/postgresql/edb-binary-urls'
11
11
  import { normalizeVersion } from '../engines/postgresql/version-maps'
12
12
  import {
@@ -17,11 +17,54 @@ import {
17
17
 
18
18
  const execAsync = promisify(exec)
19
19
 
20
+ /**
21
+ * Execute a command using spawn with argument array (safer than shell interpolation)
22
+ * Returns a promise that resolves on success or rejects on error/non-zero exit
23
+ */
24
+ function spawnAsync(
25
+ command: string,
26
+ args: string[],
27
+ options?: { cwd?: string },
28
+ ): Promise<{ stdout: string; stderr: string }> {
29
+ return new Promise((resolve, reject) => {
30
+ const proc = spawn(command, args, {
31
+ stdio: ['ignore', 'pipe', 'pipe'],
32
+ cwd: options?.cwd,
33
+ })
34
+
35
+ let stdout = ''
36
+ let stderr = ''
37
+
38
+ proc.stdout?.on('data', (data: Buffer) => {
39
+ stdout += data.toString()
40
+ })
41
+ proc.stderr?.on('data', (data: Buffer) => {
42
+ stderr += data.toString()
43
+ })
44
+
45
+ proc.on('close', (code) => {
46
+ if (code === 0) {
47
+ resolve({ stdout, stderr })
48
+ } else {
49
+ reject(
50
+ new Error(
51
+ `Command "${command} ${args.join(' ')}" failed with code ${code}: ${stderr || stdout}`,
52
+ ),
53
+ )
54
+ }
55
+ })
56
+
57
+ proc.on('error', (err) => {
58
+ reject(new Error(`Failed to execute "${command}": ${err.message}`))
59
+ })
60
+ })
61
+ }
62
+
20
63
  export class BinaryManager {
21
64
  /**
22
65
  * Get the download URL for a PostgreSQL version
23
66
  *
24
- * - macOS/Linux: Uses zonky.io Maven Central binaries (JAR format)
67
+ * - macOS/Linux: Uses hostdb GitHub releases (tar.gz format)
25
68
  * - Windows: Uses EDB (EnterpriseDB) official binaries (ZIP format)
26
69
  */
27
70
  getDownloadUrl(version: string, platform: string, arch: string): string {
@@ -31,29 +74,22 @@ export class BinaryManager {
31
74
  throw new Error(`Unsupported platform: ${platformKey}`)
32
75
  }
33
76
 
34
- // Windows uses EDB binaries instead of zonky.io
77
+ // Windows uses EDB binaries
35
78
  if (platform === 'win32') {
36
79
  const fullVersion = this.getFullVersion(version)
37
80
  return getEDBBinaryUrl(fullVersion)
38
81
  }
39
82
 
40
- // macOS/Linux use zonky.io binaries
41
- const zonkyPlatform = defaults.platformMappings[platformKey]
42
-
43
- if (!zonkyPlatform) {
44
- throw new Error(`Unsupported platform: ${platformKey}`)
45
- }
46
-
47
- // Zonky.io Maven Central URL pattern
83
+ // macOS/Linux use hostdb binaries
48
84
  const fullVersion = this.getFullVersion(version)
49
- return `https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-${zonkyPlatform}/${fullVersion}/embedded-postgres-binaries-${zonkyPlatform}-${fullVersion}.jar`
85
+ return getBinaryUrl(fullVersion, platform, arch)
50
86
  }
51
87
 
52
88
  /**
53
89
  * Convert version to full version format (e.g., "16" -> "16.11.0", "16.9" -> "16.9.0")
54
90
  *
55
91
  * Uses the shared version mappings from version-maps.ts.
56
- * Both zonky.io (macOS/Linux) and EDB (Windows) use the same PostgreSQL versions.
92
+ * Both hostdb (macOS/Linux) and EDB (Windows) use the same PostgreSQL versions.
57
93
  */
58
94
  getFullVersion(version: string): string {
59
95
  return normalizeVersion(version)
@@ -112,8 +148,7 @@ export class BinaryManager {
112
148
  /**
113
149
  * Download and extract PostgreSQL binaries
114
150
  *
115
- * - macOS/Linux (zonky.io): JAR files are ZIP archives containing a .txz (tar.xz) file.
116
- * We need to: 1) unzip the JAR, 2) extract the .txz inside
151
+ * - macOS/Linux (hostdb): tar.gz files extract directly to PostgreSQL structure
117
152
  * - Windows (EDB): ZIP files extract directly to a PostgreSQL directory structure
118
153
  */
119
154
  async download(
@@ -133,7 +168,7 @@ export class BinaryManager {
133
168
  const tempDir = join(paths.bin, `temp-${fullVersion}-${platform}-${arch}`)
134
169
  const archiveFile = join(
135
170
  tempDir,
136
- platform === 'win32' ? 'postgres.zip' : 'postgres.jar',
171
+ platform === 'win32' ? 'postgres.zip' : 'postgres.tar.gz',
137
172
  )
138
173
 
139
174
  // Ensure directories exist
@@ -168,7 +203,7 @@ export class BinaryManager {
168
203
  onProgress,
169
204
  )
170
205
  } else {
171
- // macOS/Linux: zonky.io JAR contains .txz that needs secondary extraction
206
+ // macOS/Linux: hostdb tar.gz extracts directly to PostgreSQL structure
172
207
  await this.extractUnixBinaries(
173
208
  archiveFile,
174
209
  binPath,
@@ -254,62 +289,58 @@ export class BinaryManager {
254
289
  }
255
290
 
256
291
  /**
257
- * Extract Unix binaries from zonky.io JAR file
258
- * JAR contains a .txz (tar.xz) file that needs secondary extraction
292
+ * Extract Unix binaries from hostdb tar.gz file
293
+ * Handles both flat structure (bin/, lib/, share/ at root) and
294
+ * nested structure (postgresql/bin/, postgresql/lib/, etc.)
259
295
  */
260
296
  private async extractUnixBinaries(
261
- jarFile: string,
297
+ tarFile: string,
262
298
  binPath: string,
263
299
  tempDir: string,
264
300
  onProgress?: ProgressCallback,
265
301
  ): Promise<void> {
266
- // Extract the JAR (it's a ZIP file) using unzipper
267
302
  onProgress?.({
268
303
  stage: 'extracting',
269
- message: 'Extracting binaries (step 1/2)...',
270
- })
271
-
272
- await new Promise<void>((resolve, reject) => {
273
- createReadStream(jarFile)
274
- .pipe(unzipper.Extract({ path: tempDir }))
275
- .on('close', resolve)
276
- .on('error', reject)
277
- })
278
-
279
- // Find the .txz file inside
280
- onProgress?.({
281
- stage: 'extracting',
282
- message: 'Extracting binaries (step 2/2)...',
304
+ message: 'Extracting binaries...',
283
305
  })
284
306
 
285
- const txzFile = await this.findTxzFile(tempDir)
286
- if (!txzFile) {
287
- throw new Error('Could not find .txz file in downloaded archive')
288
- }
307
+ // Extract tar.gz to temp directory first to check structure
308
+ // Using spawnAsync with argument array to avoid command injection
309
+ const extractDir = join(tempDir, 'extract')
310
+ await mkdir(extractDir, { recursive: true })
311
+ await spawnAsync('tar', ['-xzf', tarFile, '-C', extractDir])
289
312
 
290
- // Extract the tar.xz file (no strip-components since files are at root level)
291
- await execAsync(`tar -xJf "${txzFile}" -C "${binPath}"`)
292
- }
313
+ // Check if there's a nested postgresql/ directory
314
+ const entries = await readdir(extractDir, { withFileTypes: true })
315
+ const postgresDir = entries.find(
316
+ (e) => e.isDirectory() && e.name === 'postgresql',
317
+ )
293
318
 
294
- /**
295
- * Recursively find a .txz or .tar.xz file in a directory
296
- */
297
- private async findTxzFile(dir: string): Promise<string | null> {
298
- const entries = await readdir(dir, { withFileTypes: true })
299
- for (const entry of entries) {
300
- const fullPath = join(dir, entry.name)
301
- if (
302
- entry.isFile() &&
303
- (entry.name.endsWith('.txz') || entry.name.endsWith('.tar.xz'))
304
- ) {
305
- return fullPath
319
+ if (postgresDir) {
320
+ // Nested structure: move contents from postgresql/ to binPath
321
+ const sourceDir = join(extractDir, 'postgresql')
322
+ const sourceEntries = await readdir(sourceDir, { withFileTypes: true })
323
+ for (const entry of sourceEntries) {
324
+ const sourcePath = join(sourceDir, entry.name)
325
+ const destPath = join(binPath, entry.name)
326
+ try {
327
+ await rename(sourcePath, destPath)
328
+ } catch {
329
+ await cp(sourcePath, destPath, { recursive: true })
330
+ }
306
331
  }
307
- if (entry.isDirectory()) {
308
- const found = await this.findTxzFile(fullPath)
309
- if (found) return found
332
+ } else {
333
+ // Flat structure: move contents directly to binPath
334
+ for (const entry of entries) {
335
+ const sourcePath = join(extractDir, entry.name)
336
+ const destPath = join(binPath, entry.name)
337
+ try {
338
+ await rename(sourcePath, destPath)
339
+ } catch {
340
+ await cp(sourcePath, destPath, { recursive: true })
341
+ }
310
342
  }
311
343
  }
312
- return null
313
344
  }
314
345
 
315
346
  /**
@@ -416,8 +447,8 @@ export class BinaryManager {
416
447
  binPath = await this.download(version, platform, arch, onProgress)
417
448
  }
418
449
 
419
- // On Linux, zonky.io binaries don't include client tools (psql, pg_dump)
420
- // Download them separately from the PostgreSQL apt repository
450
+ // On Linux, hostdb binaries may not include client tools (psql, pg_dump)
451
+ // Download them separately from the PostgreSQL apt repository if missing
421
452
  if (platform === 'linux') {
422
453
  await this.ensureClientTools(binPath, version, onProgress)
423
454
  }
@@ -477,7 +508,8 @@ export class BinaryManager {
477
508
  })
478
509
 
479
510
  // Extract .deb using ar (available on Linux)
480
- await execAsync(`ar -x "${debFile}"`, { cwd: tempDir })
511
+ // Using spawnAsync with argument array to avoid command injection
512
+ await spawnAsync('ar', ['-x', debFile], { cwd: tempDir })
481
513
 
482
514
  // Find and extract data.tar.xz or data.tar.zst
483
515
  const dataFile = await this.findDataTar(tempDir)
@@ -486,17 +518,18 @@ export class BinaryManager {
486
518
  }
487
519
 
488
520
  // Determine compression type and extract
521
+ // Using spawnAsync with argument array to avoid command injection
489
522
  const extractDir = join(tempDir, 'extracted')
490
523
  await mkdir(extractDir, { recursive: true })
491
524
 
492
525
  if (dataFile.endsWith('.xz')) {
493
- await execAsync(`tar -xJf "${dataFile}" -C "${extractDir}"`)
526
+ await spawnAsync('tar', ['-xJf', dataFile, '-C', extractDir])
494
527
  } else if (dataFile.endsWith('.zst')) {
495
- await execAsync(`tar --zstd -xf "${dataFile}" -C "${extractDir}"`)
528
+ await spawnAsync('tar', ['--zstd', '-xf', dataFile, '-C', extractDir])
496
529
  } else if (dataFile.endsWith('.gz')) {
497
- await execAsync(`tar -xzf "${dataFile}" -C "${extractDir}"`)
530
+ await spawnAsync('tar', ['-xzf', dataFile, '-C', extractDir])
498
531
  } else {
499
- await execAsync(`tar -xf "${dataFile}" -C "${extractDir}"`)
532
+ await spawnAsync('tar', ['-xf', dataFile, '-C', extractDir])
500
533
  }
501
534
 
502
535
  // Copy client tools to the bin directory
@@ -538,13 +571,23 @@ export class BinaryManager {
538
571
  const packageDir = `postgresql-${majorVersion}`
539
572
  const indexUrl = `${baseUrl}/${packageDir}/`
540
573
 
574
+ let html = ''
541
575
  try {
542
576
  const response = await fetch(indexUrl)
543
577
  if (!response.ok) {
544
- throw new Error(`Failed to fetch package index: ${response.status}`)
578
+ throw new Error(
579
+ `Failed to fetch package index from ${indexUrl}: HTTP ${response.status} ${response.statusText}`,
580
+ )
545
581
  }
546
582
 
547
- const html = await response.text()
583
+ html = await response.text()
584
+
585
+ // Validate that we got HTML content (basic sanity check)
586
+ if (!html.includes('<html') && !html.includes('<!DOCTYPE')) {
587
+ throw new Error(
588
+ `Unexpected response from ${indexUrl}: content does not appear to be HTML`,
589
+ )
590
+ }
548
591
 
549
592
  // Find the latest postgresql-client package for amd64
550
593
  // Pattern: postgresql-client-17_17.x.x-x.pgdg+1_amd64.deb
@@ -556,15 +599,22 @@ export class BinaryManager {
556
599
  const matches: string[] = []
557
600
  let match
558
601
  while ((match = pattern.exec(html)) !== null) {
559
- // Skip debug symbols and snapshot versions
560
- if (!match[1].includes('dbgsym') && !match[1].includes('~')) {
561
- matches.push(match[1])
602
+ // Validate that the capture group exists
603
+ if (match[1]) {
604
+ // Skip debug symbols and snapshot versions
605
+ if (!match[1].includes('dbgsym') && !match[1].includes('~')) {
606
+ matches.push(match[1])
607
+ }
562
608
  }
563
609
  }
564
610
 
565
611
  if (matches.length === 0) {
612
+ // Provide diagnostic information for debugging
613
+ const htmlSnippet = html.substring(0, 500).replace(/\n/g, ' ')
566
614
  throw new Error(
567
- `No client package found for PostgreSQL ${majorVersion}`,
615
+ `No client package found for PostgreSQL ${majorVersion} at ${indexUrl}. ` +
616
+ `Expected pattern: postgresql-client-${majorVersion}_*_amd64.deb. ` +
617
+ `HTML snippet: ${htmlSnippet}...`,
568
618
  )
569
619
  }
570
620
 
@@ -575,7 +625,14 @@ export class BinaryManager {
575
625
  return `${indexUrl}${latestPackage}`
576
626
  } catch (error) {
577
627
  const err = error as Error
578
- throw new Error(`Failed to get client package URL: ${err.message}`)
628
+ // If the error already has context, just rethrow it
629
+ if (err.message.includes(indexUrl)) {
630
+ throw error
631
+ }
632
+ // Otherwise, add context about the URL we were trying to parse
633
+ throw new Error(
634
+ `Failed to get client package URL from ${indexUrl}: ${err.message}`,
635
+ )
579
636
  }
580
637
  }
581
638
 
@@ -396,6 +396,72 @@ export class ProcessManager {
396
396
  }
397
397
  }
398
398
 
399
+ /**
400
+ * Kill a process directly by PID (fallback when pg_ctl is unavailable)
401
+ * Sends SIGTERM first, then SIGKILL if process doesn't stop
402
+ */
403
+ async killProcess(
404
+ containerName: string,
405
+ options: { engine: string },
406
+ ): Promise<boolean> {
407
+ const pid = await this.getPid(containerName, options)
408
+ if (!pid) {
409
+ // No PID means the process isn't running - goal achieved
410
+ logDebug('No PID found for container (already stopped)', { containerName })
411
+ return true
412
+ }
413
+
414
+ try {
415
+ // Check if process is running
416
+ process.kill(pid, 0)
417
+ } catch {
418
+ // Process not running
419
+ logDebug('Process not running', { containerName, pid })
420
+ return true
421
+ }
422
+
423
+ try {
424
+ // Send SIGTERM for graceful shutdown
425
+ logDebug('Sending SIGTERM to process', { containerName, pid })
426
+ process.kill(pid, 'SIGTERM')
427
+
428
+ // Wait for process to stop (up to 10 seconds)
429
+ for (let i = 0; i < 20; i++) {
430
+ await new Promise((resolve) => setTimeout(resolve, 500))
431
+ try {
432
+ process.kill(pid, 0)
433
+ } catch {
434
+ // Process stopped
435
+ logDebug('Process stopped gracefully', { containerName, pid })
436
+ return true
437
+ }
438
+ }
439
+
440
+ // Process didn't stop, send SIGKILL
441
+ logDebug('Sending SIGKILL to process', { containerName, pid })
442
+ process.kill(pid, 'SIGKILL')
443
+
444
+ // Wait a bit more and verify process is dead
445
+ await new Promise((resolve) => setTimeout(resolve, 1000))
446
+ try {
447
+ process.kill(pid, 0)
448
+ // Process still running after SIGKILL (rare: zombie or uninterruptible sleep)
449
+ logDebug('Process still running after SIGKILL', { containerName, pid })
450
+ return false
451
+ } catch {
452
+ logDebug('Process killed with SIGKILL', { containerName, pid })
453
+ return true
454
+ }
455
+ } catch (error) {
456
+ logDebug('Failed to kill process', {
457
+ containerName,
458
+ pid,
459
+ error: error instanceof Error ? error.message : String(error),
460
+ })
461
+ return false
462
+ }
463
+ }
464
+
399
465
  async psql(
400
466
  psqlPath: string,
401
467
  options: PsqlOptions,
@@ -1,163 +1,133 @@
1
- import { platformService } from '../../core/platform-service'
2
- import { defaults } from '../../config/defaults'
1
+ import {
2
+ fetchAvailableVersions as fetchHostdbVersions,
3
+ getLatestVersion as getHostdbLatestVersion,
4
+ } from './hostdb-releases'
5
+ import {
6
+ POSTGRESQL_VERSION_MAP,
7
+ SUPPORTED_MAJOR_VERSIONS,
8
+ } from './version-maps'
3
9
 
4
10
  /**
5
11
  * Fallback map of major versions to stable patch versions
6
- * Used when Maven repository is unreachable
12
+ * Used when hostdb repository is unreachable
13
+ *
14
+ * @deprecated Use POSTGRESQL_VERSION_MAP from version-maps.ts instead
7
15
  */
8
- export const FALLBACK_VERSION_MAP: Record<string, string> = {
9
- '14': '14.20.0',
10
- '15': '15.15.0',
11
- '16': '16.11.0',
12
- '17': '17.7.0',
13
- '18': '18.1.0',
14
- }
16
+ export const FALLBACK_VERSION_MAP: Record<string, string> = POSTGRESQL_VERSION_MAP
15
17
 
16
18
  /**
17
19
  * Supported major versions (in order of display)
20
+ *
21
+ * @deprecated Use SUPPORTED_MAJOR_VERSIONS from version-maps.ts instead
18
22
  */
19
- export const SUPPORTED_MAJOR_VERSIONS = ['14', '15', '16', '17', '18']
20
-
21
- // Cache for fetched versions
22
- let cachedVersions: Record<string, string[]> | null = null
23
- let cacheTimestamp = 0
24
- const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
23
+ export { SUPPORTED_MAJOR_VERSIONS }
25
24
 
26
25
  /**
27
- * Fetch available versions from Maven repository
26
+ * Fetch available versions from hostdb repository
27
+ *
28
+ * This replaces the previous Maven Central (zonky.io) source with the new
29
+ * hostdb repository at https://github.com/robertjbass/hostdb
28
30
  */
29
31
  export async function fetchAvailableVersions(): Promise<
30
32
  Record<string, string[]>
31
33
  > {
32
- // Return cached versions if still valid
33
- if (cachedVersions && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
34
- return cachedVersions
35
- }
36
-
37
- const zonkyPlatform = platformService.getZonkyPlatform()
38
- if (!zonkyPlatform) {
39
- const { platform, arch } = platformService.getPlatformInfo()
40
- throw new Error(`Unsupported platform: ${platform}-${arch}`)
41
- }
42
-
43
- const url = `https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-${zonkyPlatform}/`
44
-
45
- try {
46
- const response = await fetch(url, { signal: AbortSignal.timeout(5000) })
47
- if (!response.ok) {
48
- throw new Error(`HTTP ${response.status}`)
49
- }
50
-
51
- const html = await response.text()
52
-
53
- // Parse version directories from the HTML listing
54
- // Format: <a href="14.15.0/">14.15.0/</a>
55
- const versionRegex = /href="(\d+\.\d+\.\d+)\/"/g
56
- const versions: string[] = []
57
- let match
58
-
59
- while ((match = versionRegex.exec(html)) !== null) {
60
- versions.push(match[1])
61
- }
62
-
63
- // Group versions by major version
64
- const grouped: Record<string, string[]> = {}
65
- for (const major of SUPPORTED_MAJOR_VERSIONS) {
66
- grouped[major] = versions
67
- .filter((v) => v.startsWith(`${major}.`))
68
- .sort((a, b) => compareVersions(b, a)) // Sort descending (latest first)
69
- }
70
-
71
- // Cache the results
72
- cachedVersions = grouped
73
- cacheTimestamp = Date.now()
74
-
75
- return grouped
76
- } catch {
77
- // Return fallback on any error
78
- return getFallbackVersions()
79
- }
34
+ return await fetchHostdbVersions()
80
35
  }
81
36
 
82
37
  /**
83
- * Get fallback versions when network is unavailable
38
+ * Get the latest version for a major version from hostdb
84
39
  */
85
- function getFallbackVersions(): Record<string, string[]> {
86
- const grouped: Record<string, string[]> = {}
87
- for (const major of SUPPORTED_MAJOR_VERSIONS) {
88
- grouped[major] = [FALLBACK_VERSION_MAP[major]]
89
- }
90
- return grouped
40
+ export async function getLatestVersion(major: string): Promise<string> {
41
+ return await getHostdbLatestVersion(major)
91
42
  }
92
43
 
93
- /**
94
- * Compare two version strings (e.g., "16.11.0" vs "16.9.0")
95
- * Returns positive if a > b, negative if a < b, 0 if equal
96
- */
97
- function compareVersions(a: string, b: string): number {
98
- const partsA = a.split('.').map(Number)
99
- const partsB = b.split('.').map(Number)
100
-
101
- for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
102
- const numA = partsA[i] || 0
103
- const numB = partsB[i] || 0
104
- if (numA !== numB) {
105
- return numA - numB
106
- }
107
- }
108
- return 0
109
- }
44
+ // Legacy export for backward compatibility
45
+ export const VERSION_MAP = FALLBACK_VERSION_MAP
110
46
 
111
47
  /**
112
- * Get the latest version for a major version
48
+ * Get the hostdb platform identifier
49
+ *
50
+ * hostdb uses standard platform naming (e.g., 'darwin-arm64', 'linux-x64')
51
+ * which matches Node.js platform identifiers directly.
52
+ *
53
+ * @param platform - Node.js platform (e.g., 'darwin', 'linux', 'win32')
54
+ * @param arch - Node.js architecture (e.g., 'arm64', 'x64')
55
+ * @returns hostdb platform identifier or undefined if unsupported
113
56
  */
114
- export async function getLatestVersion(major: string): Promise<string> {
115
- const versions = await fetchAvailableVersions()
116
- const majorVersions = versions[major]
117
- if (majorVersions && majorVersions.length > 0) {
118
- return majorVersions[0] // First is latest due to descending sort
57
+ export function getHostdbPlatform(
58
+ platform: string,
59
+ arch: string,
60
+ ): string | undefined {
61
+ const key = `${platform}-${arch}`
62
+ const mapping: Record<string, string> = {
63
+ 'darwin-arm64': 'darwin-arm64',
64
+ 'darwin-x64': 'darwin-x64',
65
+ 'linux-arm64': 'linux-arm64',
66
+ 'linux-x64': 'linux-x64',
67
+ 'win32-x64': 'win32-x64',
119
68
  }
120
- return FALLBACK_VERSION_MAP[major] || `${major}.0.0`
69
+ return mapping[key]
121
70
  }
122
71
 
123
- // Legacy export for backward compatibility
124
- export const VERSION_MAP = FALLBACK_VERSION_MAP
125
-
126
72
  /**
127
- * Get the zonky.io platform identifier
128
- * @deprecated Use platformService.getZonkyPlatform() instead
73
+ * Get the hostdb platform identifier
74
+ *
75
+ * @deprecated Use getHostdbPlatform instead. This function exists for backward compatibility.
129
76
  */
130
77
  export function getZonkyPlatform(
131
78
  platform: string,
132
79
  arch: string,
133
80
  ): string | undefined {
134
- const key = `${platform}-${arch}`
135
- return defaults.platformMappings[key]
81
+ return getHostdbPlatform(platform, arch)
136
82
  }
137
83
 
138
84
  /**
139
- * Build the download URL for PostgreSQL binaries from zonky.io
85
+ * Build the download URL for PostgreSQL binaries from hostdb
86
+ *
87
+ * Format: https://github.com/robertjbass/hostdb/releases/download/postgresql-{version}/postgresql-{version}-{platform}-{arch}.tar.gz
88
+ *
89
+ * @param version - PostgreSQL version (e.g., '17', '17.7.0')
90
+ * @param platform - Platform identifier (e.g., 'darwin', 'linux')
91
+ * @param arch - Architecture identifier (e.g., 'arm64', 'x64')
92
+ * @returns Download URL for the binary
140
93
  */
141
94
  export function getBinaryUrl(
142
95
  version: string,
143
96
  platform: string,
144
97
  arch: string,
145
98
  ): string {
146
- const zonkyPlatform = getZonkyPlatform(platform, arch)
147
- if (!zonkyPlatform) {
148
- throw new Error(`Unsupported platform: ${platform}-${arch}`)
99
+ const platformKey = `${platform}-${arch}`
100
+ const hostdbPlatform = getHostdbPlatform(platform, arch)
101
+ if (!hostdbPlatform) {
102
+ throw new Error(`Unsupported platform: ${platformKey}`)
149
103
  }
150
104
 
151
- // Use VERSION_MAP for major versions, otherwise treat as full version
152
- const fullVersion = VERSION_MAP[version] || normalizeVersion(version)
105
+ // Normalize version (handles major version lookup and X.Y -> X.Y.0 conversion)
106
+ const fullVersion = normalizeVersion(version, VERSION_MAP)
107
+
108
+ const tag = `postgresql-${fullVersion}`
109
+ const filename = `postgresql-${fullVersion}-${hostdbPlatform}.tar.gz`
153
110
 
154
- return `https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-${zonkyPlatform}/${fullVersion}/embedded-postgres-binaries-${zonkyPlatform}-${fullVersion}.jar`
111
+ return `https://github.com/robertjbass/hostdb/releases/download/${tag}/${filename}`
155
112
  }
156
113
 
157
114
  /**
158
115
  * Normalize version string to X.Y.Z format
116
+ *
117
+ * @param version - Version string (e.g., '17', '17.7', '17.7.0')
118
+ * @param versionMap - Optional version map for major version lookup
119
+ * @returns Normalized version (e.g., '17.7.0')
159
120
  */
160
- function normalizeVersion(version: string): string {
121
+ function normalizeVersion(
122
+ version: string,
123
+ versionMap: Record<string, string> = VERSION_MAP,
124
+ ): string {
125
+ // Check if it's a major version in the map
126
+ if (versionMap[version]) {
127
+ return versionMap[version]
128
+ }
129
+
130
+ // Normalize to X.Y.Z format
161
131
  const parts = version.split('.')
162
132
  if (parts.length === 2) {
163
133
  return `${version}.0`
@@ -167,6 +137,9 @@ function normalizeVersion(version: string): string {
167
137
 
168
138
  /**
169
139
  * Get the full version string for a major version
140
+ *
141
+ * @param majorVersion - Major version (e.g., '17')
142
+ * @returns Full version string (e.g., '17.7.0') or null if not supported
170
143
  */
171
144
  export function getFullVersion(majorVersion: string): string | null {
172
145
  return VERSION_MAP[majorVersion] || null
@@ -0,0 +1,256 @@
1
+ /**
2
+ * hostdb Releases Module
3
+ *
4
+ * Fetches PostgreSQL binary information from the hostdb repository at
5
+ * https://github.com/robertjbass/hostdb
6
+ *
7
+ * hostdb provides pre-built PostgreSQL binaries for multiple platforms,
8
+ * replacing the previous zonky.io (macOS/Linux) and EDB (Windows) sources.
9
+ */
10
+
11
+ import {
12
+ POSTGRESQL_VERSION_MAP,
13
+ SUPPORTED_MAJOR_VERSIONS,
14
+ } from './version-maps'
15
+
16
+ /**
17
+ * Platform definition in hostdb releases.json
18
+ */
19
+ export type HostdbPlatform = {
20
+ url: string
21
+ sha256: string
22
+ size: number
23
+ }
24
+
25
+ /**
26
+ * Version entry in hostdb releases.json
27
+ */
28
+ export type HostdbRelease = {
29
+ version: string
30
+ releaseTag: string
31
+ releasedAt: string
32
+ platforms: Record<string, HostdbPlatform>
33
+ }
34
+
35
+ /**
36
+ * Structure of hostdb releases.json
37
+ */
38
+ export type HostdbReleasesData = {
39
+ repository: string
40
+ updatedAt: string
41
+ databases: {
42
+ postgresql: Record<string, HostdbRelease>
43
+ // Other databases...
44
+ }
45
+ }
46
+
47
+ // Cache for fetched releases
48
+ let cachedReleases: HostdbReleasesData | null = null
49
+ let cacheTimestamp = 0
50
+ const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
51
+
52
+ /**
53
+ * Clear the releases cache (for testing)
54
+ */
55
+ export function clearCache(): void {
56
+ cachedReleases = null
57
+ cacheTimestamp = 0
58
+ }
59
+
60
+ /**
61
+ * Fetch releases.json from hostdb repository
62
+ */
63
+ export async function fetchHostdbReleases(): Promise<HostdbReleasesData> {
64
+ // Return cached releases if still valid
65
+ if (cachedReleases && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
66
+ return cachedReleases
67
+ }
68
+
69
+ const url =
70
+ 'https://raw.githubusercontent.com/robertjbass/hostdb/main/releases.json'
71
+
72
+ try {
73
+ const response = await fetch(url, { signal: AbortSignal.timeout(5000) })
74
+ if (!response.ok) {
75
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
76
+ }
77
+
78
+ const data = (await response.json()) as HostdbReleasesData
79
+
80
+ // Cache the results
81
+ cachedReleases = data
82
+ cacheTimestamp = Date.now()
83
+
84
+ return data
85
+ } catch (error) {
86
+ const err = error as Error
87
+ // Log the failure and rethrow - caller decides whether to use fallback
88
+ console.warn(`Warning: Failed to fetch hostdb releases: ${err.message}`)
89
+ throw error
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Get available PostgreSQL versions from hostdb, grouped by major version
95
+ */
96
+ export async function fetchAvailableVersions(): Promise<
97
+ Record<string, string[]>
98
+ > {
99
+ try {
100
+ const releases = await fetchHostdbReleases()
101
+ const pgReleases = releases.databases.postgresql
102
+
103
+ // Group versions by major version
104
+ const grouped: Record<string, string[]> = {}
105
+
106
+ for (const major of SUPPORTED_MAJOR_VERSIONS) {
107
+ grouped[major] = []
108
+
109
+ // Find all versions matching this major version
110
+ for (const [_versionKey, release] of Object.entries(pgReleases)) {
111
+ if (release.version.startsWith(`${major}.`)) {
112
+ grouped[major].push(release.version)
113
+ }
114
+ }
115
+
116
+ // Sort descending (latest first)
117
+ grouped[major].sort((a, b) => compareVersions(b, a))
118
+ }
119
+
120
+ return grouped
121
+ } catch {
122
+ // Fallback to version map on error
123
+ return getFallbackVersions()
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Get fallback versions when network is unavailable
129
+ */
130
+ function getFallbackVersions(): Record<string, string[]> {
131
+ const grouped: Record<string, string[]> = {}
132
+ for (const major of SUPPORTED_MAJOR_VERSIONS) {
133
+ grouped[major] = [POSTGRESQL_VERSION_MAP[major]]
134
+ }
135
+ return grouped
136
+ }
137
+
138
+ /**
139
+ * Compare two version strings (e.g., "16.11.0" vs "16.9.0")
140
+ * Returns positive if a > b, negative if a < b, 0 if equal
141
+ */
142
+ function compareVersions(a: string, b: string): number {
143
+ const partsA = a.split('.').map(Number)
144
+ const partsB = b.split('.').map(Number)
145
+
146
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
147
+ const numA = partsA[i] || 0
148
+ const numB = partsB[i] || 0
149
+ if (numA !== numB) {
150
+ return numA - numB
151
+ }
152
+ }
153
+ return 0
154
+ }
155
+
156
+ /**
157
+ * Get the latest version for a major version from hostdb
158
+ */
159
+ export async function getLatestVersion(major: string): Promise<string> {
160
+ const versions = await fetchAvailableVersions()
161
+ const majorVersions = versions[major]
162
+ if (majorVersions && majorVersions.length > 0) {
163
+ return majorVersions[0] // First is latest due to descending sort
164
+ }
165
+ return POSTGRESQL_VERSION_MAP[major] || `${major}.0.0`
166
+ }
167
+
168
+ /**
169
+ * Get the download URL for a PostgreSQL version from hostdb
170
+ *
171
+ * @param version - Full version (e.g., '17.7.0')
172
+ * @param platform - Platform identifier (e.g., 'darwin', 'linux', 'win32')
173
+ * @param arch - Architecture identifier (e.g., 'arm64', 'x64')
174
+ * @returns Download URL for the binary
175
+ */
176
+ export async function getHostdbDownloadUrl(
177
+ version: string,
178
+ platform: string,
179
+ arch: string,
180
+ ): Promise<string> {
181
+ try {
182
+ const releases = await fetchHostdbReleases()
183
+ const pgReleases = releases.databases.postgresql
184
+
185
+ // Find the version in releases
186
+ const release = pgReleases[version]
187
+ if (!release) {
188
+ throw new Error(`Version ${version} not found in hostdb releases`)
189
+ }
190
+
191
+ // Map Node.js platform names to hostdb platform names
192
+ const platformKey = `${platform}-${arch}`
193
+ const hostdbPlatform = mapPlatformToHostdb(platformKey)
194
+
195
+ // Get the platform-specific download URL
196
+ const platformData = release.platforms[hostdbPlatform]
197
+ if (!platformData) {
198
+ throw new Error(
199
+ `Platform ${hostdbPlatform} not available for PostgreSQL ${version}`,
200
+ )
201
+ }
202
+
203
+ return platformData.url
204
+ } catch {
205
+ // Fallback to constructing URL manually if fetch fails
206
+ const platformKey = `${platform}-${arch}`
207
+ const hostdbPlatform = mapPlatformToHostdb(platformKey)
208
+ const tag = `postgresql-${version}`
209
+ const filename = `postgresql-${version}-${hostdbPlatform}.tar.gz`
210
+
211
+ return `https://github.com/robertjbass/hostdb/releases/download/${tag}/${filename}`
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Map Node.js platform identifiers to hostdb platform identifiers
217
+ *
218
+ * @param platformKey - Node.js platform-arch key (e.g., 'darwin-arm64')
219
+ * @returns hostdb platform identifier (e.g., 'darwin-arm64')
220
+ */
221
+ function mapPlatformToHostdb(platformKey: string): string {
222
+ // hostdb uses standard platform naming, which matches Node.js
223
+ // No transformation needed unlike zonky.io which used suffixes like 'v8'
224
+ const mapping: Record<string, string> = {
225
+ 'darwin-arm64': 'darwin-arm64',
226
+ 'darwin-x64': 'darwin-x64',
227
+ 'linux-arm64': 'linux-arm64',
228
+ 'linux-x64': 'linux-x64',
229
+ 'win32-x64': 'win32-x64',
230
+ }
231
+
232
+ const result = mapping[platformKey]
233
+ if (!result) {
234
+ throw new Error(`Unsupported platform: ${platformKey}`)
235
+ }
236
+
237
+ return result
238
+ }
239
+
240
+ /**
241
+ * Check if a version is available in hostdb
242
+ *
243
+ * @param version - Version to check
244
+ * @returns true if the version exists in hostdb releases
245
+ */
246
+ export async function isVersionAvailable(version: string): Promise<boolean> {
247
+ try {
248
+ const releases = await fetchHostdbReleases()
249
+ return version in releases.databases.postgresql
250
+ } catch {
251
+ // Fallback to checking version map
252
+ // Handle both major versions ("17") and full versions ("17.7.0")
253
+ const major = version.split('.')[0]
254
+ return version in POSTGRESQL_VERSION_MAP || POSTGRESQL_VERSION_MAP[major] === version
255
+ }
256
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * PostgreSQL Version Maps
3
3
  *
4
- * Shared version mappings used by both zonky.io (macOS/Linux) and EDB (Windows).
4
+ * Shared version mappings used by both hostdb (macOS/Linux) and EDB (Windows).
5
5
  * Both sources use the same PostgreSQL releases.
6
6
  *
7
7
  * When updating versions:
8
- * 1. Check zonky.io Maven Central for new versions:
9
- * https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-darwin-arm64v8/
8
+ * 1. Check hostdb GitHub releases for new versions:
9
+ * https://github.com/robertjbass/hostdb/releases
10
10
  * 2. Check EDB download page for matching Windows versions:
11
11
  * https://www.enterprisedb.com/download-postgresql-binaries
12
12
  * 3. Update POSTGRESQL_VERSION_MAP with new full versions
@@ -15,7 +15,7 @@
15
15
 
16
16
  /**
17
17
  * Map of major PostgreSQL versions to their latest stable patch versions.
18
- * Used for both zonky.io (macOS/Linux) and EDB (Windows) binaries.
18
+ * Used for both hostdb (macOS/Linux) and EDB (Windows) binaries.
19
19
  */
20
20
  export const POSTGRESQL_VERSION_MAP: Record<string, string> = {
21
21
  '14': '14.20.0',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "description": "Zero-config Docker-free local database containers. Create, backup, and clone PostgreSQL, MySQL, MongoDB, Redis, and SQLite instances.",
5
5
  "type": "module",
6
6
  "bin": {