spindb 0.6.0 → 0.7.3

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 +421 -294
  2. package/cli/commands/config.ts +7 -1
  3. package/cli/commands/connect.ts +1 -0
  4. package/cli/commands/create.ts +7 -7
  5. package/cli/commands/edit.ts +10 -0
  6. package/cli/commands/engines.ts +10 -188
  7. package/cli/commands/info.ts +7 -14
  8. package/cli/commands/list.ts +2 -9
  9. package/cli/commands/logs.ts +130 -0
  10. package/cli/commands/menu/backup-handlers.ts +798 -0
  11. package/cli/commands/menu/container-handlers.ts +832 -0
  12. package/cli/commands/menu/engine-handlers.ts +382 -0
  13. package/cli/commands/menu/index.ts +184 -0
  14. package/cli/commands/menu/shared.ts +26 -0
  15. package/cli/commands/menu/shell-handlers.ts +331 -0
  16. package/cli/commands/menu/sql-handlers.ts +197 -0
  17. package/cli/commands/menu/update-handlers.ts +94 -0
  18. package/cli/commands/run.ts +150 -0
  19. package/cli/commands/url.ts +19 -5
  20. package/cli/constants.ts +10 -0
  21. package/cli/helpers.ts +152 -0
  22. package/cli/index.ts +5 -2
  23. package/cli/ui/prompts.ts +3 -11
  24. package/config/defaults.ts +5 -29
  25. package/core/binary-manager.ts +2 -2
  26. package/core/container-manager.ts +3 -2
  27. package/core/dependency-manager.ts +0 -163
  28. package/core/error-handler.ts +0 -26
  29. package/core/platform-service.ts +60 -40
  30. package/core/start-with-retry.ts +3 -28
  31. package/core/transaction-manager.ts +0 -8
  32. package/engines/base-engine.ts +10 -0
  33. package/engines/mysql/binary-detection.ts +1 -1
  34. package/engines/mysql/index.ts +78 -2
  35. package/engines/postgresql/index.ts +49 -0
  36. package/package.json +1 -1
  37. package/types/index.ts +7 -4
  38. package/cli/commands/menu.ts +0 -2670
@@ -0,0 +1,150 @@
1
+ import { Command } from 'commander'
2
+ import { existsSync } from 'fs'
3
+ import chalk from 'chalk'
4
+ import { containerManager } from '../../core/container-manager'
5
+ import { processManager } from '../../core/process-manager'
6
+ import { getEngine } from '../../engines'
7
+ import { promptInstallDependencies } from '../ui/prompts'
8
+ import { error, warning } from '../ui/theme'
9
+ import { getMissingDependencies } from '../../core/dependency-manager'
10
+
11
+ export const runCommand = new Command('run')
12
+ .description('Run SQL file or statement against a container')
13
+ .argument('<name>', 'Container name')
14
+ .argument('[file]', 'Path to SQL file')
15
+ .option('-d, --database <name>', 'Target database (defaults to primary)')
16
+ .option('--sql <statement>', 'SQL statement to execute (alternative to file)')
17
+ .action(
18
+ async (
19
+ name: string,
20
+ file: string | undefined,
21
+ options: { database?: string; sql?: string },
22
+ ) => {
23
+ try {
24
+ const containerName = name
25
+
26
+ // Get container config
27
+ const config = await containerManager.getConfig(containerName)
28
+ if (!config) {
29
+ console.error(error(`Container "${containerName}" not found`))
30
+ process.exit(1)
31
+ }
32
+
33
+ const { engine: engineName } = config
34
+
35
+ // Check if running
36
+ const running = await processManager.isRunning(containerName, {
37
+ engine: engineName,
38
+ })
39
+ if (!running) {
40
+ console.error(
41
+ error(
42
+ `Container "${containerName}" is not running. Start it first with: spindb start ${containerName}`,
43
+ ),
44
+ )
45
+ process.exit(1)
46
+ }
47
+
48
+ // Validate: must have either file or --sql, not both
49
+ if (file && options.sql) {
50
+ console.error(
51
+ error('Cannot specify both a file and --sql option. Choose one.'),
52
+ )
53
+ process.exit(1)
54
+ }
55
+
56
+ if (!file && !options.sql) {
57
+ console.error(error('Must provide either a SQL file or --sql option'))
58
+ console.log(
59
+ chalk.gray(' Usage: spindb run <container> <file.sql>'),
60
+ )
61
+ console.log(
62
+ chalk.gray(' or: spindb run <container> --sql "SELECT ..."'),
63
+ )
64
+ process.exit(1)
65
+ }
66
+
67
+ // Validate file exists
68
+ if (file && !existsSync(file)) {
69
+ console.error(error(`SQL file not found: ${file}`))
70
+ process.exit(1)
71
+ }
72
+
73
+ // Get engine
74
+ const engine = getEngine(engineName)
75
+
76
+ // Check for required client tools
77
+ let missingDeps = await getMissingDependencies(engineName)
78
+ if (missingDeps.length > 0) {
79
+ console.log(
80
+ warning(
81
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
82
+ ),
83
+ )
84
+
85
+ // Offer to install
86
+ const installed = await promptInstallDependencies(
87
+ missingDeps[0].binary,
88
+ engineName,
89
+ )
90
+
91
+ if (!installed) {
92
+ process.exit(1)
93
+ }
94
+
95
+ // Verify installation worked
96
+ missingDeps = await getMissingDependencies(engineName)
97
+ if (missingDeps.length > 0) {
98
+ console.error(
99
+ error(
100
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
101
+ ),
102
+ )
103
+ process.exit(1)
104
+ }
105
+
106
+ console.log(chalk.green(' ✓ All required tools are now available'))
107
+ console.log()
108
+ }
109
+
110
+ // Determine target database
111
+ const database = options.database || config.database
112
+
113
+ // Run the SQL
114
+ await engine.runScript(config, {
115
+ file,
116
+ sql: options.sql,
117
+ database,
118
+ })
119
+ } catch (err) {
120
+ const e = err as Error
121
+
122
+ // Check if this is a missing tool error
123
+ const missingToolPatterns = [
124
+ 'psql not found',
125
+ 'mysql not found',
126
+ 'mysql client not found',
127
+ ]
128
+
129
+ const matchingPattern = missingToolPatterns.find((p) =>
130
+ e.message.toLowerCase().includes(p.toLowerCase()),
131
+ )
132
+
133
+ if (matchingPattern) {
134
+ const missingTool = matchingPattern
135
+ .replace(' not found', '')
136
+ .replace(' client', '')
137
+ const installed = await promptInstallDependencies(missingTool)
138
+ if (installed) {
139
+ console.log(
140
+ chalk.yellow(' Please re-run your command to continue.'),
141
+ )
142
+ }
143
+ process.exit(1)
144
+ }
145
+
146
+ console.error(error(e.message))
147
+ process.exit(1)
148
+ }
149
+ },
150
+ )
@@ -11,10 +11,11 @@ export const urlCommand = new Command('url')
11
11
  .argument('[name]', 'Container name')
12
12
  .option('-c, --copy', 'Copy to clipboard')
13
13
  .option('-d, --database <database>', 'Use different database name')
14
+ .option('--json', 'Output as JSON with additional connection info')
14
15
  .action(
15
16
  async (
16
17
  name: string | undefined,
17
- options: { copy?: boolean; database?: string },
18
+ options: { copy?: boolean; database?: string; json?: boolean },
18
19
  ) => {
19
20
  try {
20
21
  let containerName = name
@@ -45,10 +46,23 @@ export const urlCommand = new Command('url')
45
46
 
46
47
  // Get connection string
47
48
  const engine = getEngine(config.engine)
48
- const connectionString = engine.getConnectionString(
49
- config,
50
- options.database,
51
- )
49
+ const databaseName = options.database || config.database
50
+ const connectionString = engine.getConnectionString(config, databaseName)
51
+
52
+ // JSON output
53
+ if (options.json) {
54
+ const jsonOutput = {
55
+ connectionString,
56
+ host: '127.0.0.1',
57
+ port: config.port,
58
+ database: databaseName,
59
+ user: config.engine === 'postgresql' ? 'postgres' : 'root',
60
+ engine: config.engine,
61
+ container: config.name,
62
+ }
63
+ console.log(JSON.stringify(jsonOutput, null, 2))
64
+ return
65
+ }
52
66
 
53
67
  // Copy to clipboard if requested
54
68
  if (options.copy) {
@@ -0,0 +1,10 @@
1
+ export const ENGINE_ICONS: Record<string, string> = {
2
+ postgresql: '🐘',
3
+ mysql: '🐬',
4
+ }
5
+
6
+ export const DEFAULT_ENGINE_ICON = '▣'
7
+
8
+ export function getEngineIcon(engine: string): string {
9
+ return ENGINE_ICONS[engine] || DEFAULT_ENGINE_ICON
10
+ }
package/cli/helpers.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { existsSync } from 'fs'
2
+ import { readdir, lstat } from 'fs/promises'
3
+ import { join } from 'path'
4
+ import { exec } from 'child_process'
5
+ import { promisify } from 'util'
6
+ import { paths } from '../config/paths'
7
+ import {
8
+ getMysqldPath,
9
+ getMysqlVersion,
10
+ isMariaDB,
11
+ } from '../engines/mysql/binary-detection'
12
+
13
+ const execAsync = promisify(exec)
14
+
15
+ export type InstalledPostgresEngine = {
16
+ engine: 'postgresql'
17
+ version: string
18
+ platform: string
19
+ arch: string
20
+ path: string
21
+ sizeBytes: number
22
+ source: 'downloaded'
23
+ }
24
+
25
+ export type InstalledMysqlEngine = {
26
+ engine: 'mysql'
27
+ version: string
28
+ path: string
29
+ source: 'system'
30
+ isMariaDB: boolean
31
+ }
32
+
33
+ export type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
34
+
35
+ async function getPostgresVersion(binPath: string): Promise<string | null> {
36
+ const postgresPath = join(binPath, 'bin', 'postgres')
37
+ if (!existsSync(postgresPath)) {
38
+ return null
39
+ }
40
+
41
+ try {
42
+ const { stdout } = await execAsync(`"${postgresPath}" --version`)
43
+ const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
44
+ return match ? match[1] : null
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ export async function getInstalledPostgresEngines(): Promise<InstalledPostgresEngine[]> {
51
+ const binDir = paths.bin
52
+
53
+ if (!existsSync(binDir)) {
54
+ return []
55
+ }
56
+
57
+ const entries = await readdir(binDir, { withFileTypes: true })
58
+ const engines: InstalledPostgresEngine[] = []
59
+
60
+ for (const entry of entries) {
61
+ if (entry.isDirectory()) {
62
+ const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
63
+ if (match && match[1] === 'postgresql') {
64
+ const [, , majorVersion, platform, arch] = match
65
+ const dirPath = join(binDir, entry.name)
66
+
67
+ const actualVersion =
68
+ (await getPostgresVersion(dirPath)) || majorVersion
69
+
70
+ let sizeBytes = 0
71
+ try {
72
+ const files = await readdir(dirPath, { recursive: true })
73
+ for (const file of files) {
74
+ try {
75
+ const filePath = join(dirPath, file.toString())
76
+ const fileStat = await lstat(filePath)
77
+ if (fileStat.isFile()) {
78
+ sizeBytes += fileStat.size
79
+ }
80
+ } catch {
81
+ // Skip files we can't stat
82
+ }
83
+ }
84
+ } catch {
85
+ // Skip directories we can't read
86
+ }
87
+
88
+ engines.push({
89
+ engine: 'postgresql',
90
+ version: actualVersion,
91
+ platform,
92
+ arch,
93
+ path: dirPath,
94
+ sizeBytes,
95
+ source: 'downloaded',
96
+ })
97
+ }
98
+ }
99
+ }
100
+
101
+ engines.sort((a, b) => compareVersions(b.version, a.version))
102
+
103
+ return engines
104
+ }
105
+
106
+ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
107
+ const mysqldPath = await getMysqldPath()
108
+ if (!mysqldPath) {
109
+ return null
110
+ }
111
+
112
+ const version = await getMysqlVersion(mysqldPath)
113
+ if (!version) {
114
+ return null
115
+ }
116
+
117
+ const mariadb = await isMariaDB()
118
+
119
+ return {
120
+ engine: 'mysql',
121
+ version,
122
+ path: mysqldPath,
123
+ source: 'system',
124
+ isMariaDB: mariadb,
125
+ }
126
+ }
127
+
128
+ export function compareVersions(a: string, b: string): number {
129
+ const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
130
+ const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
131
+
132
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
133
+ const numA = partsA[i] || 0
134
+ const numB = partsB[i] || 0
135
+ if (numA !== numB) return numA - numB
136
+ }
137
+ return 0
138
+ }
139
+
140
+ export async function getInstalledEngines(): Promise<InstalledEngine[]> {
141
+ const engines: InstalledEngine[] = []
142
+
143
+ const pgEngines = await getInstalledPostgresEngines()
144
+ engines.push(...pgEngines)
145
+
146
+ const mysqlEngine = await getInstalledMysqlEngine()
147
+ if (mysqlEngine) {
148
+ engines.push(mysqlEngine)
149
+ }
150
+
151
+ return engines
152
+ }
package/cli/index.ts CHANGED
@@ -22,6 +22,8 @@ import { urlCommand } from './commands/url'
22
22
  import { infoCommand } from './commands/info'
23
23
  import { selfUpdateCommand } from './commands/self-update'
24
24
  import { versionCommand } from './commands/version'
25
+ import { runCommand } from './commands/run'
26
+ import { logsCommand } from './commands/logs'
25
27
  import { updateManager } from '../core/update-manager'
26
28
 
27
29
  /**
@@ -119,11 +121,12 @@ export async function run(): Promise<void> {
119
121
  program.addCommand(infoCommand)
120
122
  program.addCommand(selfUpdateCommand)
121
123
  program.addCommand(versionCommand)
124
+ program.addCommand(runCommand)
125
+ program.addCommand(logsCommand)
122
126
 
123
127
  // If no arguments provided, show interactive menu
124
128
  if (process.argv.length <= 2) {
125
- const { menuCommand: menu } = await import('./commands/menu')
126
- await menu.parseAsync([])
129
+ await menuCommand.parseAsync([])
127
130
  return
128
131
  }
129
132
 
package/cli/ui/prompts.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  installEngineDependencies,
12
12
  } from '../../core/dependency-manager'
13
13
  import { getEngineDependencies } from '../../config/os-dependencies'
14
+ import { getEngineIcon } from '../constants'
14
15
  import type { ContainerConfig } from '../../types'
15
16
 
16
17
  /**
@@ -37,23 +38,14 @@ export async function promptContainerName(
37
38
  return name
38
39
  }
39
40
 
40
- /**
41
- * Engine icons for display
42
- */
43
- const engineIcons: Record<string, string> = {
44
- postgresql: '🐘',
45
- mysql: '🐬',
46
- }
47
-
48
41
  /**
49
42
  * Prompt for database engine selection
50
43
  */
51
44
  export async function promptEngine(): Promise<string> {
52
45
  const engines = listEngines()
53
46
 
54
- // Build choices from available engines
55
47
  const choices = engines.map((e) => ({
56
- name: `${engineIcons[e.name] || '▣'} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
48
+ name: `${getEngineIcon(e.name)} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
57
49
  value: e.name,
58
50
  short: e.displayName,
59
51
  }))
@@ -227,7 +219,7 @@ export async function promptContainerSelect(
227
219
  name: 'container',
228
220
  message,
229
221
  choices: containers.map((c) => ({
230
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine} ${c.version}, port ${c.port})`)} ${
222
+ name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine} ${c.version}, port ${c.port})`)} ${
231
223
  c.status === 'running'
232
224
  ? chalk.green('● running')
233
225
  : chalk.gray('○ stopped')
@@ -5,6 +5,7 @@ import {
5
5
  getSupportedEngines,
6
6
  type EngineDefaults,
7
7
  } from './engine-defaults'
8
+ import { Engine } from '../types'
8
9
 
9
10
  // Re-export engine-related functions and types
10
11
  export {
@@ -24,50 +25,25 @@ export type PortRange = {
24
25
  end: number
25
26
  }
26
27
 
27
- /**
28
- * Legacy Defaults type - kept for backward compatibility
29
- * New code should use getEngineDefaults(engine) instead
30
- */
31
28
  export type Defaults = {
32
- /** @deprecated Use getEngineDefaults(engine).defaultVersion instead */
33
- postgresVersion: string
34
29
  port: number
35
30
  portRange: PortRange
36
- engine: string
37
- /** @deprecated Use getEngineDefaults(engine).supportedVersions instead */
38
- supportedPostgresVersions: string[]
31
+ engine: Engine
39
32
  superuser: string
40
33
  platformMappings: PlatformMappings
41
34
  }
42
35
 
43
- // Get PostgreSQL defaults from engine-defaults
44
36
  const pgDefaults = engineDefaults.postgresql
45
37
 
46
38
  /**
47
- * Default configuration values
48
- * For backward compatibility, this defaults to PostgreSQL settings.
49
- * New code should use getEngineDefaults(engine) for engine-specific defaults.
39
+ * Default configuration values (PostgreSQL-based defaults)
40
+ * Use getEngineDefaults(engine) for engine-specific defaults.
50
41
  */
51
42
  export const defaults: Defaults = {
52
- // Default PostgreSQL version (from engine defaults)
53
- postgresVersion: pgDefaults.defaultVersion,
54
-
55
- // Default port (standard PostgreSQL port)
56
43
  port: pgDefaults.defaultPort,
57
-
58
- // Port range to scan if default is busy
59
44
  portRange: pgDefaults.portRange,
60
-
61
- // Default engine
62
- engine: 'postgresql',
63
-
64
- // Supported PostgreSQL versions (from engine defaults)
65
- supportedPostgresVersions: pgDefaults.supportedVersions,
66
-
67
- // Default superuser (from engine defaults)
45
+ engine: Engine.PostgreSQL,
68
46
  superuser: pgDefaults.superuser,
69
-
70
- // Platform mappings for zonky.io binaries (PostgreSQL specific)
71
47
  platformMappings: {
72
48
  'darwin-arm64': 'darwin-arm64v8',
73
49
  'darwin-x64': 'darwin-amd64',
@@ -6,7 +6,7 @@ import { exec } from 'child_process'
6
6
  import { promisify } from 'util'
7
7
  import { paths } from '../config/paths'
8
8
  import { defaults } from '../config/defaults'
9
- import type { ProgressCallback, InstalledBinary } from '../types'
9
+ import { Engine, type ProgressCallback, type InstalledBinary } from '../types'
10
10
 
11
11
  const execAsync = promisify(exec)
12
12
 
@@ -91,7 +91,7 @@ export class BinaryManager {
91
91
  const parts = entry.name.split('-')
92
92
  if (parts.length >= 4) {
93
93
  installed.push({
94
- engine: parts[0],
94
+ engine: parts[0] as Engine,
95
95
  version: parts[1],
96
96
  platform: parts[2],
97
97
  arch: parts[3],
@@ -5,10 +5,11 @@ import { processManager } from './process-manager'
5
5
  import { portManager } from './port-manager'
6
6
  import { getEngineDefaults, getSupportedEngines } from '../config/defaults'
7
7
  import { getEngine } from '../engines'
8
- import type { ContainerConfig, EngineName } from '../types'
8
+ import type { ContainerConfig } from '../types'
9
+ import { Engine } from '../types'
9
10
 
10
11
  export type CreateOptions = {
11
- engine: EngineName
12
+ engine: Engine
12
13
  version: string
13
14
  port: number
14
15
  database: string