spindb 0.4.1 → 0.5.3

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.
Files changed (44) hide show
  1. package/README.md +207 -101
  2. package/cli/commands/clone.ts +3 -1
  3. package/cli/commands/connect.ts +54 -24
  4. package/cli/commands/create.ts +309 -189
  5. package/cli/commands/delete.ts +3 -1
  6. package/cli/commands/deps.ts +19 -4
  7. package/cli/commands/edit.ts +245 -0
  8. package/cli/commands/engines.ts +434 -0
  9. package/cli/commands/info.ts +279 -0
  10. package/cli/commands/list.ts +14 -3
  11. package/cli/commands/menu.ts +510 -198
  12. package/cli/commands/restore.ts +66 -43
  13. package/cli/commands/start.ts +50 -19
  14. package/cli/commands/stop.ts +3 -1
  15. package/cli/commands/url.ts +79 -0
  16. package/cli/index.ts +9 -3
  17. package/cli/ui/prompts.ts +99 -34
  18. package/config/defaults.ts +40 -15
  19. package/config/engine-defaults.ts +107 -0
  20. package/config/os-dependencies.ts +119 -124
  21. package/config/paths.ts +82 -56
  22. package/core/binary-manager.ts +44 -6
  23. package/core/config-manager.ts +17 -5
  24. package/core/container-manager.ts +124 -60
  25. package/core/dependency-manager.ts +9 -15
  26. package/core/error-handler.ts +336 -0
  27. package/core/platform-service.ts +634 -0
  28. package/core/port-manager.ts +51 -32
  29. package/core/process-manager.ts +26 -8
  30. package/core/start-with-retry.ts +167 -0
  31. package/core/transaction-manager.ts +170 -0
  32. package/engines/index.ts +7 -2
  33. package/engines/mysql/binary-detection.ts +325 -0
  34. package/engines/mysql/index.ts +808 -0
  35. package/engines/mysql/restore.ts +257 -0
  36. package/engines/mysql/version-validator.ts +373 -0
  37. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  38. package/engines/postgresql/binary-urls.ts +5 -3
  39. package/engines/postgresql/index.ts +17 -9
  40. package/engines/postgresql/restore.ts +54 -5
  41. package/engines/postgresql/version-validator.ts +262 -0
  42. package/package.json +9 -3
  43. package/types/index.ts +29 -5
  44. package/cli/commands/postgres-tools.ts +0 -216
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Platform Service
3
+ *
4
+ * Centralizes all OS-specific detection and behavior, similar to how
5
+ * the engine abstraction handles database-specific behavior.
6
+ *
7
+ * This enables:
8
+ * - Consistent platform detection across the codebase
9
+ * - Easy mocking for unit tests
10
+ * - Simple addition of new platforms (e.g., Windows)
11
+ */
12
+
13
+ import { homedir, platform as osPlatform, arch as osArch } from 'os'
14
+ import { execSync, exec, spawn } from 'child_process'
15
+ import { promisify } from 'util'
16
+ import { existsSync } from 'fs'
17
+
18
+ const execAsync = promisify(exec)
19
+
20
+ // =============================================================================
21
+ // Types
22
+ // =============================================================================
23
+
24
+ export type Platform = 'darwin' | 'linux' | 'win32'
25
+ export type Architecture = 'arm64' | 'x64'
26
+
27
+ export type PlatformInfo = {
28
+ platform: Platform
29
+ arch: Architecture
30
+ homeDir: string
31
+ isWSL: boolean
32
+ isSudo: boolean
33
+ sudoUser: string | null
34
+ }
35
+
36
+ export type ClipboardConfig = {
37
+ copyCommand: string
38
+ copyArgs: string[]
39
+ pasteCommand: string
40
+ pasteArgs: string[]
41
+ available: boolean
42
+ }
43
+
44
+ export type WhichCommandConfig = {
45
+ command: string
46
+ args: string[]
47
+ }
48
+
49
+ export type PackageManagerInfo = {
50
+ id: string
51
+ name: string
52
+ checkCommand: string
53
+ installTemplate: string
54
+ updateCommand: string
55
+ }
56
+
57
+ // =============================================================================
58
+ // Abstract Base Class
59
+ // =============================================================================
60
+
61
+ export abstract class BasePlatformService {
62
+ protected cachedPlatformInfo: PlatformInfo | null = null
63
+
64
+ /**
65
+ * Get platform information
66
+ */
67
+ abstract getPlatformInfo(): PlatformInfo
68
+
69
+ /**
70
+ * Get clipboard configuration for this platform
71
+ */
72
+ abstract getClipboardConfig(): ClipboardConfig
73
+
74
+ /**
75
+ * Get the "which" command equivalent for this platform
76
+ */
77
+ abstract getWhichCommand(): WhichCommandConfig
78
+
79
+ /**
80
+ * Get common search paths for a tool on this platform
81
+ */
82
+ abstract getSearchPaths(tool: string): string[]
83
+
84
+ /**
85
+ * Detect available package manager
86
+ */
87
+ abstract detectPackageManager(): Promise<PackageManagerInfo | null>
88
+
89
+ /**
90
+ * Get the zonky.io platform identifier for PostgreSQL binaries
91
+ */
92
+ abstract getZonkyPlatform(): string | null
93
+
94
+ /**
95
+ * Copy text to clipboard
96
+ */
97
+ async copyToClipboard(text: string): Promise<boolean> {
98
+ const config = this.getClipboardConfig()
99
+ if (!config.available) return false
100
+
101
+ try {
102
+ await new Promise<void>((resolve, reject) => {
103
+ const proc = spawn(config.copyCommand, config.copyArgs, {
104
+ stdio: ['pipe', 'inherit', 'inherit'],
105
+ })
106
+ proc.stdin?.write(text)
107
+ proc.stdin?.end()
108
+ proc.on('close', (code) => {
109
+ if (code === 0) resolve()
110
+ else reject(new Error(`Clipboard command exited with code ${code}`))
111
+ })
112
+ proc.on('error', reject)
113
+ })
114
+ return true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Check if a tool is installed and return its path
122
+ */
123
+ async findToolPath(toolName: string): Promise<string | null> {
124
+ const whichConfig = this.getWhichCommand()
125
+
126
+ // First try the which/where command
127
+ try {
128
+ const { stdout } = await execAsync(`${whichConfig.command} ${toolName}`)
129
+ const path = stdout.trim().split('\n')[0]
130
+ if (path && existsSync(path)) {
131
+ return path
132
+ }
133
+ } catch {
134
+ // Not found via which, continue to search paths
135
+ }
136
+
137
+ // Search common installation paths
138
+ const searchPaths = this.getSearchPaths(toolName)
139
+ for (const dir of searchPaths) {
140
+ const fullPath = this.buildToolPath(dir, toolName)
141
+ if (existsSync(fullPath)) {
142
+ return fullPath
143
+ }
144
+ }
145
+
146
+ return null
147
+ }
148
+
149
+ /**
150
+ * Build the full path to a tool in a directory
151
+ */
152
+ protected abstract buildToolPath(dir: string, toolName: string): string
153
+
154
+ /**
155
+ * Get tool version by running --version
156
+ */
157
+ async getToolVersion(toolPath: string): Promise<string | null> {
158
+ try {
159
+ const { stdout } = await execAsync(`"${toolPath}" --version`)
160
+ const match = stdout.match(/(\d+\.\d+(\.\d+)?)/)
161
+ return match ? match[1] : null
162
+ } catch {
163
+ return null
164
+ }
165
+ }
166
+ }
167
+
168
+ // =============================================================================
169
+ // Darwin (macOS) Implementation
170
+ // =============================================================================
171
+
172
+ class DarwinPlatformService extends BasePlatformService {
173
+ getPlatformInfo(): PlatformInfo {
174
+ if (this.cachedPlatformInfo) return this.cachedPlatformInfo
175
+
176
+ const sudoUser = process.env.SUDO_USER || null
177
+ let homeDir: string
178
+
179
+ if (sudoUser) {
180
+ // Running under sudo - get original user's home
181
+ try {
182
+ const result = execSync(`getent passwd ${sudoUser}`, {
183
+ encoding: 'utf-8',
184
+ })
185
+ const parts = result.trim().split(':')
186
+ homeDir =
187
+ parts.length >= 6 && parts[5] ? parts[5] : `/Users/${sudoUser}`
188
+ } catch {
189
+ homeDir = `/Users/${sudoUser}`
190
+ }
191
+ } else {
192
+ homeDir = homedir()
193
+ }
194
+
195
+ this.cachedPlatformInfo = {
196
+ platform: 'darwin',
197
+ arch: osArch() as Architecture,
198
+ homeDir,
199
+ isWSL: false,
200
+ isSudo: !!sudoUser,
201
+ sudoUser,
202
+ }
203
+
204
+ return this.cachedPlatformInfo
205
+ }
206
+
207
+ getClipboardConfig(): ClipboardConfig {
208
+ return {
209
+ copyCommand: 'pbcopy',
210
+ copyArgs: [],
211
+ pasteCommand: 'pbpaste',
212
+ pasteArgs: [],
213
+ available: true, // pbcopy is always available on macOS
214
+ }
215
+ }
216
+
217
+ getWhichCommand(): WhichCommandConfig {
218
+ return {
219
+ command: 'which',
220
+ args: [],
221
+ }
222
+ }
223
+
224
+ getSearchPaths(tool: string): string[] {
225
+ const paths: string[] = []
226
+
227
+ // MySQL-specific paths
228
+ if (
229
+ tool === 'mysqld' ||
230
+ tool === 'mysql' ||
231
+ tool === 'mysqladmin' ||
232
+ tool === 'mysqldump'
233
+ ) {
234
+ paths.push(
235
+ // Homebrew (Apple Silicon)
236
+ '/opt/homebrew/bin',
237
+ '/opt/homebrew/opt/mysql/bin',
238
+ '/opt/homebrew/opt/mysql@8.0/bin',
239
+ '/opt/homebrew/opt/mysql@8.4/bin',
240
+ '/opt/homebrew/opt/mysql@5.7/bin',
241
+ // Homebrew (Intel)
242
+ '/usr/local/bin',
243
+ '/usr/local/opt/mysql/bin',
244
+ '/usr/local/opt/mysql@8.0/bin',
245
+ '/usr/local/opt/mysql@8.4/bin',
246
+ '/usr/local/opt/mysql@5.7/bin',
247
+ // Official MySQL installer
248
+ '/usr/local/mysql/bin',
249
+ )
250
+ }
251
+
252
+ // PostgreSQL-specific paths
253
+ if (
254
+ tool === 'psql' ||
255
+ tool === 'pg_dump' ||
256
+ tool === 'pg_restore' ||
257
+ tool === 'pg_basebackup'
258
+ ) {
259
+ paths.push(
260
+ // Homebrew (Apple Silicon)
261
+ '/opt/homebrew/bin',
262
+ '/opt/homebrew/opt/postgresql/bin',
263
+ '/opt/homebrew/opt/postgresql@17/bin',
264
+ '/opt/homebrew/opt/postgresql@16/bin',
265
+ '/opt/homebrew/opt/postgresql@15/bin',
266
+ '/opt/homebrew/opt/postgresql@14/bin',
267
+ // Homebrew (Intel)
268
+ '/usr/local/bin',
269
+ '/usr/local/opt/postgresql/bin',
270
+ '/usr/local/opt/postgresql@17/bin',
271
+ '/usr/local/opt/postgresql@16/bin',
272
+ '/usr/local/opt/postgresql@15/bin',
273
+ '/usr/local/opt/postgresql@14/bin',
274
+ // Postgres.app
275
+ '/Applications/Postgres.app/Contents/Versions/latest/bin',
276
+ )
277
+ }
278
+
279
+ // Generic paths
280
+ paths.push('/usr/local/bin', '/usr/bin')
281
+
282
+ return paths
283
+ }
284
+
285
+ async detectPackageManager(): Promise<PackageManagerInfo | null> {
286
+ try {
287
+ await execAsync('brew --version')
288
+ return {
289
+ id: 'brew',
290
+ name: 'Homebrew',
291
+ checkCommand: 'brew --version',
292
+ installTemplate: 'brew install {package}',
293
+ updateCommand: 'brew update',
294
+ }
295
+ } catch {
296
+ return null
297
+ }
298
+ }
299
+
300
+ getZonkyPlatform(): string | null {
301
+ const arch = osArch()
302
+ if (arch === 'arm64') return 'darwin-arm64v8'
303
+ if (arch === 'x64') return 'darwin-amd64'
304
+ return null
305
+ }
306
+
307
+ protected buildToolPath(dir: string, toolName: string): string {
308
+ return `${dir}/${toolName}`
309
+ }
310
+ }
311
+
312
+ // =============================================================================
313
+ // Linux Implementation
314
+ // =============================================================================
315
+
316
+ class LinuxPlatformService extends BasePlatformService {
317
+ getPlatformInfo(): PlatformInfo {
318
+ if (this.cachedPlatformInfo) return this.cachedPlatformInfo
319
+
320
+ const sudoUser = process.env.SUDO_USER || null
321
+ let homeDir: string
322
+
323
+ if (sudoUser) {
324
+ try {
325
+ const result = execSync(`getent passwd ${sudoUser}`, {
326
+ encoding: 'utf-8',
327
+ })
328
+ const parts = result.trim().split(':')
329
+ homeDir = parts.length >= 6 && parts[5] ? parts[5] : `/home/${sudoUser}`
330
+ } catch {
331
+ homeDir = `/home/${sudoUser}`
332
+ }
333
+ } else {
334
+ homeDir = homedir()
335
+ }
336
+
337
+ // Check if running in WSL
338
+ let isWSL = false
339
+ try {
340
+ const uname = execSync('uname -r', { encoding: 'utf-8' })
341
+ isWSL = uname.toLowerCase().includes('microsoft')
342
+ } catch {
343
+ // Not WSL
344
+ }
345
+
346
+ this.cachedPlatformInfo = {
347
+ platform: 'linux',
348
+ arch: osArch() as Architecture,
349
+ homeDir,
350
+ isWSL,
351
+ isSudo: !!sudoUser,
352
+ sudoUser,
353
+ }
354
+
355
+ return this.cachedPlatformInfo
356
+ }
357
+
358
+ getClipboardConfig(): ClipboardConfig {
359
+ // Check if xclip is available
360
+ let available = false
361
+ try {
362
+ execSync('which xclip', { encoding: 'utf-8' })
363
+ available = true
364
+ } catch {
365
+ // xclip not installed
366
+ }
367
+
368
+ return {
369
+ copyCommand: 'xclip',
370
+ copyArgs: ['-selection', 'clipboard'],
371
+ pasteCommand: 'xclip',
372
+ pasteArgs: ['-selection', 'clipboard', '-o'],
373
+ available,
374
+ }
375
+ }
376
+
377
+ getWhichCommand(): WhichCommandConfig {
378
+ return {
379
+ command: 'which',
380
+ args: [],
381
+ }
382
+ }
383
+
384
+ getSearchPaths(tool: string): string[] {
385
+ const paths: string[] = []
386
+
387
+ // MySQL-specific paths
388
+ if (
389
+ tool === 'mysqld' ||
390
+ tool === 'mysql' ||
391
+ tool === 'mysqladmin' ||
392
+ tool === 'mysqldump'
393
+ ) {
394
+ paths.push(
395
+ '/usr/bin',
396
+ '/usr/sbin',
397
+ '/usr/local/bin',
398
+ '/usr/local/mysql/bin',
399
+ )
400
+ }
401
+
402
+ // PostgreSQL-specific paths
403
+ if (
404
+ tool === 'psql' ||
405
+ tool === 'pg_dump' ||
406
+ tool === 'pg_restore' ||
407
+ tool === 'pg_basebackup'
408
+ ) {
409
+ paths.push(
410
+ '/usr/bin',
411
+ '/usr/local/bin',
412
+ '/usr/lib/postgresql/17/bin',
413
+ '/usr/lib/postgresql/16/bin',
414
+ '/usr/lib/postgresql/15/bin',
415
+ '/usr/lib/postgresql/14/bin',
416
+ )
417
+ }
418
+
419
+ // Generic paths
420
+ paths.push('/usr/bin', '/usr/local/bin')
421
+
422
+ return paths
423
+ }
424
+
425
+ async detectPackageManager(): Promise<PackageManagerInfo | null> {
426
+ // Try apt first (Debian/Ubuntu)
427
+ try {
428
+ await execAsync('apt --version')
429
+ return {
430
+ id: 'apt',
431
+ name: 'APT',
432
+ checkCommand: 'apt --version',
433
+ installTemplate: 'sudo apt install -y {package}',
434
+ updateCommand: 'sudo apt update',
435
+ }
436
+ } catch {
437
+ // Not apt
438
+ }
439
+
440
+ // Try dnf (Fedora/RHEL 8+)
441
+ try {
442
+ await execAsync('dnf --version')
443
+ return {
444
+ id: 'dnf',
445
+ name: 'DNF',
446
+ checkCommand: 'dnf --version',
447
+ installTemplate: 'sudo dnf install -y {package}',
448
+ updateCommand: 'sudo dnf check-update',
449
+ }
450
+ } catch {
451
+ // Not dnf
452
+ }
453
+
454
+ // Try yum (RHEL/CentOS 7)
455
+ try {
456
+ await execAsync('yum --version')
457
+ return {
458
+ id: 'yum',
459
+ name: 'YUM',
460
+ checkCommand: 'yum --version',
461
+ installTemplate: 'sudo yum install -y {package}',
462
+ updateCommand: 'sudo yum check-update',
463
+ }
464
+ } catch {
465
+ // Not yum
466
+ }
467
+
468
+ // Try pacman (Arch)
469
+ try {
470
+ await execAsync('pacman --version')
471
+ return {
472
+ id: 'pacman',
473
+ name: 'Pacman',
474
+ checkCommand: 'pacman --version',
475
+ installTemplate: 'sudo pacman -S --noconfirm {package}',
476
+ updateCommand: 'sudo pacman -Sy',
477
+ }
478
+ } catch {
479
+ // Not pacman
480
+ }
481
+
482
+ return null
483
+ }
484
+
485
+ getZonkyPlatform(): string | null {
486
+ const arch = osArch()
487
+ if (arch === 'arm64') return 'linux-arm64v8'
488
+ if (arch === 'x64') return 'linux-amd64'
489
+ return null
490
+ }
491
+
492
+ protected buildToolPath(dir: string, toolName: string): string {
493
+ return `${dir}/${toolName}`
494
+ }
495
+ }
496
+
497
+ // =============================================================================
498
+ // Windows Implementation (Stub for future support)
499
+ // =============================================================================
500
+
501
+ class Win32PlatformService extends BasePlatformService {
502
+ getPlatformInfo(): PlatformInfo {
503
+ if (this.cachedPlatformInfo) return this.cachedPlatformInfo
504
+
505
+ this.cachedPlatformInfo = {
506
+ platform: 'win32',
507
+ arch: osArch() as Architecture,
508
+ homeDir: homedir(),
509
+ isWSL: false,
510
+ isSudo: false,
511
+ sudoUser: null,
512
+ }
513
+
514
+ return this.cachedPlatformInfo
515
+ }
516
+
517
+ getClipboardConfig(): ClipboardConfig {
518
+ return {
519
+ copyCommand: 'clip',
520
+ copyArgs: [],
521
+ pasteCommand: 'powershell',
522
+ pasteArgs: ['-command', 'Get-Clipboard'],
523
+ available: true,
524
+ }
525
+ }
526
+
527
+ getWhichCommand(): WhichCommandConfig {
528
+ return {
529
+ command: 'where',
530
+ args: [],
531
+ }
532
+ }
533
+
534
+ getSearchPaths(tool: string): string[] {
535
+ const paths: string[] = []
536
+
537
+ // MySQL-specific paths
538
+ if (
539
+ tool === 'mysqld' ||
540
+ tool === 'mysql' ||
541
+ tool === 'mysqladmin' ||
542
+ tool === 'mysqldump'
543
+ ) {
544
+ paths.push(
545
+ 'C:\\Program Files\\MySQL\\MySQL Server 8.0\\bin',
546
+ 'C:\\Program Files\\MySQL\\MySQL Server 8.4\\bin',
547
+ 'C:\\Program Files\\MySQL\\MySQL Server 5.7\\bin',
548
+ )
549
+ }
550
+
551
+ // PostgreSQL-specific paths
552
+ if (
553
+ tool === 'psql' ||
554
+ tool === 'pg_dump' ||
555
+ tool === 'pg_restore' ||
556
+ tool === 'pg_basebackup'
557
+ ) {
558
+ paths.push(
559
+ 'C:\\Program Files\\PostgreSQL\\17\\bin',
560
+ 'C:\\Program Files\\PostgreSQL\\16\\bin',
561
+ 'C:\\Program Files\\PostgreSQL\\15\\bin',
562
+ 'C:\\Program Files\\PostgreSQL\\14\\bin',
563
+ )
564
+ }
565
+
566
+ return paths
567
+ }
568
+
569
+ async detectPackageManager(): Promise<PackageManagerInfo | null> {
570
+ // Try chocolatey
571
+ try {
572
+ await execAsync('choco --version')
573
+ return {
574
+ id: 'choco',
575
+ name: 'Chocolatey',
576
+ checkCommand: 'choco --version',
577
+ installTemplate: 'choco install -y {package}',
578
+ updateCommand: 'choco upgrade all',
579
+ }
580
+ } catch {
581
+ // Not chocolatey
582
+ }
583
+
584
+ // Try winget
585
+ try {
586
+ await execAsync('winget --version')
587
+ return {
588
+ id: 'winget',
589
+ name: 'Windows Package Manager',
590
+ checkCommand: 'winget --version',
591
+ installTemplate: 'winget install {package}',
592
+ updateCommand: 'winget upgrade --all',
593
+ }
594
+ } catch {
595
+ // Not winget
596
+ }
597
+
598
+ return null
599
+ }
600
+
601
+ getZonkyPlatform(): string | null {
602
+ // zonky.io doesn't provide Windows binaries
603
+ return null
604
+ }
605
+
606
+ protected buildToolPath(dir: string, toolName: string): string {
607
+ return `${dir}\\${toolName}.exe`
608
+ }
609
+ }
610
+
611
+ // =============================================================================
612
+ // Factory and Singleton
613
+ // =============================================================================
614
+
615
+ /**
616
+ * Create the appropriate platform service for the current OS
617
+ */
618
+ export function createPlatformService(): BasePlatformService {
619
+ const platform = osPlatform()
620
+
621
+ switch (platform) {
622
+ case 'darwin':
623
+ return new DarwinPlatformService()
624
+ case 'linux':
625
+ return new LinuxPlatformService()
626
+ case 'win32':
627
+ return new Win32PlatformService()
628
+ default:
629
+ throw new Error(`Unsupported platform: ${platform}`)
630
+ }
631
+ }
632
+
633
+ // Export singleton instance for convenience
634
+ export const platformService = createPlatformService()