spindb 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Cross-platform file following utility
3
+ *
4
+ * Replaces Unix `tail -f` with Node.js fs.watch for cross-platform support.
5
+ * Works on Windows, macOS, and Linux.
6
+ */
7
+
8
+ import { watch, createReadStream } from 'fs'
9
+ import { readFile, stat } from 'fs/promises'
10
+ import { createInterface } from 'readline'
11
+
12
+ /**
13
+ * Get the last N lines from a string
14
+ */
15
+ export function getLastNLines(content: string, n: number): string {
16
+ const lines = content.split('\n')
17
+ const nonEmptyLines =
18
+ lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
19
+ return nonEmptyLines.slice(-n).join('\n')
20
+ }
21
+
22
+ /**
23
+ * Follow a file and stream new content to stdout
24
+ *
25
+ * Uses Node.js fs.watch to monitor file changes and streams new content.
26
+ * Handles SIGINT (Ctrl+C) gracefully and cleans up resources.
27
+ *
28
+ * @param filePath - Path to the file to follow
29
+ * @param initialLines - Number of lines to display initially
30
+ * @returns Promise that resolves when following is stopped (via SIGINT)
31
+ */
32
+ export async function followFile(
33
+ filePath: string,
34
+ initialLines: number,
35
+ ): Promise<void> {
36
+ // Read and display initial content
37
+ const content = await readFile(filePath, 'utf-8')
38
+ const initial = getLastNLines(content, initialLines)
39
+ if (initial) {
40
+ console.log(initial)
41
+ }
42
+
43
+ // Track file position - use byte length of content we already read
44
+ // This eliminates race condition: we start exactly where the initial read ended
45
+ let fileSize = Buffer.byteLength(content, 'utf-8')
46
+
47
+ return new Promise((resolve) => {
48
+ let settled = false
49
+
50
+ // Watch for changes
51
+ const watcher = watch(filePath, async (eventType) => {
52
+ if (eventType === 'change') {
53
+ try {
54
+ const newSize = (await stat(filePath)).size
55
+
56
+ if (newSize > fileSize) {
57
+ // Read only the new content
58
+ const stream = createReadStream(filePath, {
59
+ start: fileSize,
60
+ encoding: 'utf-8',
61
+ })
62
+
63
+ const rl = createInterface({ input: stream })
64
+
65
+ for await (const line of rl) {
66
+ console.log(line)
67
+ }
68
+
69
+ fileSize = newSize
70
+ } else if (newSize < fileSize) {
71
+ // File was truncated (log rotation), reset position
72
+ fileSize = newSize
73
+ }
74
+ } catch {
75
+ // File might be temporarily unavailable, ignore
76
+ }
77
+ }
78
+ })
79
+
80
+ const cleanup = () => {
81
+ if (!settled) {
82
+ settled = true
83
+ watcher.close()
84
+ process.off('SIGINT', handleSigint)
85
+ resolve()
86
+ }
87
+ }
88
+
89
+ const handleSigint = () => {
90
+ cleanup()
91
+ }
92
+
93
+ process.on('SIGINT', handleSigint)
94
+ })
95
+ }
@@ -49,5 +49,8 @@ export const defaults: Defaults = {
49
49
  'darwin-x64': 'darwin-amd64',
50
50
  'linux-arm64': 'linux-arm64v8',
51
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',
52
55
  },
53
56
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { getPostgresHomebrewPackage } from './engine-defaults'
9
9
 
10
- export type PackageManagerId = 'brew' | 'apt' | 'yum' | 'dnf' | 'pacman'
10
+ export type PackageManagerId = 'brew' | 'apt' | 'yum' | 'dnf' | 'pacman' | 'choco' | 'winget' | 'scoop'
11
11
 
12
12
  export type Platform = 'darwin' | 'linux' | 'win32'
13
13
 
@@ -112,6 +112,30 @@ export const packageManagers: PackageManagerConfig[] = [
112
112
  installTemplate: 'sudo pacman -S --noconfirm {package}',
113
113
  updateTemplate: 'sudo pacman -Syu --noconfirm {package}',
114
114
  },
115
+ {
116
+ id: 'choco',
117
+ name: 'Chocolatey',
118
+ checkCommand: 'choco --version',
119
+ platforms: ['win32'],
120
+ installTemplate: 'choco install -y {package}',
121
+ updateTemplate: 'choco upgrade -y {package}',
122
+ },
123
+ {
124
+ id: 'winget',
125
+ name: 'Windows Package Manager',
126
+ checkCommand: 'winget --version',
127
+ platforms: ['win32'],
128
+ installTemplate: 'winget install {package}',
129
+ updateTemplate: 'winget upgrade {package}',
130
+ },
131
+ {
132
+ id: 'scoop',
133
+ name: 'Scoop',
134
+ checkCommand: 'scoop --version',
135
+ platforms: ['win32'],
136
+ installTemplate: 'scoop install {package}',
137
+ updateTemplate: 'scoop update {package}',
138
+ },
115
139
  ]
116
140
 
117
141
  // =============================================================================
@@ -141,6 +165,9 @@ function createPostgresDependency(
141
165
  yum: { package: 'postgresql' },
142
166
  dnf: { package: 'postgresql' },
143
167
  pacman: { package: 'postgresql-libs' },
168
+ choco: { package: 'postgresql' },
169
+ winget: { package: 'PostgreSQL.PostgreSQL' },
170
+ scoop: { package: 'postgresql' },
144
171
  },
145
172
  manualInstall: {
146
173
  darwin: [
@@ -154,6 +181,12 @@ function createPostgresDependency(
154
181
  'Fedora: sudo dnf install postgresql',
155
182
  'Arch: sudo pacman -S postgresql-libs',
156
183
  ],
184
+ win32: [
185
+ 'Using Chocolatey: choco install postgresql',
186
+ 'Using winget: winget install PostgreSQL.PostgreSQL',
187
+ 'Using Scoop: scoop install postgresql',
188
+ 'Or download from: https://www.enterprisedb.com/downloads/postgres-postgresql-downloads',
189
+ ],
157
190
  },
158
191
  }
159
192
  }
@@ -197,6 +230,9 @@ const mysqlDependencies: EngineDependencies = {
197
230
  brew: { package: 'mysql' },
198
231
  // Modern Debian/Ubuntu use mariadb-server (MySQL-compatible)
199
232
  apt: { package: 'mariadb-server' },
233
+ choco: { package: 'mysql' },
234
+ winget: { package: 'Oracle.MySQL' },
235
+ scoop: { package: 'mysql' },
200
236
  yum: { package: 'mariadb-server' },
201
237
  dnf: { package: 'mariadb-server' },
202
238
  pacman: { package: 'mariadb' },
@@ -212,6 +248,12 @@ const mysqlDependencies: EngineDependencies = {
212
248
  'Fedora: sudo dnf install mariadb-server',
213
249
  'Arch: sudo pacman -S mariadb',
214
250
  ],
251
+ win32: [
252
+ 'Using Chocolatey: choco install mysql',
253
+ 'Using winget: winget install Oracle.MySQL',
254
+ 'Using Scoop: scoop install mysql',
255
+ 'Or download from: https://dev.mysql.com/downloads/mysql/',
256
+ ],
215
257
  },
216
258
  },
217
259
  {
@@ -224,6 +266,9 @@ const mysqlDependencies: EngineDependencies = {
224
266
  yum: { package: 'mariadb' },
225
267
  dnf: { package: 'mariadb' },
226
268
  pacman: { package: 'mariadb-clients' },
269
+ choco: { package: 'mysql' },
270
+ winget: { package: 'Oracle.MySQL' },
271
+ scoop: { package: 'mysql' },
227
272
  },
228
273
  manualInstall: {
229
274
  darwin: [
@@ -236,6 +281,12 @@ const mysqlDependencies: EngineDependencies = {
236
281
  'Fedora: sudo dnf install mariadb',
237
282
  'Arch: sudo pacman -S mariadb-clients',
238
283
  ],
284
+ win32: [
285
+ 'Using Chocolatey: choco install mysql',
286
+ 'Using winget: winget install Oracle.MySQL',
287
+ 'Using Scoop: scoop install mysql',
288
+ 'Or download from: https://dev.mysql.com/downloads/mysql/',
289
+ ],
239
290
  },
240
291
  },
241
292
  {
@@ -248,6 +299,9 @@ const mysqlDependencies: EngineDependencies = {
248
299
  yum: { package: 'mariadb' },
249
300
  dnf: { package: 'mariadb' },
250
301
  pacman: { package: 'mariadb-clients' },
302
+ choco: { package: 'mysql' },
303
+ winget: { package: 'Oracle.MySQL' },
304
+ scoop: { package: 'mysql' },
251
305
  },
252
306
  manualInstall: {
253
307
  darwin: [
@@ -260,6 +314,12 @@ const mysqlDependencies: EngineDependencies = {
260
314
  'Fedora: sudo dnf install mariadb',
261
315
  'Arch: sudo pacman -S mariadb-clients',
262
316
  ],
317
+ win32: [
318
+ 'Using Chocolatey: choco install mysql',
319
+ 'Using winget: winget install Oracle.MySQL',
320
+ 'Using Scoop: scoop install mysql',
321
+ 'Or download from: https://dev.mysql.com/downloads/mysql/',
322
+ ],
263
323
  },
264
324
  },
265
325
  {
@@ -272,6 +332,9 @@ const mysqlDependencies: EngineDependencies = {
272
332
  yum: { package: 'mariadb' },
273
333
  dnf: { package: 'mariadb' },
274
334
  pacman: { package: 'mariadb-clients' },
335
+ choco: { package: 'mysql' },
336
+ winget: { package: 'Oracle.MySQL' },
337
+ scoop: { package: 'mysql' },
275
338
  },
276
339
  manualInstall: {
277
340
  darwin: [
@@ -284,6 +347,12 @@ const mysqlDependencies: EngineDependencies = {
284
347
  'Fedora: sudo dnf install mariadb',
285
348
  'Arch: sudo pacman -S mariadb-clients',
286
349
  ],
350
+ win32: [
351
+ 'Using Chocolatey: choco install mysql',
352
+ 'Using winget: winget install Oracle.MySQL',
353
+ 'Using Scoop: scoop install mysql',
354
+ 'Or download from: https://dev.mysql.com/downloads/mysql/',
355
+ ],
287
356
  },
288
357
  },
289
358
  ],
@@ -307,6 +376,9 @@ const sqliteDependencies: EngineDependencies = {
307
376
  yum: { package: 'sqlite' },
308
377
  dnf: { package: 'sqlite' },
309
378
  pacman: { package: 'sqlite' },
379
+ choco: { package: 'sqlite' },
380
+ winget: { package: 'SQLite.SQLite' },
381
+ scoop: { package: 'sqlite' },
310
382
  },
311
383
  manualInstall: {
312
384
  darwin: [
@@ -320,6 +392,12 @@ const sqliteDependencies: EngineDependencies = {
320
392
  'Fedora: sudo dnf install sqlite',
321
393
  'Arch: sudo pacman -S sqlite',
322
394
  ],
395
+ win32: [
396
+ 'Using Chocolatey: choco install sqlite',
397
+ 'Using winget: winget install SQLite.SQLite',
398
+ 'Using Scoop: scoop install sqlite',
399
+ 'Or download from: https://www.sqlite.org/download.html',
400
+ ],
323
401
  },
324
402
  },
325
403
  ],
package/config/paths.ts CHANGED
@@ -114,12 +114,4 @@ export const paths = {
114
114
  getEngineContainersPath(engine: string): string {
115
115
  return join(this.containers, engine)
116
116
  },
117
-
118
- /**
119
- * Get path for SQLite registry file
120
- * SQLite uses a registry (not container directories) since databases are stored externally
121
- */
122
- getSqliteRegistryPath(): string {
123
- return join(this.root, 'sqlite-registry.json')
124
- },
125
117
  }
@@ -1,11 +1,14 @@
1
- import { createWriteStream, existsSync } from 'fs'
2
- import { mkdir, readdir, rm, chmod } from 'fs/promises'
1
+ import { createWriteStream, existsSync, createReadStream } from 'fs'
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
5
  import { exec } from 'child_process'
6
6
  import { promisify } from 'util'
7
+ import unzipper from 'unzipper'
7
8
  import { paths } from '../config/paths'
8
9
  import { defaults } from '../config/defaults'
10
+ import { getEDBBinaryUrl } from '../engines/postgresql/edb-binary-urls'
11
+ import { normalizeVersion } from '../engines/postgresql/version-maps'
9
12
  import {
10
13
  type Engine,
11
14
  type ProgressCallback,
@@ -17,9 +20,24 @@ const execAsync = promisify(exec)
17
20
  export class BinaryManager {
18
21
  /**
19
22
  * Get the download URL for a PostgreSQL version
23
+ *
24
+ * - macOS/Linux: Uses zonky.io Maven Central binaries (JAR format)
25
+ * - Windows: Uses EDB (EnterpriseDB) official binaries (ZIP format)
20
26
  */
21
27
  getDownloadUrl(version: string, platform: string, arch: string): string {
22
28
  const platformKey = `${platform}-${arch}`
29
+
30
+ if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
31
+ throw new Error(`Unsupported platform: ${platformKey}`)
32
+ }
33
+
34
+ // Windows uses EDB binaries instead of zonky.io
35
+ if (platform === 'win32') {
36
+ const fullVersion = this.getFullVersion(version)
37
+ return getEDBBinaryUrl(fullVersion)
38
+ }
39
+
40
+ // macOS/Linux use zonky.io binaries
23
41
  const zonkyPlatform = defaults.platformMappings[platformKey]
24
42
 
25
43
  if (!zonkyPlatform) {
@@ -32,30 +50,13 @@ export class BinaryManager {
32
50
  }
33
51
 
34
52
  /**
35
- * Convert version to full version format (e.g., "16" -> "16.6.0", "16.9" -> "16.9.0")
53
+ * Convert version to full version format (e.g., "16" -> "16.11.0", "16.9" -> "16.9.0")
54
+ *
55
+ * Uses the shared version mappings from version-maps.ts.
56
+ * Both zonky.io (macOS/Linux) and EDB (Windows) use the same PostgreSQL versions.
36
57
  */
37
58
  getFullVersion(version: string): string {
38
- // Map major versions to latest stable patch versions
39
- // Updated from: https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-darwin-arm64v8/
40
- const versionMap: Record<string, string> = {
41
- '14': '14.20.0',
42
- '15': '15.15.0',
43
- '16': '16.11.0',
44
- '17': '17.7.0',
45
- }
46
-
47
- // If it's a major version only, use the map
48
- if (versionMap[version]) {
49
- return versionMap[version]
50
- }
51
-
52
- // Normalize to X.Y.Z format
53
- const parts = version.split('.')
54
- if (parts.length === 2) {
55
- return `${version}.0`
56
- }
57
-
58
- return version
59
+ return normalizeVersion(version)
59
60
  }
60
61
 
61
62
  /**
@@ -74,7 +75,8 @@ export class BinaryManager {
74
75
  platform,
75
76
  arch,
76
77
  })
77
- const postgresPath = join(binPath, 'bin', 'postgres')
78
+ const ext = platform === 'win32' ? '.exe' : ''
79
+ const postgresPath = join(binPath, 'bin', `postgres${ext}`)
78
80
  return existsSync(postgresPath)
79
81
  }
80
82
 
@@ -110,8 +112,9 @@ export class BinaryManager {
110
112
  /**
111
113
  * Download and extract PostgreSQL binaries
112
114
  *
113
- * The zonky.io JAR files are ZIP archives containing a .txz (tar.xz) file.
114
- * We need to: 1) unzip the JAR, 2) extract the .txz inside
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
117
+ * - Windows (EDB): ZIP files extract directly to a PostgreSQL directory structure
115
118
  */
116
119
  async download(
117
120
  version: string,
@@ -128,7 +131,10 @@ export class BinaryManager {
128
131
  arch,
129
132
  })
130
133
  const tempDir = join(paths.bin, `temp-${fullVersion}-${platform}-${arch}`)
131
- const jarFile = join(tempDir, 'postgres.jar')
134
+ const archiveFile = join(
135
+ tempDir,
136
+ platform === 'win32' ? 'postgres.zip' : 'postgres.jar',
137
+ )
132
138
 
133
139
  // Ensure directories exist
134
140
  await mkdir(paths.bin, { recursive: true })
@@ -136,7 +142,7 @@ export class BinaryManager {
136
142
  await mkdir(binPath, { recursive: true })
137
143
 
138
144
  try {
139
- // Download the JAR file
145
+ // Download the archive
140
146
  onProgress?.({
141
147
  stage: 'downloading',
142
148
  message: 'Downloading PostgreSQL binaries...',
@@ -149,42 +155,36 @@ export class BinaryManager {
149
155
  )
150
156
  }
151
157
 
152
- const fileStream = createWriteStream(jarFile)
158
+ const fileStream = createWriteStream(archiveFile)
153
159
  // @ts-expect-error - response.body is ReadableStream
154
160
  await pipeline(response.body, fileStream)
155
161
 
156
- // Extract the JAR (it's a ZIP file)
157
- onProgress?.({
158
- stage: 'extracting',
159
- message: 'Extracting binaries (step 1/2)...',
160
- })
161
-
162
- await execAsync(`unzip -q -o "${jarFile}" -d "${tempDir}"`)
163
-
164
- // Find and extract the .txz file inside
165
- onProgress?.({
166
- stage: 'extracting',
167
- message: 'Extracting binaries (step 2/2)...',
168
- })
169
-
170
- const { stdout: findOutput } = await execAsync(
171
- `find "${tempDir}" -name "*.txz" -o -name "*.tar.xz" | head -1`,
172
- )
173
- const txzFile = findOutput.trim()
174
-
175
- if (!txzFile) {
176
- throw new Error('Could not find .txz file in downloaded archive')
162
+ if (platform === 'win32') {
163
+ // Windows: EDB ZIP extracts directly to PostgreSQL structure
164
+ await this.extractWindowsBinaries(
165
+ archiveFile,
166
+ binPath,
167
+ tempDir,
168
+ onProgress,
169
+ )
170
+ } else {
171
+ // macOS/Linux: zonky.io JAR contains .txz that needs secondary extraction
172
+ await this.extractUnixBinaries(
173
+ archiveFile,
174
+ binPath,
175
+ tempDir,
176
+ onProgress,
177
+ )
177
178
  }
178
179
 
179
- // Extract the tar.xz file (no strip-components since files are at root level)
180
- await execAsync(`tar -xJf "${txzFile}" -C "${binPath}"`)
181
-
182
- // Make binaries executable
183
- const binDir = join(binPath, 'bin')
184
- if (existsSync(binDir)) {
185
- const binaries = await readdir(binDir)
186
- for (const binary of binaries) {
187
- await chmod(join(binDir, binary), 0o755)
180
+ // Make binaries executable (on Unix-like systems)
181
+ if (platform !== 'win32') {
182
+ const binDir = join(binPath, 'bin')
183
+ if (existsSync(binDir)) {
184
+ const binaries = await readdir(binDir)
185
+ for (const binary of binaries) {
186
+ await chmod(join(binDir, binary), 0o755)
187
+ }
188
188
  }
189
189
  }
190
190
 
@@ -199,6 +199,119 @@ export class BinaryManager {
199
199
  }
200
200
  }
201
201
 
202
+ /**
203
+ * Extract Windows binaries from EDB ZIP file
204
+ * EDB ZIPs contain a pgsql/ directory with bin/, lib/, share/ etc.
205
+ */
206
+ private async extractWindowsBinaries(
207
+ zipFile: string,
208
+ binPath: string,
209
+ tempDir: string,
210
+ onProgress?: ProgressCallback,
211
+ ): Promise<void> {
212
+ onProgress?.({
213
+ stage: 'extracting',
214
+ message: 'Extracting binaries...',
215
+ })
216
+
217
+ // Extract ZIP to temp directory first
218
+ await new Promise<void>((resolve, reject) => {
219
+ createReadStream(zipFile)
220
+ .pipe(unzipper.Extract({ path: tempDir }))
221
+ .on('close', resolve)
222
+ .on('error', reject)
223
+ })
224
+
225
+ // EDB ZIPs have a pgsql/ directory - find it and move contents to binPath
226
+ const entries = await readdir(tempDir, { withFileTypes: true })
227
+ const pgsqlDir = entries.find(
228
+ (e) =>
229
+ e.isDirectory() &&
230
+ (e.name === 'pgsql' || e.name.startsWith('postgresql-')),
231
+ )
232
+
233
+ if (pgsqlDir) {
234
+ // Move contents from pgsql/ to binPath using cross-platform Node.js fs methods
235
+ const sourceDir = join(tempDir, pgsqlDir.name)
236
+ const sourceEntries = await readdir(sourceDir, { withFileTypes: true })
237
+ for (const entry of sourceEntries) {
238
+ const sourcePath = join(sourceDir, entry.name)
239
+ const destPath = join(binPath, entry.name)
240
+ try {
241
+ // Try rename first (works if on same filesystem)
242
+ await rename(sourcePath, destPath)
243
+ } catch {
244
+ // Fallback to recursive copy for cross-filesystem moves
245
+ await cp(sourcePath, destPath, { recursive: true })
246
+ }
247
+ }
248
+ } else {
249
+ // No pgsql directory, extract contents directly
250
+ throw new Error(
251
+ 'Unexpected EDB archive structure - no pgsql directory found',
252
+ )
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Extract Unix binaries from zonky.io JAR file
258
+ * JAR contains a .txz (tar.xz) file that needs secondary extraction
259
+ */
260
+ private async extractUnixBinaries(
261
+ jarFile: string,
262
+ binPath: string,
263
+ tempDir: string,
264
+ onProgress?: ProgressCallback,
265
+ ): Promise<void> {
266
+ // Extract the JAR (it's a ZIP file) using unzipper
267
+ onProgress?.({
268
+ 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)...',
283
+ })
284
+
285
+ const txzFile = await this.findTxzFile(tempDir)
286
+ if (!txzFile) {
287
+ throw new Error('Could not find .txz file in downloaded archive')
288
+ }
289
+
290
+ // Extract the tar.xz file (no strip-components since files are at root level)
291
+ await execAsync(`tar -xJf "${txzFile}" -C "${binPath}"`)
292
+ }
293
+
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
306
+ }
307
+ if (entry.isDirectory()) {
308
+ const found = await this.findTxzFile(fullPath)
309
+ if (found) return found
310
+ }
311
+ }
312
+ return null
313
+ }
314
+
202
315
  /**
203
316
  * Verify that PostgreSQL binaries are working
204
317
  */
@@ -214,7 +327,8 @@ export class BinaryManager {
214
327
  platform,
215
328
  arch,
216
329
  })
217
- const postgresPath = join(binPath, 'bin', 'postgres')
330
+ const ext = platform === 'win32' ? '.exe' : ''
331
+ const postgresPath = join(binPath, 'bin', `postgres${ext}`)
218
332
 
219
333
  if (!existsSync(postgresPath)) {
220
334
  throw new Error(`PostgreSQL binary not found at ${postgresPath}`)
@@ -229,10 +343,10 @@ export class BinaryManager {
229
343
  }
230
344
 
231
345
  const reportedVersion = match[1]
232
- // Normalize both versions for comparison (16.9.0 -> 16.9, 16 -> 16)
233
- const normalizeVersion = (v: string) => v.replace(/\.0$/, '')
234
- const expectedNormalized = normalizeVersion(version)
235
- const reportedNormalized = normalizeVersion(reportedVersion)
346
+ // Strip trailing .0 for comparison (16.9.0 -> 16.9, 16 -> 16)
347
+ const stripTrailingZero = (v: string) => v.replace(/\.0$/, '')
348
+ const expectedNormalized = stripTrailingZero(version)
349
+ const reportedNormalized = stripTrailingZero(reportedVersion)
236
350
 
237
351
  // Check if versions match (after normalization)
238
352
  if (reportedNormalized === expectedNormalized) {
@@ -271,7 +385,8 @@ export class BinaryManager {
271
385
  platform,
272
386
  arch,
273
387
  })
274
- return join(binPath, 'bin', binary)
388
+ const ext = platform === 'win32' ? '.exe' : ''
389
+ return join(binPath, 'bin', `${binary}${ext}`)
275
390
  }
276
391
 
277
392
  /**