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.
@@ -1,29 +1,45 @@
1
1
  import { Command } from 'commander'
2
2
  import { existsSync } from 'fs'
3
+ import { rm } from 'fs/promises'
3
4
  import chalk from 'chalk'
4
5
  import { containerManager } from '../../core/container-manager'
5
6
  import { processManager } from '../../core/process-manager'
6
7
  import { getEngine } from '../../engines'
7
- import { promptContainerSelect, promptDatabaseName } from '../ui/prompts'
8
+ import {
9
+ promptContainerSelect,
10
+ promptDatabaseName,
11
+ promptInstallDependencies,
12
+ } from '../ui/prompts'
8
13
  import { createSpinner } from '../ui/spinner'
9
14
  import { success, error, warning } from '../ui/theme'
10
- import { platform } from 'os'
15
+ import { platform, tmpdir } from 'os'
11
16
  import { spawn } from 'child_process'
17
+ import { join } from 'path'
18
+ import { getMissingDependencies } from '../../core/dependency-manager'
12
19
 
13
20
  export const restoreCommand = new Command('restore')
14
21
  .description('Restore a backup to a container')
15
22
  .argument('[name]', 'Container name')
16
- .argument('[backup]', 'Path to backup file')
23
+ .argument(
24
+ '[backup]',
25
+ 'Path to backup file (not required if using --from-url)',
26
+ )
17
27
  .option('-d, --database <name>', 'Target database name')
28
+ .option(
29
+ '--from-url <url>',
30
+ 'Pull data from a remote database connection string',
31
+ )
18
32
  .action(
19
33
  async (
20
34
  name: string | undefined,
21
35
  backup: string | undefined,
22
- options: { database?: string },
36
+ options: { database?: string; fromUrl?: string },
23
37
  ) => {
38
+ let tempDumpPath: string | null = null
39
+
24
40
  try {
25
41
  let containerName = name
26
- const backupPath = backup
42
+ let backupPath = backup
27
43
 
28
44
  // Interactive selection if no name provided
29
45
  if (!containerName) {
@@ -71,18 +87,129 @@ export const restoreCommand = new Command('restore')
71
87
  process.exit(1)
72
88
  }
73
89
 
74
- // Check backup file
75
- if (!backupPath) {
76
- console.error(error('Backup file path is required'))
77
- console.log(
78
- chalk.gray(' Usage: spindb restore <container> <backup-file>'),
90
+ // Get engine
91
+ const engine = getEngine(config.engine)
92
+
93
+ // Check for required client tools BEFORE doing anything
94
+ const depsSpinner = createSpinner('Checking required tools...')
95
+ depsSpinner.start()
96
+
97
+ let missingDeps = await getMissingDependencies(config.engine)
98
+ if (missingDeps.length > 0) {
99
+ depsSpinner.warn(
100
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
79
101
  )
80
- process.exit(1)
102
+
103
+ // Offer to install
104
+ const installed = await promptInstallDependencies(
105
+ missingDeps[0].binary,
106
+ config.engine,
107
+ )
108
+
109
+ if (!installed) {
110
+ process.exit(1)
111
+ }
112
+
113
+ // Verify installation worked
114
+ missingDeps = await getMissingDependencies(config.engine)
115
+ if (missingDeps.length > 0) {
116
+ console.error(
117
+ error(
118
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
119
+ ),
120
+ )
121
+ process.exit(1)
122
+ }
123
+
124
+ console.log(chalk.green(' ✓ All required tools are now available'))
125
+ console.log()
126
+ } else {
127
+ depsSpinner.succeed('Required tools available')
81
128
  }
82
129
 
83
- if (!existsSync(backupPath)) {
84
- console.error(error(`Backup file not found: ${backupPath}`))
85
- process.exit(1)
130
+ // Handle --from-url option
131
+ if (options.fromUrl) {
132
+ // Validate connection string
133
+ if (
134
+ !options.fromUrl.startsWith('postgresql://') &&
135
+ !options.fromUrl.startsWith('postgres://')
136
+ ) {
137
+ console.error(
138
+ error(
139
+ 'Connection string must start with postgresql:// or postgres://',
140
+ ),
141
+ )
142
+ process.exit(1)
143
+ }
144
+
145
+ // Create temp file for the dump
146
+ const timestamp = Date.now()
147
+ tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
148
+
149
+ let dumpSuccess = false
150
+ let attempts = 0
151
+ const maxAttempts = 2 // Allow one retry after installing deps
152
+
153
+ while (!dumpSuccess && attempts < maxAttempts) {
154
+ attempts++
155
+ const dumpSpinner = createSpinner(
156
+ 'Creating dump from remote database...',
157
+ )
158
+ dumpSpinner.start()
159
+
160
+ try {
161
+ await engine.dumpFromConnectionString(options.fromUrl, tempDumpPath)
162
+ dumpSpinner.succeed('Dump created from remote database')
163
+ backupPath = tempDumpPath
164
+ dumpSuccess = true
165
+ } catch (err) {
166
+ const e = err as Error
167
+ dumpSpinner.fail('Failed to create dump')
168
+
169
+ // Check if this is a missing tool error
170
+ if (
171
+ e.message.includes('pg_dump not found') ||
172
+ e.message.includes('ENOENT')
173
+ ) {
174
+ const installed = await promptInstallDependencies('pg_dump')
175
+ if (!installed) {
176
+ process.exit(1)
177
+ }
178
+ // Loop will retry
179
+ continue
180
+ }
181
+
182
+ console.log()
183
+ console.error(error('pg_dump error:'))
184
+ console.log(chalk.gray(` ${e.message}`))
185
+ process.exit(1)
186
+ }
187
+ }
188
+
189
+ // Safety check - should never reach here without backupPath set
190
+ if (!dumpSuccess) {
191
+ console.error(error('Failed to create dump after retries'))
192
+ process.exit(1)
193
+ }
194
+ } else {
195
+ // Check backup file
196
+ if (!backupPath) {
197
+ console.error(error('Backup file path is required'))
198
+ console.log(
199
+ chalk.gray(' Usage: spindb restore <container> <backup-file>'),
200
+ )
201
+ console.log(
202
+ chalk.gray(
203
+ ' or: spindb restore <container> --from-url <connection-string>',
204
+ ),
205
+ )
206
+ process.exit(1)
207
+ }
208
+
209
+ if (!existsSync(backupPath)) {
210
+ console.error(error(`Backup file not found: ${backupPath}`))
211
+ process.exit(1)
212
+ }
86
213
  }
87
214
 
88
215
  // Get database name
@@ -91,8 +218,11 @@ export const restoreCommand = new Command('restore')
91
218
  databaseName = await promptDatabaseName(containerName)
92
219
  }
93
220
 
94
- // Get engine
95
- const engine = getEngine(config.engine)
221
+ // At this point backupPath is guaranteed to be set
222
+ if (!backupPath) {
223
+ console.error(error('No backup path specified'))
224
+ process.exit(1)
225
+ }
96
226
 
97
227
  // Detect backup format
98
228
  const detectSpinner = createSpinner('Detecting backup format...')
@@ -182,8 +312,35 @@ export const restoreCommand = new Command('restore')
182
312
  console.log()
183
313
  } catch (err) {
184
314
  const e = err as Error
315
+
316
+ // Check if this is a missing tool error
317
+ if (
318
+ e.message.includes('pg_restore not found') ||
319
+ e.message.includes('psql not found')
320
+ ) {
321
+ const missingTool = e.message.includes('pg_restore')
322
+ ? 'pg_restore'
323
+ : 'psql'
324
+ const installed = await promptInstallDependencies(missingTool)
325
+ if (installed) {
326
+ console.log(
327
+ chalk.yellow(' Please re-run your command to continue.'),
328
+ )
329
+ }
330
+ process.exit(1)
331
+ }
332
+
185
333
  console.error(error(e.message))
186
334
  process.exit(1)
335
+ } finally {
336
+ // Clean up temp file if we created one
337
+ if (tempDumpPath) {
338
+ try {
339
+ await rm(tempDumpPath, { force: true })
340
+ } catch {
341
+ // Ignore cleanup errors
342
+ }
343
+ }
187
344
  }
188
345
  },
189
346
  )
package/cli/index.ts CHANGED
@@ -10,6 +10,7 @@ import { cloneCommand } from './commands/clone'
10
10
  import { menuCommand } from './commands/menu'
11
11
  import { configCommand } from './commands/config'
12
12
  import { postgresToolsCommand } from './commands/postgres-tools'
13
+ import { depsCommand } from './commands/deps'
13
14
 
14
15
  export async function run(): Promise<void> {
15
16
  program
@@ -28,6 +29,7 @@ export async function run(): Promise<void> {
28
29
  program.addCommand(menuCommand)
29
30
  program.addCommand(configCommand)
30
31
  program.addCommand(postgresToolsCommand)
32
+ program.addCommand(depsCommand)
31
33
 
32
34
  // If no arguments provided, show interactive menu
33
35
  if (process.argv.length <= 2) {
package/cli/ui/prompts.ts CHANGED
@@ -3,6 +3,13 @@ import chalk from 'chalk'
3
3
  import ora from 'ora'
4
4
  import { listEngines, getEngine } from '../../engines'
5
5
  import { defaults } from '../../config/defaults'
6
+ import { installPostgresBinaries } from '../../core/postgres-binary-manager'
7
+ import {
8
+ detectPackageManager,
9
+ getManualInstallInstructions,
10
+ getCurrentPlatform,
11
+ } from '../../core/dependency-manager'
12
+ import { getEngineDependencies } from '../../config/os-dependencies'
6
13
  import type { ContainerConfig } from '../../types'
7
14
 
8
15
  /**
@@ -285,3 +292,129 @@ export async function promptCreateOptions(
285
292
 
286
293
  return { name, engine, version, port, database }
287
294
  }
295
+
296
+ /**
297
+ * Prompt user to install missing database client tools
298
+ * Returns true if installation was successful or user declined, false if installation failed
299
+ *
300
+ * @param missingTool - The name of the missing tool (e.g., 'psql', 'pg_dump', 'mysql')
301
+ * @param engine - The database engine (defaults to 'postgresql')
302
+ */
303
+ export async function promptInstallDependencies(
304
+ missingTool: string,
305
+ engine: string = 'postgresql',
306
+ ): Promise<boolean> {
307
+ const platform = getCurrentPlatform()
308
+
309
+ console.log()
310
+ console.log(
311
+ chalk.yellow(` Database client tool "${missingTool}" is not installed.`),
312
+ )
313
+ console.log()
314
+
315
+ // Check what package manager is available
316
+ const packageManager = await detectPackageManager()
317
+
318
+ if (!packageManager) {
319
+ console.log(chalk.red(' No supported package manager found.'))
320
+ console.log()
321
+
322
+ // Get instructions from the dependency registry
323
+ const engineDeps = getEngineDependencies(engine)
324
+ if (engineDeps) {
325
+ // Find the specific dependency or use the first one for general instructions
326
+ const dep =
327
+ engineDeps.dependencies.find((d) => d.binary === missingTool) ||
328
+ engineDeps.dependencies[0]
329
+
330
+ if (dep) {
331
+ const instructions = getManualInstallInstructions(dep, platform)
332
+ console.log(
333
+ chalk.gray(` Please install ${engineDeps.displayName} client tools:`),
334
+ )
335
+ console.log()
336
+ for (const instruction of instructions) {
337
+ console.log(chalk.gray(` ${instruction}`))
338
+ }
339
+ }
340
+ }
341
+ console.log()
342
+ return false
343
+ }
344
+
345
+ console.log(
346
+ chalk.gray(` Detected package manager: ${chalk.white(packageManager.name)}`),
347
+ )
348
+ console.log()
349
+
350
+ // Get engine display name
351
+ const engineDeps = getEngineDependencies(engine)
352
+ const engineName = engineDeps?.displayName || engine
353
+
354
+ const { shouldInstall } = await inquirer.prompt<{ shouldInstall: string }>([
355
+ {
356
+ type: 'list',
357
+ name: 'shouldInstall',
358
+ message: `Would you like to install ${engineName} client tools now?`,
359
+ choices: [
360
+ { name: 'Yes, install now', value: 'yes' },
361
+ { name: 'No, I will install manually', value: 'no' },
362
+ ],
363
+ default: 'yes',
364
+ },
365
+ ])
366
+
367
+ if (shouldInstall === 'no') {
368
+ console.log()
369
+ console.log(chalk.gray(' To install manually, run:'))
370
+
371
+ // Get the specific dependency and build install command info
372
+ if (engineDeps) {
373
+ const dep = engineDeps.dependencies.find((d) => d.binary === missingTool)
374
+ if (dep) {
375
+ const pkgDef = dep.packages[packageManager.id]
376
+ if (pkgDef) {
377
+ const installCmd = packageManager.config.installTemplate.replace(
378
+ '{package}',
379
+ pkgDef.package,
380
+ )
381
+ console.log(chalk.cyan(` ${installCmd}`))
382
+ if (pkgDef.postInstall) {
383
+ for (const postCmd of pkgDef.postInstall) {
384
+ console.log(chalk.cyan(` ${postCmd}`))
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+ console.log()
391
+ return false
392
+ }
393
+
394
+ console.log()
395
+
396
+ // For now, only PostgreSQL has full install support
397
+ // Future engines will need their own install functions
398
+ if (engine === 'postgresql') {
399
+ const success = await installPostgresBinaries()
400
+
401
+ if (success) {
402
+ console.log()
403
+ console.log(
404
+ chalk.green(` ${engineName} client tools installed successfully!`),
405
+ )
406
+ console.log(chalk.gray(' Continuing with your operation...'))
407
+ console.log()
408
+ }
409
+
410
+ return success
411
+ }
412
+
413
+ // For other engines, show manual instructions
414
+ console.log(
415
+ chalk.yellow(` Automatic installation for ${engineName} is not yet supported.`),
416
+ )
417
+ console.log(chalk.gray(' Please install manually.'))
418
+ console.log()
419
+ return false
420
+ }