spindb 0.3.6 → 0.4.1

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,429 @@
1
+ /**
2
+ * Dependency Manager
3
+ *
4
+ * Handles checking, installing, and updating OS-level dependencies
5
+ * for database engines.
6
+ */
7
+
8
+ import { exec, spawnSync } 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
+ * Check if stdin is a TTY (interactive terminal)
177
+ */
178
+ function hasTTY(): boolean {
179
+ return process.stdin.isTTY === true
180
+ }
181
+
182
+ /**
183
+ * Check if running as root
184
+ */
185
+ function isRoot(): boolean {
186
+ return process.getuid?.() === 0
187
+ }
188
+
189
+ /**
190
+ * Execute command with inherited stdio (for TTY support with sudo)
191
+ * Uses spawnSync to properly connect to the terminal for password prompts
192
+ */
193
+ function execWithInheritedStdio(command: string): void {
194
+ let cmdToRun = command
195
+
196
+ // If already running as root, strip sudo from the command
197
+ if (isRoot() && command.startsWith('sudo ')) {
198
+ cmdToRun = command.replace(/^sudo\s+/, '')
199
+ }
200
+
201
+ // Check if we need a TTY for sudo password prompts
202
+ if (!hasTTY() && cmdToRun.includes('sudo')) {
203
+ throw new Error(
204
+ 'Cannot run sudo commands without an interactive terminal. Please run the install command manually:\n' +
205
+ ` ${command}`,
206
+ )
207
+ }
208
+
209
+ const result = spawnSync(cmdToRun, [], {
210
+ shell: true,
211
+ stdio: 'inherit',
212
+ })
213
+
214
+ if (result.error) {
215
+ throw result.error
216
+ }
217
+
218
+ if (result.status !== 0) {
219
+ throw new Error(`Command failed with exit code ${result.status}: ${cmdToRun}`)
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Build install command for a dependency using a package manager
225
+ */
226
+ export function buildInstallCommand(
227
+ dependency: Dependency,
228
+ packageManager: DetectedPackageManager,
229
+ ): string[] {
230
+ const pkgDef = dependency.packages[packageManager.id]
231
+ if (!pkgDef) {
232
+ throw new Error(
233
+ `No package definition for ${dependency.name} with ${packageManager.name}`,
234
+ )
235
+ }
236
+
237
+ const commands: string[] = []
238
+
239
+ // Pre-install commands
240
+ if (pkgDef.preInstall) {
241
+ commands.push(...pkgDef.preInstall)
242
+ }
243
+
244
+ // Main install command
245
+ const installCmd = packageManager.config.installTemplate.replace(
246
+ '{package}',
247
+ pkgDef.package,
248
+ )
249
+ commands.push(installCmd)
250
+
251
+ // Post-install commands
252
+ if (pkgDef.postInstall) {
253
+ commands.push(...pkgDef.postInstall)
254
+ }
255
+
256
+ return commands
257
+ }
258
+
259
+ /**
260
+ * Install a single dependency
261
+ */
262
+ export async function installDependency(
263
+ dependency: Dependency,
264
+ packageManager: DetectedPackageManager,
265
+ ): Promise<InstallResult> {
266
+ try {
267
+ const commands = buildInstallCommand(dependency, packageManager)
268
+
269
+ for (const cmd of commands) {
270
+ // Use inherited stdio so sudo can prompt for password in terminal
271
+ execWithInheritedStdio(cmd)
272
+ }
273
+
274
+ // Verify installation
275
+ const status = await checkDependency(dependency)
276
+ if (!status.installed) {
277
+ return {
278
+ success: false,
279
+ dependency,
280
+ error: 'Installation completed but binary not found in PATH',
281
+ }
282
+ }
283
+
284
+ return { success: true, dependency }
285
+ } catch (error) {
286
+ return {
287
+ success: false,
288
+ dependency,
289
+ error: error instanceof Error ? error.message : String(error),
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Install all dependencies for an engine
296
+ */
297
+ export async function installEngineDependencies(
298
+ engine: string,
299
+ packageManager: DetectedPackageManager,
300
+ ): Promise<InstallResult[]> {
301
+ const missing = await getMissingDependencies(engine)
302
+ if (missing.length === 0) return []
303
+
304
+ // Group by package to avoid reinstalling the same package multiple times
305
+ const packageGroups = new Map<string, Dependency[]>()
306
+ for (const dep of missing) {
307
+ const pkgDef = dep.packages[packageManager.id]
308
+ if (pkgDef) {
309
+ const existing = packageGroups.get(pkgDef.package) || []
310
+ existing.push(dep)
311
+ packageGroups.set(pkgDef.package, existing)
312
+ }
313
+ }
314
+
315
+ const results: InstallResult[] = []
316
+
317
+ // Install each unique package once
318
+ for (const [, deps] of packageGroups) {
319
+ // Install using the first dependency (they all use the same package)
320
+ const result = await installDependency(deps[0], packageManager)
321
+
322
+ // Mark all dependencies from this package with the same result
323
+ for (const dep of deps) {
324
+ results.push({ ...result, dependency: dep })
325
+ }
326
+ }
327
+
328
+ return results
329
+ }
330
+
331
+ /**
332
+ * Install all missing dependencies across all engines
333
+ */
334
+ export async function installAllDependencies(
335
+ packageManager: DetectedPackageManager,
336
+ ): Promise<InstallResult[]> {
337
+ const missing = await getAllMissingDependencies()
338
+ if (missing.length === 0) return []
339
+
340
+ // Group by package
341
+ const packageGroups = new Map<string, Dependency[]>()
342
+ for (const dep of missing) {
343
+ const pkgDef = dep.packages[packageManager.id]
344
+ if (pkgDef) {
345
+ const existing = packageGroups.get(pkgDef.package) || []
346
+ existing.push(dep)
347
+ packageGroups.set(pkgDef.package, existing)
348
+ }
349
+ }
350
+
351
+ const results: InstallResult[] = []
352
+
353
+ for (const [, deps] of packageGroups) {
354
+ const result = await installDependency(deps[0], packageManager)
355
+ for (const dep of deps) {
356
+ results.push({ ...result, dependency: dep })
357
+ }
358
+ }
359
+
360
+ return results
361
+ }
362
+
363
+ // =============================================================================
364
+ // Manual Installation Instructions
365
+ // =============================================================================
366
+
367
+ /**
368
+ * Get manual installation instructions for a dependency
369
+ */
370
+ export function getManualInstallInstructions(
371
+ dependency: Dependency,
372
+ platform: Platform = getCurrentPlatform(),
373
+ ): string[] {
374
+ return dependency.manualInstall[platform] || []
375
+ }
376
+
377
+ /**
378
+ * Get manual installation instructions for all missing dependencies of an engine
379
+ */
380
+ export function getEngineManualInstallInstructions(
381
+ engine: string,
382
+ missingDeps: Dependency[],
383
+ platform: Platform = getCurrentPlatform(),
384
+ ): string[] {
385
+ // Since all deps usually come from the same package, just get instructions from the first one
386
+ if (missingDeps.length === 0) return []
387
+
388
+ return getManualInstallInstructions(missingDeps[0], platform)
389
+ }
390
+
391
+ // =============================================================================
392
+ // High-Level API
393
+ // =============================================================================
394
+
395
+ export type DependencyCheckResult = {
396
+ engine: string
397
+ allInstalled: boolean
398
+ installed: DependencyStatus[]
399
+ missing: DependencyStatus[]
400
+ }
401
+
402
+ /**
403
+ * Get a complete dependency report for an engine
404
+ */
405
+ export async function getDependencyReport(
406
+ engine: string,
407
+ ): Promise<DependencyCheckResult> {
408
+ const statuses = await checkEngineDependencies(engine)
409
+
410
+ return {
411
+ engine,
412
+ allInstalled: statuses.every((s) => s.installed),
413
+ installed: statuses.filter((s) => s.installed),
414
+ missing: statuses.filter((s) => !s.installed),
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Get dependency reports for all engines
420
+ */
421
+ export async function getAllDependencyReports(): Promise<
422
+ DependencyCheckResult[]
423
+ > {
424
+ const engines = ['postgresql', 'mysql']
425
+ const reports = await Promise.all(
426
+ engines.map((engine) => getDependencyReport(engine)),
427
+ )
428
+ return reports
429
+ }
@@ -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,39 +280,60 @@ 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
 
292
309
  spinner.succeed(`Found package manager: ${packageManager.name}`)
293
310
 
294
- const installSpinner = createSpinner(
295
- `Installing PostgreSQL client tools with ${packageManager.name}...`,
296
- )
297
- installSpinner.start()
311
+ // Don't use a spinner during installation - it blocks TTY access for sudo password prompts
312
+ console.log(chalk.cyan(` Installing PostgreSQL client tools with ${packageManager.name}...`))
313
+ console.log(chalk.gray(' You may be prompted for your password.'))
314
+ console.log()
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
+ console.log()
322
+ console.log(success('PostgreSQL client tools installed successfully'))
323
+ return true
324
+ } else {
325
+ const failed = results.filter((r) => !r.success)
326
+ console.log()
327
+ console.log(themeError('Some installations failed:'))
328
+ for (const f of failed) {
329
+ console.log(themeError(` ${f.dependency.name}: ${f.error}`))
330
+ }
331
+ return false
332
+ }
304
333
  } catch (error: unknown) {
305
- installSpinner.fail('Installation failed')
334
+ console.log()
306
335
  console.log(themeError('Failed to install PostgreSQL client tools'))
307
- console.log(warning('Please install manually:'))
308
- console.log(` ${packageManager.installCommand('postgresql')}`)
336
+ console.log(warning('Please install manually'))
309
337
  if (error instanceof Error) {
310
338
  console.log(chalk.gray(`Error details: ${error.message}`))
311
339
  }
@@ -429,15 +457,9 @@ export async function ensurePostgresBinary(
429
457
  ): Promise<{ success: boolean; info: BinaryInfo | null; action?: string }> {
430
458
  const { autoInstall = true, autoUpdate = true } = options
431
459
 
432
- console.log(
433
- `[DEBUG] ensurePostgresBinary called for ${binary}, dumpPath: ${dumpPath}`,
434
- )
435
-
436
460
  // Check if binary exists
437
461
  const info = await getBinaryInfo(binary, dumpPath)
438
462
 
439
- console.log(`[DEBUG] getBinaryInfo result:`, info)
440
-
441
463
  if (!info) {
442
464
  if (!autoInstall) {
443
465
  return { success: false, info: null, action: 'install_required' }
@@ -460,10 +482,6 @@ export async function ensurePostgresBinary(
460
482
 
461
483
  // Check version compatibility
462
484
  if (dumpPath && !info.isCompatible) {
463
- console.log(
464
- `[DEBUG] Version incompatible: current=${info.version}, required=${info.requiredVersion}`,
465
- )
466
-
467
485
  if (!autoUpdate) {
468
486
  return { success: false, info, action: 'update_required' }
469
487
  }
@@ -487,13 +505,11 @@ export async function ensurePostgresBinary(
487
505
  // Check again after update
488
506
  const updatedInfo = await getBinaryInfo(binary, dumpPath)
489
507
  if (!updatedInfo || !updatedInfo.isCompatible) {
490
- console.log(`[DEBUG] Update failed or still incompatible:`, updatedInfo)
491
508
  return { success: false, info: updatedInfo, action: 'update_failed' }
492
509
  }
493
510
 
494
511
  return { success: true, info: updatedInfo, action: 'updated' }
495
512
  }
496
513
 
497
- console.log(`[DEBUG] Binary is compatible, returning success`)
498
514
  return { success: true, info, action: 'compatible' }
499
515
  }
@@ -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.1",
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