spindb 0.3.6 → 0.4.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,407 @@
1
+ /**
2
+ * Dependency Manager
3
+ *
4
+ * Handles checking, installing, and updating OS-level dependencies
5
+ * for database engines.
6
+ */
7
+
8
+ import { exec } from 'child_process'
9
+ import { promisify } from 'util'
10
+ import {
11
+ type PackageManagerId,
12
+ type PackageManagerConfig,
13
+ type Dependency,
14
+ type Platform,
15
+ packageManagers,
16
+ getEngineDependencies,
17
+ getUniqueDependencies,
18
+ } from '../config/os-dependencies'
19
+
20
+ const execAsync = promisify(exec)
21
+
22
+ export type DependencyStatus = {
23
+ dependency: Dependency
24
+ installed: boolean
25
+ path?: string
26
+ version?: string
27
+ }
28
+
29
+ export type DetectedPackageManager = {
30
+ config: PackageManagerConfig
31
+ id: PackageManagerId
32
+ name: string
33
+ }
34
+
35
+ export type InstallResult = {
36
+ success: boolean
37
+ dependency: Dependency
38
+ error?: string
39
+ }
40
+
41
+ // =============================================================================
42
+ // Package Manager Detection
43
+ // =============================================================================
44
+
45
+ /**
46
+ * Detect which package manager is available on the current system
47
+ */
48
+ export async function detectPackageManager(): Promise<DetectedPackageManager | null> {
49
+ const platform = process.platform as Platform
50
+
51
+ // Filter to package managers available on this platform
52
+ const candidates = packageManagers.filter((pm) =>
53
+ pm.platforms.includes(platform),
54
+ )
55
+
56
+ for (const pm of candidates) {
57
+ try {
58
+ await execAsync(pm.checkCommand)
59
+ return {
60
+ config: pm,
61
+ id: pm.id,
62
+ name: pm.name,
63
+ }
64
+ } catch {
65
+ // Package manager not available
66
+ }
67
+ }
68
+
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * Get the current platform
74
+ */
75
+ export function getCurrentPlatform(): Platform {
76
+ return process.platform as Platform
77
+ }
78
+
79
+ // =============================================================================
80
+ // Dependency Checking
81
+ // =============================================================================
82
+
83
+ /**
84
+ * Check if a binary is installed and get its path
85
+ */
86
+ export async function findBinary(
87
+ binary: string,
88
+ ): Promise<{ path: string; version?: string } | null> {
89
+ try {
90
+ const command = process.platform === 'win32' ? 'where' : 'which'
91
+ const { stdout } = await execAsync(`${command} ${binary}`)
92
+ const path = stdout.trim().split('\n')[0]
93
+
94
+ if (!path) return null
95
+
96
+ // Try to get version
97
+ let version: string | undefined
98
+ try {
99
+ const { stdout: versionOutput } = await execAsync(`${binary} --version`)
100
+ const match = versionOutput.match(/(\d+\.\d+(\.\d+)?)/)
101
+ version = match ? match[1] : undefined
102
+ } catch {
103
+ // Version check failed, that's ok
104
+ }
105
+
106
+ return { path, version }
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Check the status of a single dependency
114
+ */
115
+ export async function checkDependency(
116
+ dependency: Dependency,
117
+ ): Promise<DependencyStatus> {
118
+ const result = await findBinary(dependency.binary)
119
+
120
+ return {
121
+ dependency,
122
+ installed: result !== null,
123
+ path: result?.path,
124
+ version: result?.version,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Check all dependencies for a specific engine
130
+ */
131
+ export async function checkEngineDependencies(
132
+ engine: string,
133
+ ): Promise<DependencyStatus[]> {
134
+ const engineDeps = getEngineDependencies(engine)
135
+ if (!engineDeps) return []
136
+
137
+ const results = await Promise.all(
138
+ engineDeps.dependencies.map((dep) => checkDependency(dep)),
139
+ )
140
+
141
+ return results
142
+ }
143
+
144
+ /**
145
+ * Check all dependencies across all engines
146
+ */
147
+ export async function checkAllDependencies(): Promise<DependencyStatus[]> {
148
+ const deps = getUniqueDependencies()
149
+ const results = await Promise.all(deps.map((dep) => checkDependency(dep)))
150
+ return results
151
+ }
152
+
153
+ /**
154
+ * Get missing dependencies for an engine
155
+ */
156
+ export async function getMissingDependencies(
157
+ engine: string,
158
+ ): Promise<Dependency[]> {
159
+ const statuses = await checkEngineDependencies(engine)
160
+ return statuses.filter((s) => !s.installed).map((s) => s.dependency)
161
+ }
162
+
163
+ /**
164
+ * Get all missing dependencies across all engines
165
+ */
166
+ export async function getAllMissingDependencies(): Promise<Dependency[]> {
167
+ const statuses = await checkAllDependencies()
168
+ return statuses.filter((s) => !s.installed).map((s) => s.dependency)
169
+ }
170
+
171
+ // =============================================================================
172
+ // Installation
173
+ // =============================================================================
174
+
175
+ /**
176
+ * Execute command with timeout
177
+ */
178
+ async function execWithTimeout(
179
+ command: string,
180
+ timeoutMs: number = 120000,
181
+ ): Promise<{ stdout: string; stderr: string }> {
182
+ return new Promise((resolve, reject) => {
183
+ const child = exec(
184
+ command,
185
+ { timeout: timeoutMs },
186
+ (error, stdout, stderr) => {
187
+ if (error) {
188
+ reject(error)
189
+ } else {
190
+ resolve({ stdout, stderr })
191
+ }
192
+ },
193
+ )
194
+
195
+ setTimeout(() => {
196
+ child.kill('SIGTERM')
197
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
198
+ }, timeoutMs)
199
+ })
200
+ }
201
+
202
+ /**
203
+ * Build install command for a dependency using a package manager
204
+ */
205
+ export function buildInstallCommand(
206
+ dependency: Dependency,
207
+ packageManager: DetectedPackageManager,
208
+ ): string[] {
209
+ const pkgDef = dependency.packages[packageManager.id]
210
+ if (!pkgDef) {
211
+ throw new Error(
212
+ `No package definition for ${dependency.name} with ${packageManager.name}`,
213
+ )
214
+ }
215
+
216
+ const commands: string[] = []
217
+
218
+ // Pre-install commands
219
+ if (pkgDef.preInstall) {
220
+ commands.push(...pkgDef.preInstall)
221
+ }
222
+
223
+ // Main install command
224
+ const installCmd = packageManager.config.installTemplate.replace(
225
+ '{package}',
226
+ pkgDef.package,
227
+ )
228
+ commands.push(installCmd)
229
+
230
+ // Post-install commands
231
+ if (pkgDef.postInstall) {
232
+ commands.push(...pkgDef.postInstall)
233
+ }
234
+
235
+ return commands
236
+ }
237
+
238
+ /**
239
+ * Install a single dependency
240
+ */
241
+ export async function installDependency(
242
+ dependency: Dependency,
243
+ packageManager: DetectedPackageManager,
244
+ ): Promise<InstallResult> {
245
+ try {
246
+ const commands = buildInstallCommand(dependency, packageManager)
247
+
248
+ for (const cmd of commands) {
249
+ await execWithTimeout(cmd, 120000)
250
+ }
251
+
252
+ // Verify installation
253
+ const status = await checkDependency(dependency)
254
+ if (!status.installed) {
255
+ return {
256
+ success: false,
257
+ dependency,
258
+ error: 'Installation completed but binary not found in PATH',
259
+ }
260
+ }
261
+
262
+ return { success: true, dependency }
263
+ } catch (error) {
264
+ return {
265
+ success: false,
266
+ dependency,
267
+ error: error instanceof Error ? error.message : String(error),
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Install all dependencies for an engine
274
+ */
275
+ export async function installEngineDependencies(
276
+ engine: string,
277
+ packageManager: DetectedPackageManager,
278
+ ): Promise<InstallResult[]> {
279
+ const missing = await getMissingDependencies(engine)
280
+ if (missing.length === 0) return []
281
+
282
+ // Group by package to avoid reinstalling the same package multiple times
283
+ const packageGroups = new Map<string, Dependency[]>()
284
+ for (const dep of missing) {
285
+ const pkgDef = dep.packages[packageManager.id]
286
+ if (pkgDef) {
287
+ const existing = packageGroups.get(pkgDef.package) || []
288
+ existing.push(dep)
289
+ packageGroups.set(pkgDef.package, existing)
290
+ }
291
+ }
292
+
293
+ const results: InstallResult[] = []
294
+
295
+ // Install each unique package once
296
+ for (const [, deps] of packageGroups) {
297
+ // Install using the first dependency (they all use the same package)
298
+ const result = await installDependency(deps[0], packageManager)
299
+
300
+ // Mark all dependencies from this package with the same result
301
+ for (const dep of deps) {
302
+ results.push({ ...result, dependency: dep })
303
+ }
304
+ }
305
+
306
+ return results
307
+ }
308
+
309
+ /**
310
+ * Install all missing dependencies across all engines
311
+ */
312
+ export async function installAllDependencies(
313
+ packageManager: DetectedPackageManager,
314
+ ): Promise<InstallResult[]> {
315
+ const missing = await getAllMissingDependencies()
316
+ if (missing.length === 0) return []
317
+
318
+ // Group by package
319
+ const packageGroups = new Map<string, Dependency[]>()
320
+ for (const dep of missing) {
321
+ const pkgDef = dep.packages[packageManager.id]
322
+ if (pkgDef) {
323
+ const existing = packageGroups.get(pkgDef.package) || []
324
+ existing.push(dep)
325
+ packageGroups.set(pkgDef.package, existing)
326
+ }
327
+ }
328
+
329
+ const results: InstallResult[] = []
330
+
331
+ for (const [, deps] of packageGroups) {
332
+ const result = await installDependency(deps[0], packageManager)
333
+ for (const dep of deps) {
334
+ results.push({ ...result, dependency: dep })
335
+ }
336
+ }
337
+
338
+ return results
339
+ }
340
+
341
+ // =============================================================================
342
+ // Manual Installation Instructions
343
+ // =============================================================================
344
+
345
+ /**
346
+ * Get manual installation instructions for a dependency
347
+ */
348
+ export function getManualInstallInstructions(
349
+ dependency: Dependency,
350
+ platform: Platform = getCurrentPlatform(),
351
+ ): string[] {
352
+ return dependency.manualInstall[platform] || []
353
+ }
354
+
355
+ /**
356
+ * Get manual installation instructions for all missing dependencies of an engine
357
+ */
358
+ export function getEngineManualInstallInstructions(
359
+ engine: string,
360
+ missingDeps: Dependency[],
361
+ platform: Platform = getCurrentPlatform(),
362
+ ): string[] {
363
+ // Since all deps usually come from the same package, just get instructions from the first one
364
+ if (missingDeps.length === 0) return []
365
+
366
+ return getManualInstallInstructions(missingDeps[0], platform)
367
+ }
368
+
369
+ // =============================================================================
370
+ // High-Level API
371
+ // =============================================================================
372
+
373
+ export type DependencyCheckResult = {
374
+ engine: string
375
+ allInstalled: boolean
376
+ installed: DependencyStatus[]
377
+ missing: DependencyStatus[]
378
+ }
379
+
380
+ /**
381
+ * Get a complete dependency report for an engine
382
+ */
383
+ export async function getDependencyReport(
384
+ engine: string,
385
+ ): Promise<DependencyCheckResult> {
386
+ const statuses = await checkEngineDependencies(engine)
387
+
388
+ return {
389
+ engine,
390
+ allInstalled: statuses.every((s) => s.installed),
391
+ installed: statuses.filter((s) => s.installed),
392
+ missing: statuses.filter((s) => !s.installed),
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Get dependency reports for all engines
398
+ */
399
+ export async function getAllDependencyReports(): Promise<
400
+ DependencyCheckResult[]
401
+ > {
402
+ const engines = ['postgresql', 'mysql']
403
+ const reports = await Promise.all(
404
+ engines.map((engine) => getDependencyReport(engine)),
405
+ )
406
+ return reports
407
+ }
@@ -3,6 +3,13 @@ import { promisify } from 'util'
3
3
  import chalk from 'chalk'
4
4
  import { createSpinner } from '../cli/ui/spinner'
5
5
  import { warning, error as themeError, success } from '../cli/ui/theme'
6
+ import {
7
+ detectPackageManager as detectPM,
8
+ installEngineDependencies,
9
+ getManualInstallInstructions,
10
+ getCurrentPlatform,
11
+ } from './dependency-manager'
12
+ import { getEngineDependencies } from '../config/os-dependencies'
6
13
 
7
14
  const execAsync = promisify(exec)
8
15
 
@@ -273,19 +280,29 @@ export async function getBinaryInfo(
273
280
  }
274
281
 
275
282
  /**
276
- * Install PostgreSQL client tools
283
+ * Install PostgreSQL client tools using the new dependency manager
277
284
  */
278
285
  export async function installPostgresBinaries(): Promise<boolean> {
279
286
  const spinner = createSpinner('Checking package manager...')
280
287
  spinner.start()
281
288
 
282
- const packageManager = await detectPackageManager()
289
+ const packageManager = await detectPM()
283
290
  if (!packageManager) {
284
291
  spinner.fail('No supported package manager found')
285
292
  console.log(themeError('Please install PostgreSQL client tools manually:'))
286
- console.log(' macOS: brew install libpq')
287
- console.log(' Ubuntu/Debian: sudo apt install postgresql-client')
288
- console.log(' CentOS/RHEL/Fedora: sudo yum install postgresql')
293
+
294
+ // Show platform-specific instructions from the registry
295
+ const platform = getCurrentPlatform()
296
+ const pgDeps = getEngineDependencies('postgresql')
297
+ if (pgDeps && pgDeps.dependencies.length > 0) {
298
+ const instructions = getManualInstallInstructions(
299
+ pgDeps.dependencies[0],
300
+ platform,
301
+ )
302
+ for (const instruction of instructions) {
303
+ console.log(` ${instruction}`)
304
+ }
305
+ }
289
306
  return false
290
307
  }
291
308
 
@@ -297,15 +314,25 @@ export async function installPostgresBinaries(): Promise<boolean> {
297
314
  installSpinner.start()
298
315
 
299
316
  try {
300
- await execWithTimeout(packageManager.installCommand('postgresql'), 120000) // 2 minute timeout
301
- installSpinner.succeed('PostgreSQL client tools installed')
302
- console.log(success('Installation completed successfully'))
303
- return true
317
+ const results = await installEngineDependencies('postgresql', packageManager)
318
+ const allSuccess = results.every((r) => r.success)
319
+
320
+ if (allSuccess) {
321
+ installSpinner.succeed('PostgreSQL client tools installed')
322
+ console.log(success('Installation completed successfully'))
323
+ return true
324
+ } else {
325
+ const failed = results.filter((r) => !r.success)
326
+ installSpinner.fail('Some installations failed')
327
+ for (const f of failed) {
328
+ console.log(themeError(`Failed to install ${f.dependency.name}: ${f.error}`))
329
+ }
330
+ return false
331
+ }
304
332
  } catch (error: unknown) {
305
333
  installSpinner.fail('Installation failed')
306
334
  console.log(themeError('Failed to install PostgreSQL client tools'))
307
- console.log(warning('Please install manually:'))
308
- console.log(` ${packageManager.installCommand('postgresql')}`)
335
+ console.log(warning('Please install manually'))
309
336
  if (error instanceof Error) {
310
337
  console.log(chalk.gray(`Error details: ${error.message}`))
311
338
  }
@@ -429,15 +456,9 @@ export async function ensurePostgresBinary(
429
456
  ): Promise<{ success: boolean; info: BinaryInfo | null; action?: string }> {
430
457
  const { autoInstall = true, autoUpdate = true } = options
431
458
 
432
- console.log(
433
- `[DEBUG] ensurePostgresBinary called for ${binary}, dumpPath: ${dumpPath}`,
434
- )
435
-
436
459
  // Check if binary exists
437
460
  const info = await getBinaryInfo(binary, dumpPath)
438
461
 
439
- console.log(`[DEBUG] getBinaryInfo result:`, info)
440
-
441
462
  if (!info) {
442
463
  if (!autoInstall) {
443
464
  return { success: false, info: null, action: 'install_required' }
@@ -460,10 +481,6 @@ export async function ensurePostgresBinary(
460
481
 
461
482
  // Check version compatibility
462
483
  if (dumpPath && !info.isCompatible) {
463
- console.log(
464
- `[DEBUG] Version incompatible: current=${info.version}, required=${info.requiredVersion}`,
465
- )
466
-
467
484
  if (!autoUpdate) {
468
485
  return { success: false, info, action: 'update_required' }
469
486
  }
@@ -487,13 +504,11 @@ export async function ensurePostgresBinary(
487
504
  // Check again after update
488
505
  const updatedInfo = await getBinaryInfo(binary, dumpPath)
489
506
  if (!updatedInfo || !updatedInfo.isCompatible) {
490
- console.log(`[DEBUG] Update failed or still incompatible:`, updatedInfo)
491
507
  return { success: false, info: updatedInfo, action: 'update_failed' }
492
508
  }
493
509
 
494
510
  return { success: true, info: updatedInfo, action: 'updated' }
495
511
  }
496
512
 
497
- console.log(`[DEBUG] Binary is compatible, returning success`)
498
513
  return { success: true, info, action: 'compatible' }
499
514
  }
@@ -3,6 +3,7 @@ import type {
3
3
  ProgressCallback,
4
4
  BackupFormat,
5
5
  RestoreResult,
6
+ DumpResult,
6
7
  StatusResult,
7
8
  } from '../types'
8
9
 
@@ -122,4 +123,12 @@ export abstract class BaseEngine {
122
123
  }
123
124
  return versions
124
125
  }
126
+
127
+ /**
128
+ * Create a dump from a remote database using a connection string
129
+ */
130
+ abstract dumpFromConnectionString(
131
+ connectionString: string,
132
+ outputPath: string,
133
+ ): Promise<DumpResult>
125
134
  }
@@ -19,6 +19,7 @@ import type {
19
19
  ProgressCallback,
20
20
  BackupFormat,
21
21
  RestoreResult,
22
+ DumpResult,
22
23
  StatusResult,
23
24
  } from '../../types'
24
25
 
@@ -329,6 +330,58 @@ export class PostgreSQLEngine extends BaseEngine {
329
330
  }
330
331
  }
331
332
  }
333
+
334
+ /**
335
+ * Create a dump from a remote database using a connection string
336
+ * @param connectionString PostgreSQL connection string (e.g., postgresql://user:pass@host:port/dbname)
337
+ * @param outputPath Path where the dump file will be saved
338
+ * @returns DumpResult with file path and any output
339
+ */
340
+ async dumpFromConnectionString(
341
+ connectionString: string,
342
+ outputPath: string,
343
+ ): Promise<DumpResult> {
344
+ const pgDumpPath = await this.getPgDumpPath()
345
+
346
+ return new Promise((resolve, reject) => {
347
+ // Use custom format (-Fc) for best compatibility and compression
348
+ const args = [connectionString, '-Fc', '-f', outputPath]
349
+
350
+ const proc = spawn(pgDumpPath, args, {
351
+ stdio: ['pipe', 'pipe', 'pipe'],
352
+ })
353
+
354
+ let stdout = ''
355
+ let stderr = ''
356
+
357
+ proc.stdout?.on('data', (data: Buffer) => {
358
+ stdout += data.toString()
359
+ })
360
+
361
+ proc.stderr?.on('data', (data: Buffer) => {
362
+ stderr += data.toString()
363
+ })
364
+
365
+ proc.on('error', (err: NodeJS.ErrnoException) => {
366
+ reject(err)
367
+ })
368
+
369
+ proc.on('close', (code) => {
370
+ if (code === 0) {
371
+ resolve({
372
+ filePath: outputPath,
373
+ stdout,
374
+ stderr,
375
+ code,
376
+ })
377
+ } else {
378
+ // pg_dump failed
379
+ const errorMessage = stderr || `pg_dump exited with code ${code}`
380
+ reject(new Error(errorMessage))
381
+ }
382
+ })
383
+ })
384
+ }
332
385
  }
333
386
 
334
387
  export const postgresqlEngine = new PostgreSQLEngine()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "dev": "tsx watch cli/bin.ts",
12
12
  "test": "tsx --test",
13
13
  "format": "prettier --write .",
14
- "lint": "eslint ."
14
+ "lint": "tsc --noEmit && eslint ."
15
15
  },
16
16
  "keywords": [
17
17
  "postgres",
package/types/index.ts CHANGED
@@ -50,6 +50,13 @@ export type RestoreResult = {
50
50
  code?: number
51
51
  }
52
52
 
53
+ export type DumpResult = {
54
+ filePath: string
55
+ stdout?: string
56
+ stderr?: string
57
+ code?: number
58
+ }
59
+
53
60
  export type EngineInfo = {
54
61
  name: string
55
62
  displayName: string