spindb 0.5.2 → 0.5.4

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 (38) hide show
  1. package/README.md +188 -9
  2. package/cli/commands/connect.ts +334 -105
  3. package/cli/commands/create.ts +106 -67
  4. package/cli/commands/deps.ts +19 -4
  5. package/cli/commands/edit.ts +245 -0
  6. package/cli/commands/engines.ts +434 -0
  7. package/cli/commands/info.ts +279 -0
  8. package/cli/commands/list.ts +1 -1
  9. package/cli/commands/menu.ts +664 -167
  10. package/cli/commands/restore.ts +11 -25
  11. package/cli/commands/start.ts +25 -20
  12. package/cli/commands/url.ts +79 -0
  13. package/cli/index.ts +9 -3
  14. package/cli/ui/prompts.ts +20 -12
  15. package/cli/ui/theme.ts +1 -1
  16. package/config/engine-defaults.ts +24 -1
  17. package/config/os-dependencies.ts +151 -113
  18. package/config/paths.ts +7 -36
  19. package/core/binary-manager.ts +12 -6
  20. package/core/config-manager.ts +17 -5
  21. package/core/dependency-manager.ts +144 -15
  22. package/core/error-handler.ts +336 -0
  23. package/core/platform-service.ts +634 -0
  24. package/core/port-manager.ts +11 -3
  25. package/core/process-manager.ts +12 -2
  26. package/core/start-with-retry.ts +167 -0
  27. package/core/transaction-manager.ts +170 -0
  28. package/engines/mysql/binary-detection.ts +177 -100
  29. package/engines/mysql/index.ts +240 -131
  30. package/engines/mysql/restore.ts +257 -0
  31. package/engines/mysql/version-validator.ts +373 -0
  32. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  33. package/engines/postgresql/binary-urls.ts +5 -3
  34. package/engines/postgresql/index.ts +35 -4
  35. package/engines/postgresql/restore.ts +54 -5
  36. package/engines/postgresql/version-validator.ts +262 -0
  37. package/package.json +6 -2
  38. package/cli/commands/postgres-tools.ts +0 -216
@@ -16,9 +16,10 @@ import { createSpinner } from '../ui/spinner'
16
16
  import { header, error, connectionBox } from '../ui/theme'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
- import { spawn } from 'child_process'
20
- import { platform } from 'os'
21
19
  import { getMissingDependencies } from '../../core/dependency-manager'
20
+ import { platformService } from '../../core/platform-service'
21
+ import { startWithRetry } from '../../core/start-with-retry'
22
+ import { TransactionManager } from '../../core/transaction-manager'
22
23
  import type { EngineName } from '../../types'
23
24
 
24
25
  /**
@@ -244,28 +245,50 @@ export const createCommand = new Command('create')
244
245
  containerName = await promptContainerName()
245
246
  }
246
247
 
248
+ // Create transaction manager for rollback support
249
+ const tx = new TransactionManager()
250
+
247
251
  // Create container
248
252
  const createSpinnerInstance = createSpinner('Creating container...')
249
253
  createSpinnerInstance.start()
250
254
 
251
- await containerManager.create(containerName, {
252
- engine: dbEngine.name as EngineName,
253
- version,
254
- port,
255
- database,
256
- })
255
+ try {
256
+ await containerManager.create(containerName, {
257
+ engine: dbEngine.name as EngineName,
258
+ version,
259
+ port,
260
+ database,
261
+ })
262
+
263
+ // Register rollback action for container deletion
264
+ tx.addRollback({
265
+ description: `Delete container "${containerName}"`,
266
+ execute: async () => {
267
+ await containerManager.delete(containerName, { force: true })
268
+ },
269
+ })
257
270
 
258
- createSpinnerInstance.succeed('Container created')
271
+ createSpinnerInstance.succeed('Container created')
272
+ } catch (err) {
273
+ createSpinnerInstance.fail('Failed to create container')
274
+ throw err
275
+ }
259
276
 
260
277
  // Initialize database cluster
261
278
  const initSpinner = createSpinner('Initializing database cluster...')
262
279
  initSpinner.start()
263
280
 
264
- await dbEngine.initDataDir(containerName, version, {
265
- superuser: engineDefaults.superuser,
266
- })
267
-
268
- initSpinner.succeed('Database cluster initialized')
281
+ try {
282
+ await dbEngine.initDataDir(containerName, version, {
283
+ superuser: engineDefaults.superuser,
284
+ })
285
+ // Note: initDataDir is covered by the container delete rollback
286
+ initSpinner.succeed('Database cluster initialized')
287
+ } catch (err) {
288
+ initSpinner.fail('Failed to initialize database cluster')
289
+ await tx.rollback()
290
+ throw err
291
+ }
269
292
 
270
293
  // Determine if we should start the container
271
294
  // If --from is specified, we must start to restore
@@ -281,10 +304,7 @@ export const createCommand = new Command('create')
281
304
  } else {
282
305
  // Ask the user
283
306
  console.log()
284
- shouldStart = await promptConfirm(
285
- `Start ${containerName} now?`,
286
- true,
287
- )
307
+ shouldStart = await promptConfirm(`Start ${containerName} now?`, true)
288
308
  }
289
309
 
290
310
  // Get container config for starting and restoration
@@ -292,34 +312,63 @@ export const createCommand = new Command('create')
292
312
 
293
313
  // Start container if requested
294
314
  if (shouldStart && config) {
295
- // Check port availability before starting
296
- const portAvailable = await portManager.isPortAvailable(config.port)
297
- if (!portAvailable) {
298
- // Find a new available port
299
- const { port: newPort } = await portManager.findAvailablePort({
300
- portRange: engineDefaults.portRange,
301
- })
302
- console.log(
303
- chalk.yellow(
304
- ` ⚠ Port ${config.port} is in use, switching to port ${newPort}`,
305
- ),
306
- )
307
- config.port = newPort
308
- port = newPort
309
- await containerManager.updateConfig(containerName, { port: newPort })
310
- }
311
-
312
315
  const startSpinner = createSpinner(
313
316
  `Starting ${dbEngine.displayName}...`,
314
317
  )
315
318
  startSpinner.start()
316
319
 
317
- await dbEngine.start(config)
318
- await containerManager.updateConfig(containerName, {
319
- status: 'running',
320
- })
320
+ try {
321
+ // Use startWithRetry to handle port race conditions
322
+ const result = await startWithRetry({
323
+ engine: dbEngine,
324
+ config,
325
+ onPortChange: (oldPort, newPort) => {
326
+ startSpinner.text = `Port ${oldPort} was in use, retrying with port ${newPort}...`
327
+ port = newPort
328
+ },
329
+ })
321
330
 
322
- startSpinner.succeed(`${dbEngine.displayName} started`)
331
+ if (!result.success) {
332
+ startSpinner.fail(`Failed to start ${dbEngine.displayName}`)
333
+ await tx.rollback()
334
+ if (result.error) {
335
+ throw result.error
336
+ }
337
+ throw new Error('Failed to start container')
338
+ }
339
+
340
+ // Register rollback action for stopping the container
341
+ tx.addRollback({
342
+ description: `Stop container "${containerName}"`,
343
+ execute: async () => {
344
+ try {
345
+ await dbEngine.stop(config)
346
+ } catch {
347
+ // Ignore stop errors during rollback
348
+ }
349
+ },
350
+ })
351
+
352
+ await containerManager.updateConfig(containerName, {
353
+ status: 'running',
354
+ })
355
+
356
+ if (result.retriesUsed > 0) {
357
+ startSpinner.warn(
358
+ `${dbEngine.displayName} started on port ${result.finalPort} (original port was in use)`,
359
+ )
360
+ } else {
361
+ startSpinner.succeed(`${dbEngine.displayName} started`)
362
+ }
363
+ } catch (err) {
364
+ if (!startSpinner.isSpinning) {
365
+ // Error was already handled above
366
+ } else {
367
+ startSpinner.fail(`Failed to start ${dbEngine.displayName}`)
368
+ }
369
+ await tx.rollback()
370
+ throw err
371
+ }
323
372
 
324
373
  // Create the user's database (if different from default)
325
374
  const defaultDb = engineDefaults.superuser // postgres or root
@@ -329,9 +378,14 @@ export const createCommand = new Command('create')
329
378
  )
330
379
  dbSpinner.start()
331
380
 
332
- await dbEngine.createDatabase(config, database)
333
-
334
- dbSpinner.succeed(`Database "${database}" created`)
381
+ try {
382
+ await dbEngine.createDatabase(config, database)
383
+ dbSpinner.succeed(`Database "${database}" created`)
384
+ } catch (err) {
385
+ dbSpinner.fail(`Failed to create database "${database}"`)
386
+ await tx.rollback()
387
+ throw err
388
+ }
335
389
  }
336
390
  }
337
391
 
@@ -431,13 +485,18 @@ export const createCommand = new Command('create')
431
485
  }
432
486
  }
433
487
 
488
+ // Commit the transaction - all operations succeeded
489
+ tx.commit()
490
+
434
491
  // Show success message
435
492
  const finalConfig = await containerManager.getConfig(containerName)
436
493
  if (finalConfig) {
437
494
  const connectionString = dbEngine.getConnectionString(finalConfig)
438
495
 
439
496
  console.log()
440
- console.log(connectionBox(containerName, connectionString, finalConfig.port))
497
+ console.log(
498
+ connectionBox(containerName, connectionString, finalConfig.port),
499
+ )
441
500
  console.log()
442
501
 
443
502
  if (shouldStart) {
@@ -445,30 +504,10 @@ export const createCommand = new Command('create')
445
504
  console.log(chalk.cyan(` spindb connect ${containerName}`))
446
505
 
447
506
  // Copy connection string to clipboard
448
- try {
449
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
450
- const args =
451
- platform() === 'darwin' ? [] : ['-selection', 'clipboard']
452
-
453
- await new Promise<void>((resolve, reject) => {
454
- const proc = spawn(cmd, args, {
455
- stdio: ['pipe', 'inherit', 'inherit'],
456
- })
457
- proc.stdin?.write(connectionString)
458
- proc.stdin?.end()
459
- proc.on('close', (code) => {
460
- if (code === 0) resolve()
461
- else
462
- reject(
463
- new Error(`Clipboard command exited with code ${code}`),
464
- )
465
- })
466
- proc.on('error', reject)
467
- })
468
-
507
+ const copied =
508
+ await platformService.copyToClipboard(connectionString)
509
+ if (copied) {
469
510
  console.log(chalk.gray(' Connection string copied to clipboard'))
470
- } catch {
471
- // Ignore clipboard errors
472
511
  }
473
512
  } else {
474
513
  console.log(chalk.gray(' Start the container:'))
@@ -188,7 +188,11 @@ depsCommand
188
188
 
189
189
  if (succeeded.length > 0) {
190
190
  console.log()
191
- console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
191
+ console.log(
192
+ success(
193
+ `Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`,
194
+ ),
195
+ )
192
196
  }
193
197
  } else if (options.engine) {
194
198
  // Install dependencies for specific engine
@@ -254,7 +258,11 @@ depsCommand
254
258
 
255
259
  if (succeeded.length > 0) {
256
260
  console.log()
257
- console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
261
+ console.log(
262
+ success(
263
+ `Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`,
264
+ ),
265
+ )
258
266
  }
259
267
  } else {
260
268
  // Default: install PostgreSQL dependencies (most common use case)
@@ -278,7 +286,10 @@ depsCommand
278
286
  const spinner = createSpinner('Installing PostgreSQL dependencies...')
279
287
  spinner.start()
280
288
 
281
- const results = await installEngineDependencies('postgresql', packageManager)
289
+ const results = await installEngineDependencies(
290
+ 'postgresql',
291
+ packageManager,
292
+ )
282
293
 
283
294
  const succeeded = results.filter((r) => r.success)
284
295
  const failed = results.filter((r) => !r.success)
@@ -295,7 +306,11 @@ depsCommand
295
306
 
296
307
  if (succeeded.length > 0) {
297
308
  console.log()
298
- console.log(success(`Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`))
309
+ console.log(
310
+ success(
311
+ `Installed: ${succeeded.map((r) => r.dependency.name).join(', ')}`,
312
+ ),
313
+ )
299
314
  }
300
315
  }
301
316
  })
@@ -0,0 +1,245 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import { containerManager } from '../../core/container-manager'
5
+ import { processManager } from '../../core/process-manager'
6
+ import { portManager } from '../../core/port-manager'
7
+ import { promptContainerSelect } from '../ui/prompts'
8
+ import { createSpinner } from '../ui/spinner'
9
+ import { error, warning, success } from '../ui/theme'
10
+
11
+ /**
12
+ * Validate container name format
13
+ */
14
+ function isValidName(name: string): boolean {
15
+ return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
16
+ }
17
+
18
+ /**
19
+ * Prompt for what to edit when no options provided
20
+ */
21
+ async function promptEditAction(): Promise<'name' | 'port' | null> {
22
+ const { action } = await inquirer.prompt<{ action: string }>([
23
+ {
24
+ type: 'list',
25
+ name: 'action',
26
+ message: 'What would you like to edit?',
27
+ choices: [
28
+ { name: 'Rename container', value: 'name' },
29
+ { name: 'Change port', value: 'port' },
30
+ { name: chalk.gray('Cancel'), value: 'cancel' },
31
+ ],
32
+ },
33
+ ])
34
+
35
+ if (action === 'cancel') return null
36
+ return action as 'name' | 'port'
37
+ }
38
+
39
+ /**
40
+ * Prompt for new container name
41
+ */
42
+ async function promptNewName(currentName: string): Promise<string | null> {
43
+ const { newName } = await inquirer.prompt<{ newName: string }>([
44
+ {
45
+ type: 'input',
46
+ name: 'newName',
47
+ message: 'New container name:',
48
+ default: currentName,
49
+ validate: (input: string) => {
50
+ if (!input) return 'Name is required'
51
+ if (!isValidName(input)) {
52
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
53
+ }
54
+ return true
55
+ },
56
+ },
57
+ ])
58
+
59
+ if (newName === currentName) {
60
+ console.log(warning('Name unchanged'))
61
+ return null
62
+ }
63
+
64
+ return newName
65
+ }
66
+
67
+ /**
68
+ * Prompt for new port
69
+ */
70
+ async function promptNewPort(currentPort: number): Promise<number | null> {
71
+ const { newPort } = await inquirer.prompt<{ newPort: number }>([
72
+ {
73
+ type: 'input',
74
+ name: 'newPort',
75
+ message: 'New port:',
76
+ default: String(currentPort),
77
+ validate: (input: string) => {
78
+ const num = parseInt(input, 10)
79
+ if (isNaN(num) || num < 1 || num > 65535) {
80
+ return 'Port must be a number between 1 and 65535'
81
+ }
82
+ return true
83
+ },
84
+ filter: (input: string) => parseInt(input, 10),
85
+ },
86
+ ])
87
+
88
+ if (newPort === currentPort) {
89
+ console.log(warning('Port unchanged'))
90
+ return null
91
+ }
92
+
93
+ return newPort
94
+ }
95
+
96
+ export const editCommand = new Command('edit')
97
+ .description('Edit container properties (rename or change port)')
98
+ .argument('[name]', 'Container name')
99
+ .option('-n, --name <newName>', 'New container name')
100
+ .option('-p, --port <port>', 'New port number', parseInt)
101
+ .action(
102
+ async (
103
+ name: string | undefined,
104
+ options: { name?: string; port?: number },
105
+ ) => {
106
+ try {
107
+ let containerName = name
108
+
109
+ // Interactive selection if no name provided
110
+ if (!containerName) {
111
+ const containers = await containerManager.list()
112
+
113
+ if (containers.length === 0) {
114
+ console.log(warning('No containers found'))
115
+ return
116
+ }
117
+
118
+ const selected = await promptContainerSelect(
119
+ containers,
120
+ 'Select container to edit:',
121
+ )
122
+ if (!selected) return
123
+ containerName = selected
124
+ }
125
+
126
+ // Get container config
127
+ const config = await containerManager.getConfig(containerName)
128
+ if (!config) {
129
+ console.error(error(`Container "${containerName}" not found`))
130
+ process.exit(1)
131
+ }
132
+
133
+ // If no options provided, prompt for what to edit
134
+ if (options.name === undefined && options.port === undefined) {
135
+ const action = await promptEditAction()
136
+ if (!action) return
137
+
138
+ if (action === 'name') {
139
+ const newName = await promptNewName(containerName)
140
+ if (newName) {
141
+ options.name = newName
142
+ } else {
143
+ return
144
+ }
145
+ } else if (action === 'port') {
146
+ const newPort = await promptNewPort(config.port)
147
+ if (newPort) {
148
+ options.port = newPort
149
+ } else {
150
+ return
151
+ }
152
+ }
153
+ }
154
+
155
+ // Handle rename
156
+ if (options.name) {
157
+ // Validate new name
158
+ if (!isValidName(options.name)) {
159
+ console.error(
160
+ error(
161
+ 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores',
162
+ ),
163
+ )
164
+ process.exit(1)
165
+ }
166
+
167
+ // Check if new name already exists
168
+ const exists = await containerManager.exists(options.name, {
169
+ engine: config.engine,
170
+ })
171
+ if (exists) {
172
+ console.error(error(`Container "${options.name}" already exists`))
173
+ process.exit(1)
174
+ }
175
+
176
+ // Check if container is running
177
+ const running = await processManager.isRunning(containerName, {
178
+ engine: config.engine,
179
+ })
180
+ if (running) {
181
+ console.error(
182
+ error(
183
+ `Container "${containerName}" is running. Stop it first to rename.`,
184
+ ),
185
+ )
186
+ process.exit(1)
187
+ }
188
+
189
+ // Rename the container
190
+ const spinner = createSpinner(
191
+ `Renaming "${containerName}" to "${options.name}"...`,
192
+ )
193
+ spinner.start()
194
+
195
+ await containerManager.rename(containerName, options.name)
196
+
197
+ spinner.succeed(`Renamed "${containerName}" to "${options.name}"`)
198
+
199
+ // Update containerName for subsequent operations
200
+ containerName = options.name
201
+ }
202
+
203
+ // Handle port change
204
+ if (options.port !== undefined) {
205
+ // Validate port
206
+ if (options.port < 1 || options.port > 65535) {
207
+ console.error(error('Port must be between 1 and 65535'))
208
+ process.exit(1)
209
+ }
210
+
211
+ // Check port availability (warning only)
212
+ const portAvailable = await portManager.isPortAvailable(options.port)
213
+ if (!portAvailable) {
214
+ console.log(
215
+ warning(
216
+ `Port ${options.port} is currently in use. The container will use this port on next start.`,
217
+ ),
218
+ )
219
+ }
220
+
221
+ // Update the config
222
+ const spinner = createSpinner(`Changing port to ${options.port}...`)
223
+ spinner.start()
224
+
225
+ await containerManager.updateConfig(containerName, {
226
+ port: options.port,
227
+ })
228
+
229
+ spinner.succeed(`Port changed to ${options.port}`)
230
+ console.log(
231
+ chalk.gray(
232
+ ' Note: Port change takes effect on next container start.',
233
+ ),
234
+ )
235
+ }
236
+
237
+ console.log()
238
+ console.log(success('Container updated successfully'))
239
+ } catch (err) {
240
+ const e = err as Error
241
+ console.error(error(e.message))
242
+ process.exit(1)
243
+ }
244
+ },
245
+ )