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.
package/README.md CHANGED
@@ -51,6 +51,8 @@ spindb connect mydb
51
51
  | `spindb delete [name]` | Delete a container |
52
52
  | `spindb config show` | Show configuration |
53
53
  | `spindb config detect` | Auto-detect PostgreSQL tools |
54
+ | `spindb deps check` | Check status of client tools |
55
+ | `spindb deps install` | Install missing client tools |
54
56
 
55
57
  ## How It Works
56
58
 
@@ -71,15 +73,50 @@ Data is stored in `~/.spindb/`:
71
73
 
72
74
  ## PostgreSQL Client Tools
73
75
 
74
- SpinDB bundles the PostgreSQL **server** (postgres, pg_ctl, initdb) but not client tools (psql, pg_dump, pg_restore). For `connect` and `restore` commands, you need PostgreSQL client tools installed:
76
+ SpinDB bundles the PostgreSQL **server** (postgres, pg_ctl, initdb) but not client tools (psql, pg_dump, pg_restore). For `connect` and `restore` commands, you need PostgreSQL client tools installed.
77
+
78
+ ### Automatic Installation
79
+
80
+ SpinDB can check and install client tools automatically:
81
+
82
+ ```bash
83
+ # Check status of all client tools
84
+ spindb deps check
85
+
86
+ # Install missing tools (uses Homebrew, apt, yum, dnf, or pacman)
87
+ spindb deps install
88
+
89
+ # Install for a specific engine
90
+ spindb deps install --engine postgresql
91
+ spindb deps install --engine mysql
92
+
93
+ # Install all missing dependencies for all engines
94
+ spindb deps install --all
95
+
96
+ # List all supported dependencies
97
+ spindb deps list
98
+ ```
99
+
100
+ ### Manual Installation
101
+
102
+ If automatic installation doesn't work, install manually:
75
103
 
76
104
  ```bash
77
105
  # macOS (Homebrew)
78
- brew install libpq
79
- brew link --force libpq
106
+ brew install postgresql@17
107
+ brew link --overwrite postgresql@17
80
108
 
81
109
  # Ubuntu/Debian
82
- apt install postgresql-client
110
+ sudo apt install postgresql-client
111
+
112
+ # CentOS/RHEL
113
+ sudo yum install postgresql
114
+
115
+ # Fedora
116
+ sudo dnf install postgresql
117
+
118
+ # Arch
119
+ sudo pacman -S postgresql-libs
83
120
 
84
121
  # Or use Postgres.app (macOS)
85
122
  # Client tools are automatically detected
@@ -123,16 +160,33 @@ spindb create mydb --database my_app_db
123
160
  # Connection string: postgresql://postgres@localhost:5432/my_app_db
124
161
  ```
125
162
 
126
- ### Restore a backup
163
+ ### Create and restore in one command
127
164
 
128
165
  ```bash
129
- # Start the container first
130
- spindb start mydb
166
+ # Create a container and restore from a dump file
167
+ spindb create mydb --from ./backup.dump
168
+
169
+ # Create a container and pull from a remote database
170
+ spindb create mydb --from "postgresql://user:pass@remote-host:5432/production_db"
171
+
172
+ # With specific version and database name
173
+ spindb create mydb --pg-version 17 --database myapp --from ./backup.dump
174
+ ```
131
175
 
132
- # Restore (supports .sql, custom format, and tar format)
176
+ The `--from` option auto-detects whether the location is a file path or connection string.
177
+
178
+ ### Restore to an existing container
179
+
180
+ ```bash
181
+ # Restore from a dump file (supports .sql, custom format, and tar format)
133
182
  spindb restore mydb ./backup.dump -d myapp
183
+
184
+ # Or pull directly from a remote database
185
+ spindb restore mydb --from-url "postgresql://user:pass@remote-host:5432/production_db" -d myapp
134
186
  ```
135
187
 
188
+ The interactive menu (`spindb` → "Restore backup") also offers an option to create a new container as part of the restore flow.
189
+
136
190
  ### Clone for testing
137
191
 
138
192
  ```bash
@@ -1,12 +1,43 @@
1
1
  import { Command } from 'commander'
2
+ import { existsSync } from 'fs'
3
+ import { rm } from 'fs/promises'
2
4
  import chalk from 'chalk'
3
5
  import { containerManager } from '../../core/container-manager'
4
6
  import { portManager } from '../../core/port-manager'
5
7
  import { getEngine } from '../../engines'
6
8
  import { defaults } from '../../config/defaults'
7
- import { promptCreateOptions } from '../ui/prompts'
9
+ import {
10
+ promptCreateOptions,
11
+ promptInstallDependencies,
12
+ } from '../ui/prompts'
8
13
  import { createSpinner } from '../ui/spinner'
9
14
  import { header, error, connectionBox } from '../ui/theme'
15
+ import { tmpdir } from 'os'
16
+ import { join } from 'path'
17
+ import { spawn } from 'child_process'
18
+ import { platform } from 'os'
19
+
20
+ /**
21
+ * Detect if a location string is a connection string or a file path
22
+ */
23
+ function detectLocationType(
24
+ location: string,
25
+ ): 'connection' | 'file' | 'not_found' {
26
+ // Check if it's a connection string
27
+ if (
28
+ location.startsWith('postgresql://') ||
29
+ location.startsWith('postgres://')
30
+ ) {
31
+ return 'connection'
32
+ }
33
+
34
+ // Check if file exists
35
+ if (existsSync(location)) {
36
+ return 'file'
37
+ }
38
+
39
+ return 'not_found'
40
+ }
10
41
 
11
42
  export const createCommand = new Command('create')
12
43
  .description('Create a new database container')
@@ -20,6 +51,10 @@ export const createCommand = new Command('create')
20
51
  .option('-d, --database <database>', 'Database name')
21
52
  .option('-p, --port <port>', 'Port number')
22
53
  .option('--no-start', 'Do not start the container after creation')
54
+ .option(
55
+ '--from <location>',
56
+ 'Restore from a dump file or connection string after creation',
57
+ )
23
58
  .action(
24
59
  async (
25
60
  name: string | undefined,
@@ -29,8 +64,11 @@ export const createCommand = new Command('create')
29
64
  database?: string
30
65
  port?: string
31
66
  start: boolean
67
+ from?: string
32
68
  },
33
69
  ) => {
70
+ let tempDumpPath: string | null = null
71
+
34
72
  try {
35
73
  let containerName = name
36
74
  let engine = options.engine
@@ -49,6 +87,37 @@ export const createCommand = new Command('create')
49
87
  // Default database name to container name if not specified
50
88
  database = database ?? containerName
51
89
 
90
+ // Validate --from location if provided
91
+ let restoreLocation: string | null = null
92
+ let restoreType: 'connection' | 'file' | null = null
93
+
94
+ if (options.from) {
95
+ const locationType = detectLocationType(options.from)
96
+
97
+ if (locationType === 'not_found') {
98
+ console.error(error(`Location not found: ${options.from}`))
99
+ console.log(
100
+ chalk.gray(
101
+ ' Provide a valid file path or connection string (postgresql://...)',
102
+ ),
103
+ )
104
+ process.exit(1)
105
+ }
106
+
107
+ restoreLocation = options.from
108
+ restoreType = locationType
109
+
110
+ // If using --from, we must start the container
111
+ if (options.start === false) {
112
+ console.error(
113
+ error(
114
+ 'Cannot use --no-start with --from (restore requires running container)',
115
+ ),
116
+ )
117
+ process.exit(1)
118
+ }
119
+ }
120
+
52
121
  console.log(header('Creating Database Container'))
53
122
  console.log()
54
123
 
@@ -145,6 +214,84 @@ export const createCommand = new Command('create')
145
214
 
146
215
  dbSpinner.succeed(`Database "${database}" created`)
147
216
  }
217
+
218
+ // Handle --from restore if specified
219
+ if (restoreLocation && restoreType && config) {
220
+ let backupPath: string
221
+
222
+ if (restoreType === 'connection') {
223
+ // Create dump from remote database
224
+ const timestamp = Date.now()
225
+ tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
226
+
227
+ const dumpSpinner = createSpinner(
228
+ 'Creating dump from remote database...',
229
+ )
230
+ dumpSpinner.start()
231
+
232
+ try {
233
+ await dbEngine.dumpFromConnectionString(
234
+ restoreLocation,
235
+ tempDumpPath,
236
+ )
237
+ dumpSpinner.succeed('Dump created from remote database')
238
+ backupPath = tempDumpPath
239
+ } catch (err) {
240
+ const e = err as Error
241
+ dumpSpinner.fail('Failed to create dump')
242
+
243
+ // Check if this is a missing tool error
244
+ if (
245
+ e.message.includes('pg_dump not found') ||
246
+ e.message.includes('ENOENT')
247
+ ) {
248
+ await promptInstallDependencies('pg_dump')
249
+ process.exit(1)
250
+ }
251
+
252
+ console.log()
253
+ console.error(error('pg_dump error:'))
254
+ console.log(chalk.gray(` ${e.message}`))
255
+ process.exit(1)
256
+ }
257
+ } else {
258
+ backupPath = restoreLocation
259
+ }
260
+
261
+ // Detect backup format
262
+ const detectSpinner = createSpinner('Detecting backup format...')
263
+ detectSpinner.start()
264
+
265
+ const format = await dbEngine.detectBackupFormat(backupPath)
266
+ detectSpinner.succeed(`Detected: ${format.description}`)
267
+
268
+ // Restore backup
269
+ const restoreSpinner = createSpinner('Restoring backup...')
270
+ restoreSpinner.start()
271
+
272
+ const result = await dbEngine.restore(config, backupPath, {
273
+ database,
274
+ createDatabase: false, // Already created above
275
+ })
276
+
277
+ if (result.code === 0 || !result.stderr) {
278
+ restoreSpinner.succeed('Backup restored successfully')
279
+ } else {
280
+ restoreSpinner.warn('Restore completed with warnings')
281
+ if (result.stderr) {
282
+ console.log(chalk.yellow('\n Warnings:'))
283
+ const lines = result.stderr.split('\n').slice(0, 5)
284
+ lines.forEach((line) => {
285
+ if (line.trim()) {
286
+ console.log(chalk.gray(` ${line}`))
287
+ }
288
+ })
289
+ if (result.stderr.split('\n').length > 5) {
290
+ console.log(chalk.gray(' ...'))
291
+ }
292
+ }
293
+ }
294
+ }
148
295
  }
149
296
 
150
297
  // Show success message
@@ -157,12 +304,67 @@ export const createCommand = new Command('create')
157
304
  console.log()
158
305
  console.log(chalk.gray(' Connect with:'))
159
306
  console.log(chalk.cyan(` spindb connect ${containerName}`))
307
+
308
+ // Copy connection string to clipboard
309
+ if (options.start !== false) {
310
+ try {
311
+ const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
312
+ const args =
313
+ platform() === 'darwin' ? [] : ['-selection', 'clipboard']
314
+
315
+ await new Promise<void>((resolve, reject) => {
316
+ const proc = spawn(cmd, args, {
317
+ stdio: ['pipe', 'inherit', 'inherit'],
318
+ })
319
+ proc.stdin?.write(connectionString)
320
+ proc.stdin?.end()
321
+ proc.on('close', (code) => {
322
+ if (code === 0) resolve()
323
+ else
324
+ reject(
325
+ new Error(`Clipboard command exited with code ${code}`),
326
+ )
327
+ })
328
+ proc.on('error', reject)
329
+ })
330
+
331
+ console.log(chalk.gray(' Connection string copied to clipboard'))
332
+ } catch {
333
+ // Ignore clipboard errors
334
+ }
335
+ }
336
+
160
337
  console.log()
161
338
  }
162
339
  } catch (err) {
163
340
  const e = err as Error
341
+
342
+ // Check if this is a missing tool error
343
+ if (
344
+ e.message.includes('pg_restore not found') ||
345
+ e.message.includes('psql not found') ||
346
+ e.message.includes('pg_dump not found')
347
+ ) {
348
+ const missingTool = e.message.includes('pg_restore')
349
+ ? 'pg_restore'
350
+ : e.message.includes('pg_dump')
351
+ ? 'pg_dump'
352
+ : 'psql'
353
+ await promptInstallDependencies(missingTool)
354
+ process.exit(1)
355
+ }
356
+
164
357
  console.error(error(e.message))
165
358
  process.exit(1)
359
+ } finally {
360
+ // Clean up temp file if we created one
361
+ if (tempDumpPath) {
362
+ try {
363
+ await rm(tempDumpPath, { force: true })
364
+ } catch {
365
+ // Ignore cleanup errors
366
+ }
367
+ }
166
368
  }
167
369
  },
168
370
  )
@@ -0,0 +1,326 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { header, success, warning, error } from '../ui/theme'
4
+ import { createSpinner } from '../ui/spinner'
5
+ import {
6
+ detectPackageManager,
7
+ checkEngineDependencies,
8
+ getMissingDependencies,
9
+ getAllMissingDependencies,
10
+ installEngineDependencies,
11
+ installAllDependencies,
12
+ getManualInstallInstructions,
13
+ getCurrentPlatform,
14
+ type DependencyStatus,
15
+ } from '../../core/dependency-manager'
16
+ import {
17
+ engineDependencies,
18
+ getEngineDependencies,
19
+ } from '../../config/os-dependencies'
20
+
21
+ /**
22
+ * Format dependency status for display
23
+ */
24
+ function formatStatus(status: DependencyStatus): string {
25
+ const { dependency, installed, path, version } = status
26
+
27
+ if (installed) {
28
+ const versionStr = version ? ` (${version})` : ''
29
+ const pathStr = path ? chalk.gray(` → ${path}`) : ''
30
+ return ` ${chalk.green('✓')} ${dependency.name}${versionStr}${pathStr}`
31
+ } else {
32
+ return ` ${chalk.red('✗')} ${dependency.name} ${chalk.gray('- not installed')}`
33
+ }
34
+ }
35
+
36
+ export const depsCommand = new Command('deps').description(
37
+ 'Manage OS-level database client dependencies',
38
+ )
39
+
40
+ // =============================================================================
41
+ // deps check
42
+ // =============================================================================
43
+
44
+ depsCommand
45
+ .command('check')
46
+ .description('Check status of database client tools')
47
+ .option('-e, --engine <engine>', 'Check dependencies for a specific engine')
48
+ .option('-a, --all', 'Check all dependencies for all engines')
49
+ .action(async (options: { engine?: string; all?: boolean }) => {
50
+ console.log(header('Dependency Status'))
51
+ console.log()
52
+
53
+ // Detect package manager
54
+ const packageManager = await detectPackageManager()
55
+ if (packageManager) {
56
+ console.log(` Package Manager: ${chalk.cyan(packageManager.name)}`)
57
+ } else {
58
+ console.log(` Package Manager: ${chalk.yellow('Not detected')}`)
59
+ }
60
+ console.log()
61
+
62
+ if (options.all || (!options.engine && !options.all)) {
63
+ // Check all engines
64
+ for (const engineConfig of engineDependencies) {
65
+ console.log(chalk.bold(`${engineConfig.displayName}:`))
66
+
67
+ const statuses = await checkEngineDependencies(engineConfig.engine)
68
+ for (const status of statuses) {
69
+ console.log(formatStatus(status))
70
+ }
71
+
72
+ const installed = statuses.filter((s) => s.installed).length
73
+ const total = statuses.length
74
+ if (installed === total) {
75
+ console.log(chalk.green(` All ${total} dependencies installed`))
76
+ } else {
77
+ console.log(
78
+ chalk.yellow(` ${installed}/${total} dependencies installed`),
79
+ )
80
+ }
81
+ console.log()
82
+ }
83
+ } else if (options.engine) {
84
+ // Check specific engine
85
+ const engineConfig = getEngineDependencies(options.engine)
86
+ if (!engineConfig) {
87
+ console.error(error(`Unknown engine: ${options.engine}`))
88
+ console.log(
89
+ chalk.gray(
90
+ ` Available engines: ${engineDependencies.map((e) => e.engine).join(', ')}`,
91
+ ),
92
+ )
93
+ process.exit(1)
94
+ }
95
+
96
+ console.log(chalk.bold(`${engineConfig.displayName}:`))
97
+
98
+ const statuses = await checkEngineDependencies(options.engine)
99
+ for (const status of statuses) {
100
+ console.log(formatStatus(status))
101
+ }
102
+
103
+ const installed = statuses.filter((s) => s.installed).length
104
+ const total = statuses.length
105
+ console.log()
106
+ if (installed === total) {
107
+ console.log(success(`All ${total} dependencies installed`))
108
+ } else {
109
+ console.log(warning(`${installed}/${total} dependencies installed`))
110
+ console.log()
111
+ console.log(
112
+ chalk.gray(` Run: spindb deps install --engine ${options.engine}`),
113
+ )
114
+ }
115
+ }
116
+ })
117
+
118
+ // =============================================================================
119
+ // deps install
120
+ // =============================================================================
121
+
122
+ depsCommand
123
+ .command('install')
124
+ .description('Install missing database client tools')
125
+ .option(
126
+ '-e, --engine <engine>',
127
+ 'Install dependencies for a specific engine (e.g., postgresql, mysql)',
128
+ )
129
+ .option('-a, --all', 'Install all missing dependencies for all engines')
130
+ .action(async (options: { engine?: string; all?: boolean }) => {
131
+ // Detect package manager first
132
+ const packageManager = await detectPackageManager()
133
+
134
+ if (!packageManager) {
135
+ console.log(error('No supported package manager detected'))
136
+ console.log()
137
+
138
+ const platform = getCurrentPlatform()
139
+ if (platform === 'darwin') {
140
+ console.log(chalk.gray(' macOS: Install Homebrew first:'))
141
+ console.log(
142
+ chalk.cyan(
143
+ ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
144
+ ),
145
+ )
146
+ } else {
147
+ console.log(
148
+ chalk.gray(' Supported package managers: apt, yum, dnf, pacman'),
149
+ )
150
+ }
151
+ process.exit(1)
152
+ }
153
+
154
+ console.log(header('Installing Dependencies'))
155
+ console.log()
156
+ console.log(` Using: ${chalk.cyan(packageManager.name)}`)
157
+ console.log()
158
+
159
+ if (options.all) {
160
+ // Install all missing dependencies
161
+ const missing = await getAllMissingDependencies()
162
+
163
+ if (missing.length === 0) {
164
+ console.log(success('All dependencies are already installed'))
165
+ return
166
+ }
167
+
168
+ console.log(` Missing: ${missing.map((d) => d.name).join(', ')}`)
169
+ console.log()
170
+
171
+ const spinner = createSpinner('Installing dependencies...')
172
+ spinner.start()
173
+
174
+ const results = await installAllDependencies(packageManager)
175
+
176
+ const succeeded = results.filter((r) => r.success)
177
+ const failed = results.filter((r) => !r.success)
178
+
179
+ if (failed.length === 0) {
180
+ spinner.succeed('All dependencies installed successfully')
181
+ } else {
182
+ spinner.warn('Some dependencies failed to install')
183
+ console.log()
184
+ for (const f of failed) {
185
+ console.log(error(` ${f.dependency.name}: ${f.error}`))
186
+ }
187
+ }
188
+
189
+ if (succeeded.length > 0) {
190
+ console.log()
191
+ console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
192
+ }
193
+ } else if (options.engine) {
194
+ // Install dependencies for specific engine
195
+ const engineConfig = getEngineDependencies(options.engine)
196
+ if (!engineConfig) {
197
+ console.error(error(`Unknown engine: ${options.engine}`))
198
+ console.log(
199
+ chalk.gray(
200
+ ` Available engines: ${engineDependencies.map((e) => e.engine).join(', ')}`,
201
+ ),
202
+ )
203
+ process.exit(1)
204
+ }
205
+
206
+ const missing = await getMissingDependencies(options.engine)
207
+
208
+ if (missing.length === 0) {
209
+ console.log(
210
+ success(`All ${engineConfig.displayName} dependencies are installed`),
211
+ )
212
+ return
213
+ }
214
+
215
+ console.log(` Engine: ${chalk.cyan(engineConfig.displayName)}`)
216
+ console.log(` Missing: ${missing.map((d) => d.name).join(', ')}`)
217
+ console.log()
218
+
219
+ const spinner = createSpinner(
220
+ `Installing ${engineConfig.displayName} dependencies...`,
221
+ )
222
+ spinner.start()
223
+
224
+ const results = await installEngineDependencies(
225
+ options.engine,
226
+ packageManager,
227
+ )
228
+
229
+ const succeeded = results.filter((r) => r.success)
230
+ const failed = results.filter((r) => !r.success)
231
+
232
+ if (failed.length === 0) {
233
+ spinner.succeed(
234
+ `${engineConfig.displayName} dependencies installed successfully`,
235
+ )
236
+ } else {
237
+ spinner.warn('Some dependencies failed to install')
238
+ console.log()
239
+ for (const f of failed) {
240
+ console.log(error(` ${f.dependency.name}: ${f.error}`))
241
+ }
242
+
243
+ // Show manual instructions
244
+ console.log()
245
+ console.log(chalk.gray(' Manual installation:'))
246
+ const instructions = getManualInstallInstructions(
247
+ missing[0],
248
+ getCurrentPlatform(),
249
+ )
250
+ for (const instruction of instructions) {
251
+ console.log(chalk.gray(` ${instruction}`))
252
+ }
253
+ }
254
+
255
+ if (succeeded.length > 0) {
256
+ console.log()
257
+ console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
258
+ }
259
+ } else {
260
+ // Default: install PostgreSQL dependencies (most common use case)
261
+ console.log(
262
+ chalk.gray(
263
+ ' No engine specified, defaulting to PostgreSQL. Use --all for all engines.',
264
+ ),
265
+ )
266
+ console.log()
267
+
268
+ const missing = await getMissingDependencies('postgresql')
269
+
270
+ if (missing.length === 0) {
271
+ console.log(success('All PostgreSQL dependencies are installed'))
272
+ return
273
+ }
274
+
275
+ console.log(` Missing: ${missing.map((d) => d.name).join(', ')}`)
276
+ console.log()
277
+
278
+ const spinner = createSpinner('Installing PostgreSQL dependencies...')
279
+ spinner.start()
280
+
281
+ const results = await installEngineDependencies('postgresql', packageManager)
282
+
283
+ const succeeded = results.filter((r) => r.success)
284
+ const failed = results.filter((r) => !r.success)
285
+
286
+ if (failed.length === 0) {
287
+ spinner.succeed('PostgreSQL dependencies installed successfully')
288
+ } else {
289
+ spinner.warn('Some dependencies failed to install')
290
+ console.log()
291
+ for (const f of failed) {
292
+ console.log(error(` ${f.dependency.name}: ${f.error}`))
293
+ }
294
+ }
295
+
296
+ if (succeeded.length > 0) {
297
+ console.log()
298
+ console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
299
+ }
300
+ }
301
+ })
302
+
303
+ // =============================================================================
304
+ // deps list
305
+ // =============================================================================
306
+
307
+ depsCommand
308
+ .command('list')
309
+ .description('List all supported dependencies')
310
+ .action(async () => {
311
+ console.log(header('Supported Dependencies'))
312
+ console.log()
313
+
314
+ for (const engineConfig of engineDependencies) {
315
+ console.log(chalk.bold(`${engineConfig.displayName}:`))
316
+
317
+ for (const dep of engineConfig.dependencies) {
318
+ console.log(` ${chalk.cyan(dep.name)} - ${dep.description}`)
319
+ }
320
+ console.log()
321
+ }
322
+
323
+ console.log(chalk.gray('Use: spindb deps check'))
324
+ console.log(chalk.gray(' spindb deps install --engine <engine>'))
325
+ console.log(chalk.gray(' spindb deps install --all'))
326
+ })