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.
@@ -5,11 +5,13 @@ import { promisify } from 'util'
5
5
  import { dirname } from 'path'
6
6
  import { paths } from '../config/paths'
7
7
  import { logDebug, logWarning } from './error-handler'
8
+ import { platformService } from './platform-service'
8
9
  import type {
9
10
  SpinDBConfig,
10
11
  BinaryConfig,
11
12
  BinaryTool,
12
13
  BinarySource,
14
+ SQLiteEngineRegistry,
13
15
  } from '../types'
14
16
 
15
17
  const execAsync = promisify(exec)
@@ -164,73 +166,12 @@ export class ConfigManager {
164
166
 
165
167
  /**
166
168
  * Detect a binary on the system PATH
169
+ * Uses platformService for cross-platform detection (handles which/where and .exe extension)
167
170
  */
168
171
  async detectSystemBinary(tool: BinaryTool): Promise<string | null> {
169
- try {
170
- const { stdout } = await execAsync(`which ${tool}`)
171
- const path = stdout.trim()
172
- if (path && existsSync(path)) {
173
- return path
174
- }
175
- } catch (error) {
176
- logDebug('which command failed for binary detection', {
177
- tool,
178
- error: error instanceof Error ? error.message : String(error),
179
- })
180
- }
181
-
182
- // Check common locations
183
- const commonPaths = this.getCommonBinaryPaths(tool)
184
- for (const path of commonPaths) {
185
- if (existsSync(path)) {
186
- return path
187
- }
188
- }
189
-
190
- return null
191
- }
192
-
193
- /**
194
- * Get common installation paths for database tools
195
- */
196
- private getCommonBinaryPaths(tool: BinaryTool): string[] {
197
- const commonPaths: string[] = []
198
-
199
- // Homebrew (macOS ARM)
200
- commonPaths.push(`/opt/homebrew/bin/${tool}`)
201
- // Homebrew (macOS Intel)
202
- commonPaths.push(`/usr/local/bin/${tool}`)
203
-
204
- // PostgreSQL-specific paths
205
- if (POSTGRESQL_TOOLS.includes(tool) || tool === 'pgcli') {
206
- commonPaths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
207
- commonPaths.push(`/usr/local/opt/libpq/bin/${tool}`)
208
- // Postgres.app (macOS)
209
- commonPaths.push(
210
- `/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
211
- )
212
- // Linux PostgreSQL paths
213
- commonPaths.push(`/usr/lib/postgresql/17/bin/${tool}`)
214
- commonPaths.push(`/usr/lib/postgresql/16/bin/${tool}`)
215
- commonPaths.push(`/usr/lib/postgresql/15/bin/${tool}`)
216
- commonPaths.push(`/usr/lib/postgresql/14/bin/${tool}`)
217
- }
218
-
219
- // MySQL-specific paths
220
- if (MYSQL_TOOLS.includes(tool) || tool === 'mycli') {
221
- commonPaths.push(`/opt/homebrew/opt/mysql/bin/${tool}`)
222
- commonPaths.push(`/opt/homebrew/opt/mysql-client/bin/${tool}`)
223
- commonPaths.push(`/usr/local/opt/mysql/bin/${tool}`)
224
- commonPaths.push(`/usr/local/opt/mysql-client/bin/${tool}`)
225
- // Linux MySQL/MariaDB paths
226
- commonPaths.push(`/usr/bin/${tool}`)
227
- commonPaths.push(`/usr/sbin/${tool}`)
228
- }
229
-
230
- // General Linux paths
231
- commonPaths.push(`/usr/bin/${tool}`)
232
-
233
- return commonPaths
172
+ // Use platformService which handles cross-platform differences
173
+ // (which vs where, .exe extension, platform-specific search paths)
174
+ return platformService.findToolPath(tool)
234
175
  }
235
176
 
236
177
  /**
@@ -349,6 +290,37 @@ export class ConfigManager {
349
290
  config.binaries = {}
350
291
  await this.save()
351
292
  }
293
+
294
+ // ============================================================
295
+ // SQLite Registry Methods
296
+ // ============================================================
297
+
298
+ /**
299
+ * Get the SQLite registry from config
300
+ * Returns empty registry if none exists
301
+ */
302
+ async getSqliteRegistry(): Promise<SQLiteEngineRegistry> {
303
+ const config = await this.load()
304
+ return (
305
+ config.registry?.sqlite ?? {
306
+ version: 1,
307
+ entries: [],
308
+ ignoreFolders: {},
309
+ }
310
+ )
311
+ }
312
+
313
+ /**
314
+ * Save the SQLite registry to config
315
+ */
316
+ async saveSqliteRegistry(registry: SQLiteEngineRegistry): Promise<void> {
317
+ const config = await this.load()
318
+ if (!config.registry) {
319
+ config.registry = {}
320
+ }
321
+ config.registry.sqlite = registry
322
+ await this.save()
323
+ }
352
324
  }
353
325
 
354
326
  export const configManager = new ConfigManager()
@@ -22,9 +22,37 @@ import {
22
22
  } from '../config/os-dependencies'
23
23
  import { platformService } from './platform-service'
24
24
  import { configManager } from './config-manager'
25
+ import type { BinaryTool } from '../types'
25
26
 
26
27
  const execAsync = promisify(exec)
27
28
 
29
+ /**
30
+ * Known binary tools that can be registered in config
31
+ */
32
+ const KNOWN_BINARY_TOOLS: readonly BinaryTool[] = [
33
+ 'psql',
34
+ 'pg_dump',
35
+ 'pg_restore',
36
+ 'pg_basebackup',
37
+ 'mysql',
38
+ 'mysqldump',
39
+ 'mysqlpump',
40
+ 'mysqld',
41
+ 'mysqladmin',
42
+ 'sqlite3',
43
+ 'pgcli',
44
+ 'mycli',
45
+ 'litecli',
46
+ 'usql',
47
+ ] as const
48
+
49
+ /**
50
+ * Type guard to check if a string is a known BinaryTool
51
+ */
52
+ function isBinaryTool(binary: string): binary is BinaryTool {
53
+ return KNOWN_BINARY_TOOLS.includes(binary as BinaryTool)
54
+ }
55
+
28
56
  export type DependencyStatus = {
29
57
  dependency: Dependency
30
58
  installed: boolean
@@ -79,7 +107,17 @@ export async function findBinary(
79
107
  binary: string,
80
108
  ): Promise<{ path: string; version?: string } | null> {
81
109
  try {
82
- // Use platformService to find the binary path
110
+ // First check if we have this binary registered in config (e.g., from downloaded PostgreSQL)
111
+ if (isBinaryTool(binary)) {
112
+ const configPath = await configManager.getBinaryPath(binary)
113
+ if (configPath) {
114
+ const version =
115
+ (await platformService.getToolVersion(configPath)) || undefined
116
+ return { path: configPath, version }
117
+ }
118
+ }
119
+
120
+ // Fall back to system PATH search
83
121
  const path = await platformService.findToolPath(binary)
84
122
  if (!path) return null
85
123
 
@@ -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
+ }