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
@@ -12,10 +12,10 @@ import {
12
12
  } from '../ui/prompts'
13
13
  import { createSpinner } from '../ui/spinner'
14
14
  import { success, error, warning } from '../ui/theme'
15
- import { platform, tmpdir } from 'os'
16
- import { spawn } from 'child_process'
15
+ import { tmpdir } from 'os'
17
16
  import { join } from 'path'
18
17
  import { getMissingDependencies } from '../../core/dependency-manager'
18
+ import { platformService } from '../../core/platform-service'
19
19
 
20
20
  export const restoreCommand = new Command('restore')
21
21
  .description('Restore a backup to a container')
@@ -182,7 +182,10 @@ export const restoreCommand = new Command('restore')
182
182
  dumpSpinner.start()
183
183
 
184
184
  try {
185
- await engine.dumpFromConnectionString(options.fromUrl, tempDumpPath)
185
+ await engine.dumpFromConnectionString(
186
+ options.fromUrl,
187
+ tempDumpPath,
188
+ )
186
189
  dumpSpinner.succeed('Dump created from remote database')
187
190
  backupPath = tempDumpPath
188
191
  dumpSuccess = true
@@ -243,7 +246,7 @@ export const restoreCommand = new Command('restore')
243
246
  // Get database name
244
247
  let databaseName = options.database
245
248
  if (!databaseName) {
246
- databaseName = await promptDatabaseName(containerName)
249
+ databaseName = await promptDatabaseName(containerName, engineName)
247
250
  }
248
251
 
249
252
  // At this point backupPath is guaranteed to be set
@@ -307,28 +310,11 @@ export const restoreCommand = new Command('restore')
307
310
  console.log(chalk.gray(' Connection string:'))
308
311
  console.log(chalk.cyan(` ${connectionString}`))
309
312
 
310
- // Copy connection string to clipboard using platform-specific command
311
- try {
312
- const cmd = platform() === 'darwin' ? 'pbcopy' : 'xclip'
313
- const args =
314
- platform() === 'darwin' ? [] : ['-selection', 'clipboard']
315
-
316
- await new Promise<void>((resolve, reject) => {
317
- const proc = spawn(cmd, args, {
318
- stdio: ['pipe', 'inherit', 'inherit'],
319
- })
320
- proc.stdin?.write(connectionString)
321
- proc.stdin?.end()
322
- proc.on('close', (code) => {
323
- if (code === 0) resolve()
324
- else
325
- reject(new Error(`Clipboard command exited with code ${code}`))
326
- })
327
- proc.on('error', reject)
328
- })
329
-
313
+ // Copy connection string to clipboard using platform service
314
+ const copied = await platformService.copyToClipboard(connectionString)
315
+ if (copied) {
330
316
  console.log(chalk.gray(' Connection string copied to clipboard'))
331
- } catch {
317
+ } else {
332
318
  console.log(chalk.gray(' (Could not copy to clipboard)'))
333
319
  }
334
320
 
@@ -1,8 +1,8 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import { containerManager } from '../../core/container-manager'
4
- import { portManager } from '../../core/port-manager'
5
4
  import { processManager } from '../../core/process-manager'
5
+ import { startWithRetry } from '../../core/start-with-retry'
6
6
  import { getEngine } from '../../engines'
7
7
  import { getEngineDefaults } from '../../config/defaults'
8
8
  import { promptContainerSelect } from '../ui/prompts'
@@ -61,32 +61,37 @@ export const startCommand = new Command('start')
61
61
  // Get engine defaults for port range and database name
62
62
  const engineDefaults = getEngineDefaults(engineName)
63
63
 
64
- // Check port availability
65
- const portAvailable = await portManager.isPortAvailable(config.port)
66
- if (!portAvailable) {
67
- // Try to find a new port (using engine-specific port range)
68
- const { port: newPort } = await portManager.findAvailablePort({
69
- portRange: engineDefaults.portRange,
70
- })
71
- console.log(
72
- warning(
73
- `Port ${config.port} is in use, switching to port ${newPort}`,
74
- ),
75
- )
76
- config.port = newPort
77
- await containerManager.updateConfig(containerName, { port: newPort })
78
- }
79
-
80
- // Get engine and start
64
+ // Get engine and start with retry (handles port race conditions)
81
65
  const engine = getEngine(engineName)
82
66
 
83
67
  const spinner = createSpinner(`Starting ${containerName}...`)
84
68
  spinner.start()
85
69
 
86
- await engine.start(config)
70
+ const result = await startWithRetry({
71
+ engine,
72
+ config,
73
+ onPortChange: (oldPort, newPort) => {
74
+ spinner.text = `Port ${oldPort} was in use, retrying with port ${newPort}...`
75
+ },
76
+ })
77
+
78
+ if (!result.success) {
79
+ spinner.fail(`Failed to start "${containerName}"`)
80
+ if (result.error) {
81
+ console.error(error(result.error.message))
82
+ }
83
+ process.exit(1)
84
+ }
85
+
87
86
  await containerManager.updateConfig(containerName, { status: 'running' })
88
87
 
89
- spinner.succeed(`Container "${containerName}" started`)
88
+ if (result.retriesUsed > 0) {
89
+ spinner.warn(
90
+ `Container "${containerName}" started on port ${result.finalPort} (original port was in use)`,
91
+ )
92
+ } else {
93
+ spinner.succeed(`Container "${containerName}" started`)
94
+ }
90
95
 
91
96
  // Ensure the user's database exists (if different from default)
92
97
  const defaultDb = engineDefaults.superuser // postgres or root
@@ -0,0 +1,79 @@
1
+ import { Command } from 'commander'
2
+ import { containerManager } from '../../core/container-manager'
3
+ import { platformService } from '../../core/platform-service'
4
+ import { getEngine } from '../../engines'
5
+ import { promptContainerSelect } from '../ui/prompts'
6
+ import { error, warning, success } from '../ui/theme'
7
+
8
+ export const urlCommand = new Command('url')
9
+ .alias('connection-string')
10
+ .description('Output connection string for a container')
11
+ .argument('[name]', 'Container name')
12
+ .option('-c, --copy', 'Copy to clipboard')
13
+ .option('-d, --database <database>', 'Use different database name')
14
+ .action(
15
+ async (
16
+ name: string | undefined,
17
+ options: { copy?: boolean; database?: string },
18
+ ) => {
19
+ try {
20
+ let containerName = name
21
+
22
+ // Interactive selection if no name provided
23
+ if (!containerName) {
24
+ const containers = await containerManager.list()
25
+
26
+ if (containers.length === 0) {
27
+ console.log(warning('No containers found'))
28
+ return
29
+ }
30
+
31
+ const selected = await promptContainerSelect(
32
+ containers,
33
+ 'Select container:',
34
+ )
35
+ if (!selected) return
36
+ containerName = selected
37
+ }
38
+
39
+ // Get container config
40
+ const config = await containerManager.getConfig(containerName)
41
+ if (!config) {
42
+ console.error(error(`Container "${containerName}" not found`))
43
+ process.exit(1)
44
+ }
45
+
46
+ // Get connection string
47
+ const engine = getEngine(config.engine)
48
+ const connectionString = engine.getConnectionString(
49
+ config,
50
+ options.database,
51
+ )
52
+
53
+ // Copy to clipboard if requested
54
+ if (options.copy) {
55
+ const copied = await platformService.copyToClipboard(connectionString)
56
+ if (copied) {
57
+ // Output the string AND confirmation
58
+ console.log(connectionString)
59
+ console.error(success('Copied to clipboard'))
60
+ } else {
61
+ // Output the string but warn about clipboard
62
+ console.log(connectionString)
63
+ console.error(warning('Could not copy to clipboard'))
64
+ }
65
+ } else {
66
+ // Just output the connection string (no newline formatting for easy piping)
67
+ process.stdout.write(connectionString)
68
+ // Add newline if stdout is a TTY (interactive terminal)
69
+ if (process.stdout.isTTY) {
70
+ console.log()
71
+ }
72
+ }
73
+ } catch (err) {
74
+ const e = err as Error
75
+ console.error(error(e.message))
76
+ process.exit(1)
77
+ }
78
+ },
79
+ )
package/cli/index.ts CHANGED
@@ -9,14 +9,17 @@ import { connectCommand } from './commands/connect'
9
9
  import { cloneCommand } from './commands/clone'
10
10
  import { menuCommand } from './commands/menu'
11
11
  import { configCommand } from './commands/config'
12
- import { postgresToolsCommand } from './commands/postgres-tools'
13
12
  import { depsCommand } from './commands/deps'
13
+ import { enginesCommand } from './commands/engines'
14
+ import { editCommand } from './commands/edit'
15
+ import { urlCommand } from './commands/url'
16
+ import { infoCommand } from './commands/info'
14
17
 
15
18
  export async function run(): Promise<void> {
16
19
  program
17
20
  .name('spindb')
18
21
  .description('Spin up local database containers without Docker')
19
- .version('0.1.0')
22
+ .version('0.1.0', '-v, --version', 'output the version number')
20
23
 
21
24
  program.addCommand(createCommand)
22
25
  program.addCommand(listCommand)
@@ -28,8 +31,11 @@ export async function run(): Promise<void> {
28
31
  program.addCommand(cloneCommand)
29
32
  program.addCommand(menuCommand)
30
33
  program.addCommand(configCommand)
31
- program.addCommand(postgresToolsCommand)
32
34
  program.addCommand(depsCommand)
35
+ program.addCommand(enginesCommand)
36
+ program.addCommand(editCommand)
37
+ program.addCommand(urlCommand)
38
+ program.addCommand(infoCommand)
33
39
 
34
40
  // If no arguments provided, show interactive menu
35
41
  if (process.argv.length <= 2) {
package/cli/ui/prompts.ts CHANGED
@@ -3,7 +3,7 @@ import chalk from 'chalk'
3
3
  import ora from 'ora'
4
4
  import { listEngines, getEngine } from '../../engines'
5
5
  import { defaults, getEngineDefaults } from '../../config/defaults'
6
- import { installPostgresBinaries } from '../../core/postgres-binary-manager'
6
+ import { installPostgresBinaries } from '../../engines/postgresql/binary-manager'
7
7
  import {
8
8
  detectPackageManager,
9
9
  getManualInstallInstructions,
@@ -53,7 +53,7 @@ export async function promptEngine(): Promise<string> {
53
53
 
54
54
  // Build choices from available engines
55
55
  const choices = engines.map((e) => ({
56
- name: `${engineIcons[e.name] || '🗄️'} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
56
+ name: `${engineIcons[e.name] || ''} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
57
57
  value: e.name,
58
58
  short: e.displayName,
59
59
  }))
@@ -227,7 +227,7 @@ export async function promptContainerSelect(
227
227
  name: 'container',
228
228
  message,
229
229
  choices: containers.map((c) => ({
230
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${
230
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || ''} ${c.engine} ${c.version}, port ${c.port})`)} ${
231
231
  c.status === 'running'
232
232
  ? chalk.green('● running')
233
233
  : chalk.gray('○ stopped')
@@ -243,19 +243,25 @@ export async function promptContainerSelect(
243
243
 
244
244
  /**
245
245
  * Prompt for database name
246
+ * @param defaultName - Default value for the database name
247
+ * @param engine - Database engine (mysql shows "schema" terminology)
246
248
  */
247
249
  export async function promptDatabaseName(
248
250
  defaultName?: string,
251
+ engine?: string,
249
252
  ): Promise<string> {
253
+ // MySQL uses "schema" terminology (database and schema are synonymous)
254
+ const label = engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
255
+
250
256
  const { database } = await inquirer.prompt<{ database: string }>([
251
257
  {
252
258
  type: 'input',
253
259
  name: 'database',
254
- message: 'Database name:',
260
+ message: label,
255
261
  default: defaultName,
256
262
  validate: (input: string) => {
257
263
  if (!input) return 'Database name is required'
258
- // PostgreSQL database naming rules
264
+ // PostgreSQL database naming rules (also valid for MySQL)
259
265
  if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(input)) {
260
266
  return 'Database name must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens'
261
267
  }
@@ -282,12 +288,12 @@ export type CreateOptions = {
282
288
  * Full interactive create flow
283
289
  */
284
290
  export async function promptCreateOptions(): Promise<CreateOptions> {
285
- console.log(chalk.cyan('\n 🗄️ Create New Database Container\n'))
291
+ console.log(chalk.cyan('\n Create New Database Container\n'))
286
292
 
287
293
  const engine = await promptEngine()
288
294
  const version = await promptVersion(engine)
289
295
  const name = await promptContainerName()
290
- const database = await promptDatabaseName(name) // Default to container name
296
+ const database = await promptDatabaseName(name, engine) // Default to container name
291
297
 
292
298
  // Get engine-specific default port
293
299
  const engineDefaults = getEngineDefaults(engine)
@@ -333,7 +339,9 @@ export async function promptInstallDependencies(
333
339
  if (dep) {
334
340
  const instructions = getManualInstallInstructions(dep, platform)
335
341
  console.log(
336
- chalk.gray(` Please install ${engineDeps.displayName} client tools:`),
342
+ chalk.gray(
343
+ ` Please install ${engineDeps.displayName} client tools:`,
344
+ ),
337
345
  )
338
346
  console.log()
339
347
  for (const instruction of instructions) {
@@ -346,7 +354,9 @@ export async function promptInstallDependencies(
346
354
  }
347
355
 
348
356
  console.log(
349
- chalk.gray(` Detected package manager: ${chalk.white(packageManager.name)}`),
357
+ chalk.gray(
358
+ ` Detected package manager: ${chalk.white(packageManager.name)}`,
359
+ ),
350
360
  )
351
361
  console.log()
352
362
 
@@ -425,9 +435,7 @@ export async function promptInstallDependencies(
425
435
 
426
436
  if (allSuccess) {
427
437
  console.log()
428
- console.log(
429
- chalk.green(` ${engineName} tools installed successfully!`),
430
- )
438
+ console.log(chalk.green(` ${engineName} tools installed successfully!`))
431
439
  console.log(chalk.gray(' Continuing with your operation...'))
432
440
  console.log()
433
441
  return true
package/cli/ui/theme.ts CHANGED
@@ -40,7 +40,7 @@ export const theme = {
40
40
  info: chalk.blue('ℹ'),
41
41
  arrow: chalk.cyan('→'),
42
42
  bullet: chalk.gray('•'),
43
- database: '🗄️',
43
+ database: '',
44
44
  postgres: '🐘',
45
45
  },
46
46
  }
@@ -12,6 +12,8 @@ export type EngineDefaults = {
12
12
  portRange: { start: number; end: number }
13
13
  /** Supported major versions */
14
14
  supportedVersions: string[]
15
+ /** Latest major version (used for Homebrew package names like postgresql@17) */
16
+ latestVersion: string
15
17
  /** Default superuser name */
16
18
  superuser: string
17
19
  /** Connection string scheme (e.g., 'postgresql', 'mysql') */
@@ -28,10 +30,11 @@ export type EngineDefaults = {
28
30
 
29
31
  export const engineDefaults: Record<string, EngineDefaults> = {
30
32
  postgresql: {
31
- defaultVersion: '16',
33
+ defaultVersion: '17',
32
34
  defaultPort: 5432,
33
35
  portRange: { start: 5432, end: 5500 },
34
36
  supportedVersions: ['14', '15', '16', '17'],
37
+ latestVersion: '17', // Update when PostgreSQL 18 is released
35
38
  superuser: 'postgres',
36
39
  connectionScheme: 'postgresql',
37
40
  logFileName: 'postgres.log',
@@ -44,6 +47,7 @@ export const engineDefaults: Record<string, EngineDefaults> = {
44
47
  defaultPort: 3306,
45
48
  portRange: { start: 3306, end: 3400 },
46
49
  supportedVersions: ['5.7', '8.0', '8.4', '9.0'],
50
+ latestVersion: '9.0', // MySQL doesn't use versioned Homebrew packages, but kept for consistency
47
51
  superuser: 'root',
48
52
  connectionScheme: 'mysql',
49
53
  logFileName: 'mysql.log',
@@ -82,3 +86,22 @@ export function isEngineSupported(engine: string): boolean {
82
86
  export function getSupportedEngines(): string[] {
83
87
  return Object.keys(engineDefaults)
84
88
  }
89
+
90
+ /**
91
+ * Get Homebrew package name for PostgreSQL client tools
92
+ * Returns 'postgresql@17' format for versioned installs
93
+ */
94
+ export function getPostgresHomebrewPackage(): string {
95
+ const version = engineDefaults.postgresql.latestVersion
96
+ return `postgresql@${version}`
97
+ }
98
+
99
+ /**
100
+ * Get the PostgreSQL Homebrew bin path for a given architecture
101
+ * @param arch - 'arm64' or 'x64'
102
+ */
103
+ export function getPostgresHomebrewBinPath(arch: 'arm64' | 'x64'): string {
104
+ const pkg = getPostgresHomebrewPackage()
105
+ const prefix = arch === 'arm64' ? '/opt/homebrew' : '/usr/local'
106
+ return `${prefix}/opt/${pkg}/bin`
107
+ }