spindb 0.9.3 → 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.
@@ -121,6 +121,28 @@ export abstract class BasePlatformService {
121
121
  */
122
122
  abstract getZonkyPlatform(): string | null
123
123
 
124
+ /**
125
+ * Get the null device path for this platform ('/dev/null' on Unix, 'NUL' on Windows)
126
+ */
127
+ abstract getNullDevice(): string
128
+
129
+ /**
130
+ * Get the executable file extension for this platform ('' on Unix, '.exe' on Windows)
131
+ */
132
+ abstract getExecutableExtension(): string
133
+
134
+ /**
135
+ * Terminate a process by PID
136
+ * @param pid - Process ID to terminate
137
+ * @param force - If true, force kill (SIGKILL on Unix, /F on Windows)
138
+ */
139
+ abstract terminateProcess(pid: number, force: boolean): Promise<void>
140
+
141
+ /**
142
+ * Check if a process is running by PID
143
+ */
144
+ abstract isProcessRunning(pid: number): boolean
145
+
124
146
  /**
125
147
  * Copy text to clipboard
126
148
  */
@@ -153,13 +175,17 @@ export abstract class BasePlatformService {
153
175
  async findToolPath(toolName: string): Promise<string | null> {
154
176
  const whichConfig = this.getWhichCommand()
155
177
 
156
- // First try the which/where command
178
+ // First try the which/where command (with timeout to prevent hanging)
157
179
  try {
158
- const { stdout } = await execAsync(`${whichConfig.command} ${toolName}`)
159
- const path = stdout.trim().split('\n')[0]
160
- if (path && existsSync(path)) {
161
- return path
162
- }
180
+ const cmd = [whichConfig.command, ...whichConfig.args, toolName]
181
+ .filter(Boolean)
182
+ .join(' ')
183
+ const { stdout } = await execAsync(cmd, { timeout: 5000 })
184
+ const path = stdout
185
+ .split(/\r?\n/)
186
+ .map((line) => line.trim())
187
+ .find((line) => line.length > 0)
188
+ if (path && existsSync(path)) return path
163
189
  } catch {
164
190
  // Not found via which, continue to search paths
165
191
  }
@@ -186,7 +212,9 @@ export abstract class BasePlatformService {
186
212
  */
187
213
  async getToolVersion(toolPath: string): Promise<string | null> {
188
214
  try {
189
- const { stdout } = await execAsync(`"${toolPath}" --version`)
215
+ const { stdout } = await execAsync(`"${toolPath}" --version`, {
216
+ timeout: 5000,
217
+ })
190
218
  const match = stdout.match(/(\d+\.\d+(\.\d+)?)/)
191
219
  return match ? match[1] : null
192
220
  } catch {
@@ -332,6 +360,28 @@ class DarwinPlatformService extends BasePlatformService {
332
360
  return null
333
361
  }
334
362
 
363
+ getNullDevice(): string {
364
+ return '/dev/null'
365
+ }
366
+
367
+ getExecutableExtension(): string {
368
+ return ''
369
+ }
370
+
371
+ async terminateProcess(pid: number, force: boolean): Promise<void> {
372
+ const signal = force ? 'SIGKILL' : 'SIGTERM'
373
+ process.kill(pid, signal)
374
+ }
375
+
376
+ isProcessRunning(pid: number): boolean {
377
+ try {
378
+ process.kill(pid, 0)
379
+ return true
380
+ } catch {
381
+ return false
382
+ }
383
+ }
384
+
335
385
  protected buildToolPath(dir: string, toolName: string): string {
336
386
  return `${dir}/${toolName}`
337
387
  }
@@ -517,6 +567,28 @@ class LinuxPlatformService extends BasePlatformService {
517
567
  return null
518
568
  }
519
569
 
570
+ getNullDevice(): string {
571
+ return '/dev/null'
572
+ }
573
+
574
+ getExecutableExtension(): string {
575
+ return ''
576
+ }
577
+
578
+ async terminateProcess(pid: number, force: boolean): Promise<void> {
579
+ const signal = force ? 'SIGKILL' : 'SIGTERM'
580
+ process.kill(pid, signal)
581
+ }
582
+
583
+ isProcessRunning(pid: number): boolean {
584
+ try {
585
+ process.kill(pid, 0)
586
+ return true
587
+ } catch {
588
+ return false
589
+ }
590
+ }
591
+
520
592
  protected buildToolPath(dir: string, toolName: string): string {
521
593
  return `${dir}/${toolName}`
522
594
  }
@@ -591,9 +663,12 @@ class Win32PlatformService extends BasePlatformService {
591
663
  }
592
664
 
593
665
  async detectPackageManager(): Promise<PackageManagerInfo | null> {
666
+ // Timeout for package manager detection (5 seconds)
667
+ const timeout = 5000
668
+
594
669
  // Try chocolatey
595
670
  try {
596
- await execAsync('choco --version')
671
+ await execAsync('choco --version', { timeout })
597
672
  return {
598
673
  id: 'choco',
599
674
  name: 'Chocolatey',
@@ -602,12 +677,12 @@ class Win32PlatformService extends BasePlatformService {
602
677
  updateCommand: 'choco upgrade all',
603
678
  }
604
679
  } catch {
605
- // Not chocolatey
680
+ // Not chocolatey or timed out
606
681
  }
607
682
 
608
683
  // Try winget
609
684
  try {
610
- await execAsync('winget --version')
685
+ await execAsync('winget --version', { timeout })
611
686
  return {
612
687
  id: 'winget',
613
688
  name: 'Windows Package Manager',
@@ -616,7 +691,21 @@ class Win32PlatformService extends BasePlatformService {
616
691
  updateCommand: 'winget upgrade --all',
617
692
  }
618
693
  } catch {
619
- // Not winget
694
+ // Not winget or timed out
695
+ }
696
+
697
+ // Try scoop
698
+ try {
699
+ await execAsync('scoop --version', { timeout })
700
+ return {
701
+ id: 'scoop',
702
+ name: 'Scoop',
703
+ checkCommand: 'scoop --version',
704
+ installTemplate: 'scoop install {package}',
705
+ updateCommand: 'scoop update *',
706
+ }
707
+ } catch {
708
+ // Not scoop or timed out
620
709
  }
621
710
 
622
711
  return null
@@ -627,6 +716,40 @@ class Win32PlatformService extends BasePlatformService {
627
716
  return null
628
717
  }
629
718
 
719
+ getNullDevice(): string {
720
+ return 'NUL'
721
+ }
722
+
723
+ getExecutableExtension(): string {
724
+ return '.exe'
725
+ }
726
+
727
+ async terminateProcess(pid: number, force: boolean): Promise<void> {
728
+ // On Windows, use taskkill command
729
+ // /T = terminate child processes, /F = force termination
730
+ const args = force ? `/F /PID ${pid} /T` : `/PID ${pid}`
731
+ try {
732
+ await execAsync(`taskkill ${args}`)
733
+ } catch (error) {
734
+ // taskkill exits with error if process doesn't exist, which is fine
735
+ const e = error as { code?: number }
736
+ // Error code 128 means "process not found" which is acceptable
737
+ if (e.code !== 128) {
738
+ throw error
739
+ }
740
+ }
741
+ }
742
+
743
+ isProcessRunning(pid: number): boolean {
744
+ try {
745
+ // process.kill with signal 0 works on Windows for checking process existence
746
+ process.kill(pid, 0)
747
+ return true
748
+ } catch {
749
+ return false
750
+ }
751
+ }
752
+
630
753
  protected buildToolPath(dir: string, toolName: string): string {
631
754
  return `${dir}\\${toolName}.exe`
632
755
  }
@@ -652,3 +775,18 @@ export function createPlatformService(): BasePlatformService {
652
775
 
653
776
  // Export singleton instance for convenience
654
777
  export const platformService = createPlatformService()
778
+
779
+ /**
780
+ * Check if running on Windows
781
+ */
782
+ export function isWindows(): boolean {
783
+ return process.platform === 'win32'
784
+ }
785
+
786
+ /**
787
+ * Get spawn options for Windows shell requirements.
788
+ * Windows needs shell:true for proper command execution with quoted paths.
789
+ */
790
+ export function getWindowsSpawnOptions(): { shell: true } | Record<string, never> {
791
+ return isWindows() ? { shell: true } : {}
792
+ }
@@ -1,9 +1,14 @@
1
- import { exec, spawn } from 'child_process'
1
+ import { exec, spawn, type SpawnOptions } from 'child_process'
2
2
  import { promisify } from 'util'
3
3
  import { existsSync } from 'fs'
4
4
  import { readFile, rm } from 'fs/promises'
5
5
  import { paths } from '../config/paths'
6
6
  import { logDebug } from './error-handler'
7
+ import {
8
+ platformService,
9
+ isWindows,
10
+ getWindowsSpawnOptions,
11
+ } from './platform-service'
7
12
  import type { ProcessResult, StatusResult } from '../types'
8
13
 
9
14
  const execAsync = promisify(exec)
@@ -41,23 +46,9 @@ export class ProcessManager {
41
46
  options: InitdbOptions = {},
42
47
  ): Promise<ProcessResult> {
43
48
  const { superuser = 'postgres' } = options
44
-
45
- // Track if directory existed before initdb (to know if we should clean up)
46
49
  const dirExistedBefore = existsSync(dataDir)
47
50
 
48
- const args = [
49
- '-D',
50
- dataDir,
51
- '-U',
52
- superuser,
53
- '--auth=trust',
54
- '--encoding=UTF8',
55
- '--no-locale',
56
- ]
57
-
58
- // Helper to clean up data directory on failure
59
51
  const cleanupOnFailure = async () => {
60
- // Only clean up if initdb created the directory (it didn't exist before)
61
52
  if (!dirExistedBefore && existsSync(dataDir)) {
62
53
  try {
63
54
  await rm(dataDir, { recursive: true, force: true })
@@ -70,6 +61,38 @@ export class ProcessManager {
70
61
  }
71
62
  }
72
63
 
64
+ if (isWindows()) {
65
+ // On Windows, build the entire command as a single string
66
+ const cmd = `"${initdbPath}" -D "${dataDir}" -U ${superuser} --auth=trust --encoding=UTF8 --no-locale`
67
+
68
+ logDebug('initdb command (Windows)', { cmd })
69
+
70
+ return new Promise((resolve, reject) => {
71
+ exec(cmd, { timeout: 120000 }, async (error, stdout, stderr) => {
72
+ logDebug('initdb completed', { error: error?.message, stdout, stderr })
73
+ if (error) {
74
+ await cleanupOnFailure()
75
+ reject(new Error(`initdb failed with code ${error.code}: ${stderr || stdout || error.message}`))
76
+ } else {
77
+ resolve({ stdout, stderr })
78
+ }
79
+ })
80
+ })
81
+ }
82
+
83
+ // Unix path - use spawn without shell
84
+ const args = [
85
+ '-D',
86
+ dataDir,
87
+ '-U',
88
+ superuser,
89
+ '--auth=trust',
90
+ '--encoding=UTF8',
91
+ '--no-locale',
92
+ ]
93
+
94
+ logDebug('initdb command', { initdbPath, args })
95
+
73
96
  return new Promise((resolve, reject) => {
74
97
  const proc = spawn(initdbPath, args, {
75
98
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -78,14 +101,15 @@ export class ProcessManager {
78
101
  let stdout = ''
79
102
  let stderr = ''
80
103
 
81
- proc.stdout.on('data', (data: Buffer) => {
104
+ proc.stdout?.on('data', (data: Buffer) => {
82
105
  stdout += data.toString()
83
106
  })
84
- proc.stderr.on('data', (data: Buffer) => {
107
+ proc.stderr?.on('data', (data: Buffer) => {
85
108
  stderr += data.toString()
86
109
  })
87
110
 
88
111
  proc.on('close', async (code) => {
112
+ logDebug('initdb completed', { code, stdout, stderr })
89
113
  if (code === 0) {
90
114
  resolve({ stdout, stderr })
91
115
  } else {
@@ -95,6 +119,7 @@ export class ProcessManager {
95
119
  })
96
120
 
97
121
  proc.on('error', async (err) => {
122
+ logDebug('initdb error', { error: err.message })
98
123
  await cleanupOnFailure()
99
124
  reject(err)
100
125
  })
@@ -110,7 +135,58 @@ export class ProcessManager {
110
135
  options: StartOptions = {},
111
136
  ): Promise<ProcessResult> {
112
137
  const { port, logFile } = options
138
+ const logDest = logFile || platformService.getNullDevice()
139
+
140
+ if (isWindows()) {
141
+ // On Windows, start without -w (wait) flag and poll for readiness
142
+ // The -w flag can hang indefinitely on Windows
143
+ let cmd = `"${pgCtlPath}" start -D "${dataDir}" -l "${logDest}"`
144
+ if (port) {
145
+ cmd += ` -o "-p ${port}"`
146
+ }
113
147
 
148
+ logDebug('pg_ctl start command (Windows)', { cmd })
149
+
150
+ return new Promise((resolve, reject) => {
151
+ exec(cmd, { timeout: 30000 }, async (error, stdout, stderr) => {
152
+ logDebug('pg_ctl start initiated', { error: error?.message, stdout, stderr })
153
+
154
+ if (error) {
155
+ reject(
156
+ new Error(
157
+ `pg_ctl start failed with code ${error.code}: ${stderr || stdout || error.message}`,
158
+ ),
159
+ )
160
+ return
161
+ }
162
+
163
+ // Poll for PostgreSQL to be ready using pg_isready or status check
164
+ const statusCmd = `"${pgCtlPath}" status -D "${dataDir}"`
165
+ let attempts = 0
166
+ const maxAttempts = 30
167
+ const pollInterval = 1000
168
+
169
+ const checkReady = () => {
170
+ attempts++
171
+ exec(statusCmd, (statusError, statusStdout) => {
172
+ if (!statusError && statusStdout.includes('server is running')) {
173
+ logDebug('pg_ctl start completed (Windows)', { attempts })
174
+ resolve({ stdout, stderr })
175
+ } else if (attempts >= maxAttempts) {
176
+ reject(new Error(`PostgreSQL failed to start within ${maxAttempts} seconds`))
177
+ } else {
178
+ setTimeout(checkReady, pollInterval)
179
+ }
180
+ })
181
+ }
182
+
183
+ // Give it a moment before starting to poll
184
+ setTimeout(checkReady, 500)
185
+ })
186
+ })
187
+ }
188
+
189
+ // Unix path - use spawn without shell
114
190
  const pgOptions: string[] = []
115
191
  if (port) {
116
192
  pgOptions.push(`-p ${port}`)
@@ -121,12 +197,18 @@ export class ProcessManager {
121
197
  '-D',
122
198
  dataDir,
123
199
  '-l',
124
- logFile || '/dev/null',
200
+ logDest,
125
201
  '-w', // Wait for startup to complete
126
- '-o',
127
- pgOptions.join(' '),
202
+ '-t',
203
+ '30', // Timeout after 30 seconds
128
204
  ]
129
205
 
206
+ if (pgOptions.length > 0) {
207
+ args.push('-o', pgOptions.join(' '))
208
+ }
209
+
210
+ logDebug('pg_ctl start command', { pgCtlPath, args })
211
+
130
212
  return new Promise((resolve, reject) => {
131
213
  const proc = spawn(pgCtlPath, args, {
132
214
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -135,14 +217,15 @@ export class ProcessManager {
135
217
  let stdout = ''
136
218
  let stderr = ''
137
219
 
138
- proc.stdout.on('data', (data: Buffer) => {
220
+ proc.stdout?.on('data', (data: Buffer) => {
139
221
  stdout += data.toString()
140
222
  })
141
- proc.stderr.on('data', (data: Buffer) => {
223
+ proc.stderr?.on('data', (data: Buffer) => {
142
224
  stderr += data.toString()
143
225
  })
144
226
 
145
227
  proc.on('close', (code) => {
228
+ logDebug('pg_ctl start completed', { code, stdout, stderr })
146
229
  if (code === 0) {
147
230
  resolve({ stdout, stderr })
148
231
  } else {
@@ -154,7 +237,10 @@ export class ProcessManager {
154
237
  }
155
238
  })
156
239
 
157
- proc.on('error', reject)
240
+ proc.on('error', (err) => {
241
+ logDebug('pg_ctl start error', { error: err.message })
242
+ reject(err)
243
+ })
158
244
  })
159
245
  }
160
246
 
@@ -162,6 +248,29 @@ export class ProcessManager {
162
248
  * Stop PostgreSQL server using pg_ctl
163
249
  */
164
250
  async stop(pgCtlPath: string, dataDir: string): Promise<ProcessResult> {
251
+ if (isWindows()) {
252
+ // On Windows, build the entire command as a single string
253
+ const cmd = `"${pgCtlPath}" stop -D "${dataDir}" -m fast -w -t 30`
254
+
255
+ logDebug('pg_ctl stop command (Windows)', { cmd })
256
+
257
+ return new Promise((resolve, reject) => {
258
+ exec(cmd, { timeout: 60000 }, (error, stdout, stderr) => {
259
+ logDebug('pg_ctl stop completed', { error: error?.message, stdout, stderr })
260
+ if (error) {
261
+ reject(
262
+ new Error(
263
+ `pg_ctl stop failed with code ${error.code}: ${stderr || stdout || error.message}`,
264
+ ),
265
+ )
266
+ } else {
267
+ resolve({ stdout, stderr })
268
+ }
269
+ })
270
+ })
271
+ }
272
+
273
+ // Unix path - use spawn without shell
165
274
  const args = [
166
275
  'stop',
167
276
  '-D',
@@ -169,8 +278,12 @@ export class ProcessManager {
169
278
  '-m',
170
279
  'fast',
171
280
  '-w', // Wait for shutdown to complete
281
+ '-t',
282
+ '30', // Timeout after 30 seconds
172
283
  ]
173
284
 
285
+ logDebug('pg_ctl stop command', { pgCtlPath, args })
286
+
174
287
  return new Promise((resolve, reject) => {
175
288
  const proc = spawn(pgCtlPath, args, {
176
289
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -179,14 +292,15 @@ export class ProcessManager {
179
292
  let stdout = ''
180
293
  let stderr = ''
181
294
 
182
- proc.stdout.on('data', (data: Buffer) => {
295
+ proc.stdout?.on('data', (data: Buffer) => {
183
296
  stdout += data.toString()
184
297
  })
185
- proc.stderr.on('data', (data: Buffer) => {
298
+ proc.stderr?.on('data', (data: Buffer) => {
186
299
  stderr += data.toString()
187
300
  })
188
301
 
189
302
  proc.on('close', (code) => {
303
+ logDebug('pg_ctl stop completed', { code, stdout, stderr })
190
304
  if (code === 0) {
191
305
  resolve({ stdout, stderr })
192
306
  } else {
@@ -198,7 +312,10 @@ export class ProcessManager {
198
312
  }
199
313
  })
200
314
 
201
- proc.on('error', reject)
315
+ proc.on('error', (err) => {
316
+ logDebug('pg_ctl stop error', { error: err.message })
317
+ reject(err)
318
+ })
202
319
  })
203
320
  }
204
321
 
@@ -240,8 +357,6 @@ export class ProcessManager {
240
357
  try {
241
358
  const content = await readFile(pidFile, 'utf8')
242
359
  const pid = parseInt(content.split('\n')[0], 10)
243
-
244
- // Check if process is still running
245
360
  process.kill(pid, 0)
246
361
  return true
247
362
  } catch (error) {
@@ -306,6 +421,7 @@ export class ProcessManager {
306
421
  return new Promise((resolve, reject) => {
307
422
  const proc = spawn(psqlPath, args, {
308
423
  stdio: command ? ['ignore', 'pipe', 'pipe'] : 'inherit',
424
+ ...getWindowsSpawnOptions(),
309
425
  })
310
426
 
311
427
  if (command) {
@@ -365,18 +481,21 @@ export class ProcessManager {
365
481
 
366
482
  args.push(backupFile)
367
483
 
484
+ const spawnOptions: SpawnOptions = {
485
+ stdio: ['ignore', 'pipe', 'pipe'],
486
+ ...getWindowsSpawnOptions(),
487
+ }
488
+
368
489
  return new Promise((resolve, reject) => {
369
- const proc = spawn(pgRestorePath, args, {
370
- stdio: ['ignore', 'pipe', 'pipe'],
371
- })
490
+ const proc = spawn(pgRestorePath, args, spawnOptions)
372
491
 
373
492
  let stdout = ''
374
493
  let stderr = ''
375
494
 
376
- proc.stdout.on('data', (data: Buffer) => {
495
+ proc.stdout?.on('data', (data: Buffer) => {
377
496
  stdout += data.toString()
378
497
  })
379
- proc.stderr.on('data', (data: Buffer) => {
498
+ proc.stderr?.on('data', (data: Buffer) => {
380
499
  stderr += data.toString()
381
500
  })
382
501
 
@@ -78,6 +78,33 @@ export abstract class BaseEngine {
78
78
  database?: string,
79
79
  ): string
80
80
 
81
+ /**
82
+ * Get the path to the psql client if available
83
+ * Default implementation throws; engines that can provide a bundled or
84
+ * configured psql should override this method.
85
+ */
86
+ async getPsqlPath(): Promise<string> {
87
+ throw new Error('psql not found')
88
+ }
89
+
90
+ /**
91
+ * Get the path to the mysql client if available
92
+ * Default implementation throws; engines that can provide a bundled or
93
+ * configured mysql should override this method.
94
+ */
95
+ async getMysqlClientPath(): Promise<string> {
96
+ throw new Error('mysql client not found')
97
+ }
98
+
99
+ /**
100
+ * Get the path to the mysqladmin client if available
101
+ * Default implementation throws; engines that can provide a bundled or
102
+ * configured mysqladmin should override this method.
103
+ */
104
+ async getMysqladminPath(): Promise<string> {
105
+ throw new Error('mysqladmin not found')
106
+ }
107
+
81
108
  /**
82
109
  * Open an interactive shell/CLI connection
83
110
  */
@@ -4,12 +4,13 @@
4
4
  * Creates database backups in SQL or compressed (.dump = gzipped SQL) format using mysqldump.
5
5
  */
6
6
 
7
- import { spawn } from 'child_process'
7
+ import { spawn, type SpawnOptions } from 'child_process'
8
8
  import { createWriteStream } from 'fs'
9
9
  import { stat } from 'fs/promises'
10
10
  import { createGzip } from 'zlib'
11
11
  import { pipeline } from 'stream/promises'
12
12
  import { getMysqldumpPath } from './binary-detection'
13
+ import { getWindowsSpawnOptions } from '../../core/platform-service'
13
14
  import { getEngineDefaults } from '../../config/defaults'
14
15
  import type { ContainerConfig, BackupOptions, BackupResult } from '../../types'
15
16
 
@@ -82,9 +83,12 @@ async function createSqlBackup(
82
83
  database,
83
84
  ]
84
85
 
85
- const proc = spawn(mysqldump, args, {
86
+ const spawnOptions: SpawnOptions = {
86
87
  stdio: ['pipe', 'pipe', 'pipe'],
87
- })
88
+ ...getWindowsSpawnOptions(),
89
+ }
90
+
91
+ const proc = spawn(mysqldump, args, spawnOptions)
88
92
 
89
93
  let stderr = ''
90
94
 
@@ -140,9 +144,12 @@ async function createCompressedBackup(
140
144
  database,
141
145
  ]
142
146
 
143
- const proc = spawn(mysqldump, args, {
147
+ const spawnOptions: SpawnOptions = {
144
148
  stdio: ['pipe', 'pipe', 'pipe'],
145
- })
149
+ ...getWindowsSpawnOptions(),
150
+ }
151
+
152
+ const proc = spawn(mysqldump, args, spawnOptions)
146
153
 
147
154
  const gzip = createGzip()
148
155
  const output = createWriteStream(outputPath)