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.
- package/cli/commands/engines.ts +102 -17
- package/cli/commands/start.ts +47 -1
- package/cli/commands/stop.ts +76 -2
- package/config/defaults.ts +7 -7
- package/core/binary-manager.ts +130 -73
- package/core/process-manager.ts +66 -0
- package/engines/postgresql/binary-urls.ts +84 -111
- package/engines/postgresql/hostdb-releases.ts +256 -0
- package/engines/postgresql/version-maps.ts +4 -4
- package/package.json +1 -1
package/cli/commands/engines.ts
CHANGED
|
@@ -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
|
-
|
|
291
|
-
|
|
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()
|
package/cli/commands/start.ts
CHANGED
|
@@ -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
|
|
package/cli/commands/stop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/config/defaults.ts
CHANGED
|
@@ -45,12 +45,12 @@ export const defaults: Defaults = {
|
|
|
45
45
|
engine: Engine.PostgreSQL,
|
|
46
46
|
superuser: pgDefaults.superuser,
|
|
47
47
|
platformMappings: {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'win32-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
|
}
|
package/core/binary-manager.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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.
|
|
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:
|
|
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
|
|
258
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
//
|
|
291
|
-
await
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
526
|
+
await spawnAsync('tar', ['-xJf', dataFile, '-C', extractDir])
|
|
494
527
|
} else if (dataFile.endsWith('.zst')) {
|
|
495
|
-
await
|
|
528
|
+
await spawnAsync('tar', ['--zstd', '-xf', dataFile, '-C', extractDir])
|
|
496
529
|
} else if (dataFile.endsWith('.gz')) {
|
|
497
|
-
await
|
|
530
|
+
await spawnAsync('tar', ['-xzf', dataFile, '-C', extractDir])
|
|
498
531
|
} else {
|
|
499
|
-
await
|
|
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(
|
|
578
|
+
throw new Error(
|
|
579
|
+
`Failed to fetch package index from ${indexUrl}: HTTP ${response.status} ${response.statusText}`,
|
|
580
|
+
)
|
|
545
581
|
}
|
|
546
582
|
|
|
547
|
-
|
|
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
|
-
//
|
|
560
|
-
if (
|
|
561
|
-
|
|
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
|
-
|
|
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
|
|
package/core/process-manager.ts
CHANGED
|
@@ -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 {
|
|
2
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
38
|
+
* Get the latest version for a major version from hostdb
|
|
84
39
|
*/
|
|
85
|
-
function
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
128
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
//
|
|
152
|
-
const fullVersion =
|
|
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://
|
|
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(
|
|
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
|
|
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
|
|
9
|
-
* https://
|
|
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
|
|
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